chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
forkBlockerResolution,
|
||||
selectVisibleClearedRefs,
|
||||
splitForkClearedRefs,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
|
||||
type ReferenceRef = Extract<ForkClearedRef, { cause: 'reference' }>
|
||||
type WorkflowRef = Extract<ForkClearedRef, { cause: 'workflow' }>
|
||||
type DependentRef = Extract<ForkClearedRef, { cause: 'dependent' }>
|
||||
|
||||
const base = {
|
||||
targetWorkflowId: 'wf-tgt',
|
||||
workflowName: 'Workflow',
|
||||
blockId: 'block-1',
|
||||
blockLabel: 'Block',
|
||||
sourceLabel: 'Source',
|
||||
}
|
||||
|
||||
const referenceRef = (
|
||||
kind: ReferenceRef['kind'],
|
||||
sourceId: string,
|
||||
fieldLabel = 'Field',
|
||||
sourceDeleted = false
|
||||
): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted })
|
||||
|
||||
const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({
|
||||
...base,
|
||||
fieldLabel,
|
||||
cause: 'workflow',
|
||||
kind: 'workflow',
|
||||
sourceId,
|
||||
})
|
||||
|
||||
const dependentRef = (
|
||||
parentKind: DependentRef['parentKind'],
|
||||
parentSourceId: string,
|
||||
fieldLabel = 'Field'
|
||||
): DependentRef => ({
|
||||
...base,
|
||||
fieldLabel,
|
||||
cause: 'dependent',
|
||||
kind: parentKind,
|
||||
sourceId: parentSourceId,
|
||||
parentKind,
|
||||
parentSourceId,
|
||||
})
|
||||
|
||||
// The page's predicate is `mapped || copied`; here we model each disposition as a resolved key so
|
||||
// the document-under-KB case is exercised for both a copied parent and a mapped parent.
|
||||
const resolvedKeys = (...keys: string[]) => {
|
||||
const set = new Set(keys)
|
||||
return (kind: string, sourceId: string) => set.has(`${kind}:${sourceId}`)
|
||||
}
|
||||
|
||||
const documentDependent = dependentRef('knowledge-base', 'kb-1', 'Document')
|
||||
|
||||
describe('selectVisibleClearedRefs', () => {
|
||||
it('drops a document dependent when its parent KB is selected for copy', () => {
|
||||
expect(
|
||||
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a document dependent when its parent KB is mapped', () => {
|
||||
// Map vs copy both resolve the parent through the same predicate; modeled identically here.
|
||||
expect(
|
||||
selectVisibleClearedRefs([documentDependent], resolvedKeys('knowledge-base:kb-1'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a document dependent while its parent KB is neither mapped nor copied', () => {
|
||||
expect(selectVisibleClearedRefs([documentDependent], resolvedKeys())).toEqual([
|
||||
documentDependent,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a credential-anchored dependent even when the credential is mapped (label still clears)', () => {
|
||||
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
|
||||
// A mapped credential remaps to a different account, so the account-scoped label is cleared
|
||||
// regardless - the entry must stay even though the parent is "resolved".
|
||||
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys('credential:cred-1'))).toEqual([
|
||||
labelDependent,
|
||||
])
|
||||
expect(selectVisibleClearedRefs([labelDependent], resolvedKeys())).toEqual([labelDependent])
|
||||
})
|
||||
|
||||
it('keeps a table-anchored dependent even when the table is copied/mapped (column still clears)', () => {
|
||||
const columnDependent = dependentRef('table', 'tbl-1', 'Column')
|
||||
expect(selectVisibleClearedRefs([columnDependent], resolvedKeys('table:tbl-1'))).toEqual([
|
||||
columnDependent,
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the parent KB reference AND its child document together when the KB is resolved', () => {
|
||||
const kbReference = referenceRef('knowledge-base', 'kb-1', 'Knowledge Base')
|
||||
expect(
|
||||
selectVisibleClearedRefs(
|
||||
[kbReference, documentDependent],
|
||||
resolvedKeys('knowledge-base:kb-1')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('applies the same predicate to a reference entry (drops resolved, keeps unresolved)', () => {
|
||||
const credentialReference = referenceRef('credential', 'cred-1')
|
||||
expect(
|
||||
selectVisibleClearedRefs([credentialReference], resolvedKeys('credential:cred-1'))
|
||||
).toEqual([])
|
||||
expect(selectVisibleClearedRefs([credentialReference], resolvedKeys())).toEqual([
|
||||
credentialReference,
|
||||
])
|
||||
})
|
||||
|
||||
it('always keeps a workflow reference (it cannot be resolved on the page)', () => {
|
||||
const workflowReference = workflowRef('wf-other')
|
||||
expect(
|
||||
selectVisibleClearedRefs([workflowReference], resolvedKeys('workflow:wf-other'))
|
||||
).toEqual([workflowReference])
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitForkClearedRefs', () => {
|
||||
it('splits reference/workflow causes into blockers and dependents into informational', () => {
|
||||
const tableReference = referenceRef('table', 'tbl-1')
|
||||
const workflowReference = workflowRef('wf-other')
|
||||
const labelDependent = dependentRef('credential', 'cred-1', 'Label')
|
||||
const { blockers, informational } = splitForkClearedRefs([
|
||||
tableReference,
|
||||
workflowReference,
|
||||
labelDependent,
|
||||
])
|
||||
expect(blockers).toEqual([tableReference, workflowReference])
|
||||
expect(informational).toEqual([labelDependent])
|
||||
})
|
||||
|
||||
it('treats an unmapped MCP server and a source-deleted reference as blockers', () => {
|
||||
const mcpReference = referenceRef('mcp-server', 'srv-1')
|
||||
const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true)
|
||||
const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference])
|
||||
expect(blockers).toEqual([mcpReference, deletedReference])
|
||||
expect(informational).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkBlockerResolution', () => {
|
||||
it('phrases each blocker reason with its actionable resolution', () => {
|
||||
expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe(
|
||||
'map it to a target or select it for copy'
|
||||
)
|
||||
expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe(
|
||||
'map it to a target or select it for copy'
|
||||
)
|
||||
expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe(
|
||||
'deleted in the source — map it to an existing knowledge base in the target'
|
||||
)
|
||||
expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe(
|
||||
'deploy "Source" in the source or remove the reference'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for non-blocking dependent entries', () => {
|
||||
expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork'
|
||||
import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers'
|
||||
|
||||
/** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */
|
||||
export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean
|
||||
|
||||
/**
|
||||
* Parent kinds whose dependent child is itself a resource carried alongside the parent, so
|
||||
* resolving the parent (mapping or copying it) PRESERVES the child rather than clearing it -
|
||||
* currently just knowledge bases: a referenced document is copied with its KB, or auto-copied into
|
||||
* a mapped KB, and remapped in place. Any other parent's child (a credential's label, a table's
|
||||
* column) is account/table-scoped and cleared whenever the parent is remapped (the engine's
|
||||
* `clearDependentsOnRemap`), so those entries stay regardless of the parent's disposition.
|
||||
*/
|
||||
const PARENT_KINDS_THAT_PRESERVE_CHILD: ReadonlySet<string> = new Set(['knowledge-base'])
|
||||
|
||||
/**
|
||||
* Narrow the diff's cleared-ref candidates to those still cleared under the live selection:
|
||||
* - `reference`: drops off once its own resource is resolved (mapped or copied).
|
||||
* - `dependent`: drops off once its PARENT resource (`parentKind`/`parentSourceId`) is resolved -
|
||||
* using the SAME predicate as `reference` - but ONLY when the child follows that parent (a
|
||||
* document under a KB). A credential- or table-anchored dependent is cleared on any parent remap,
|
||||
* so it stays even after the parent is mapped.
|
||||
* - `workflow`: always stays - a cross-workflow reference cannot be resolved here.
|
||||
*
|
||||
* Pure so the reactive list is unit-testable independent of the page's selection state.
|
||||
*/
|
||||
export function selectVisibleClearedRefs(
|
||||
clearedRefs: ForkClearedRef[],
|
||||
isResolved: ClearedRefResolvedPredicate
|
||||
): ForkClearedRef[] {
|
||||
return clearedRefs.filter((ref) => {
|
||||
if (ref.cause === 'reference') return !isResolved(ref.kind, ref.sourceId)
|
||||
// The discriminated union guarantees `parentKind`/`parentSourceId` on a `dependent` variant.
|
||||
if (ref.cause === 'dependent' && PARENT_KINDS_THAT_PRESERVE_CHILD.has(ref.parentKind)) {
|
||||
return !isResolved(ref.parentKind, ref.parentSourceId)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the
|
||||
* sync is disabled while any remain) and the informational remainder (`dependent` entries, owned
|
||||
* by the reconfigure flow - they clear but never block). Pure, so the page's gate and the two
|
||||
* sections stay one testable rule.
|
||||
*/
|
||||
export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): {
|
||||
blockers: ForkClearedRef[]
|
||||
informational: ForkClearedRef[]
|
||||
} {
|
||||
const blockers: ForkClearedRef[] = []
|
||||
const informational: ForkClearedRef[] = []
|
||||
for (const ref of visibleRefs) {
|
||||
if (forkSyncBlockerReasonFor(ref)) blockers.push(ref)
|
||||
else informational.push(ref)
|
||||
}
|
||||
return { blockers, informational }
|
||||
}
|
||||
|
||||
/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */
|
||||
const BLOCKER_KIND_LABEL: Record<string, string> = {
|
||||
table: 'table',
|
||||
'knowledge-base': 'knowledge base',
|
||||
file: 'file',
|
||||
'custom-tool': 'custom tool',
|
||||
skill: 'skill',
|
||||
'mcp-server': 'MCP server',
|
||||
}
|
||||
|
||||
/**
|
||||
* The actionable resolution line for a blocking entry, phrased for "{block} would lose {field}
|
||||
* in {workflow} - {resolution}". Null for non-blocking (dependent) entries.
|
||||
*/
|
||||
export function forkBlockerResolution(ref: ForkClearedRef): string | null {
|
||||
const reason = forkSyncBlockerReasonFor(ref)
|
||||
if (!reason) return null
|
||||
switch (reason) {
|
||||
case 'unmapped-copyable':
|
||||
return 'map it to a target or select it for copy'
|
||||
case 'source-deleted':
|
||||
return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target`
|
||||
case 'workflow-missing':
|
||||
return `deploy "${ref.sourceLabel}" in the source or remove the reference`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
effectiveForkTarget,
|
||||
forkCopyingKeys,
|
||||
forkDefaultCopySelection,
|
||||
forkMappedCopyableKeys,
|
||||
forkParentResolution,
|
||||
forkRefKey,
|
||||
forkRequiredKindsLabel,
|
||||
forkRequiredPending,
|
||||
forkVisibleCopyables,
|
||||
isForkRequiredComplete,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
|
||||
const entry = (overrides: Partial<ForkMappingEntry>): ForkMappingEntry => ({
|
||||
kind: 'credential',
|
||||
resourceType: 'oauth_credential',
|
||||
sourceId: 'src',
|
||||
sourceLabel: 'Src',
|
||||
targetId: null,
|
||||
suggested: false,
|
||||
required: false,
|
||||
candidates: [],
|
||||
candidatesTruncated: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const copyable = (overrides: Partial<ForkCopyableUnmapped>): ForkCopyableUnmapped => ({
|
||||
kind: 'knowledge-base',
|
||||
sourceId: 'kb',
|
||||
label: 'KB',
|
||||
parentId: null,
|
||||
parentLabel: null,
|
||||
referenced: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('forkRefKey / effectiveForkTarget', () => {
|
||||
it('shares the kind:sourceId keyspace for entries and copyables', () => {
|
||||
expect(forkRefKey({ kind: 'knowledge-base', sourceId: 'kb-1' })).toBe('knowledge-base:kb-1')
|
||||
expect(forkRefKey(entry({ kind: 'table', sourceId: 't-1' }))).toBe('table:t-1')
|
||||
})
|
||||
|
||||
it('prefers an in-session override, else the persisted target, else empty', () => {
|
||||
const e = entry({ kind: 'table', sourceId: 't-1', targetId: 'persisted' })
|
||||
expect(effectiveForkTarget(e, { 'table:t-1': 'in-session' })).toBe('in-session')
|
||||
expect(effectiveForkTarget(e, {})).toBe('persisted')
|
||||
expect(effectiveForkTarget(entry({ kind: 'table', sourceId: 't-1' }), {})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy-vs-map reconciliation', () => {
|
||||
it('drops a mapped copyable from the visible copy list (maps win over copy)', () => {
|
||||
const entries = [
|
||||
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
|
||||
entry({ kind: 'table', sourceId: 'tbl-1' }),
|
||||
]
|
||||
const candidates = [
|
||||
copyable({ kind: 'knowledge-base', sourceId: 'kb-1' }),
|
||||
copyable({ kind: 'table', sourceId: 'tbl-1' }),
|
||||
]
|
||||
const mapped = forkMappedCopyableKeys(entries, {})
|
||||
expect(mapped.has('knowledge-base:kb-1')).toBe(true)
|
||||
expect(mapped.has('table:tbl-1')).toBe(false)
|
||||
expect(forkVisibleCopyables(candidates, mapped).map(forkRefKey)).toEqual(['table:tbl-1'])
|
||||
})
|
||||
|
||||
it('an in-session mapping target also drops the copyable', () => {
|
||||
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1' })]
|
||||
const mapped = forkMappedCopyableKeys(entries, { 'knowledge-base:kb-1': 'kb-tgt' })
|
||||
expect(
|
||||
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mapped)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('copyingKeys = the visible candidates that are selected for copy', () => {
|
||||
const visible = [
|
||||
copyable({ kind: 'table', sourceId: 'tbl-1' }),
|
||||
copyable({ kind: 'skill', sourceId: 'sk-1' }),
|
||||
]
|
||||
expect([...forkCopyingKeys(visible, new Set(['table:tbl-1']))]).toEqual(['table:tbl-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkDefaultCopySelection', () => {
|
||||
it('seeds every referenced candidate and leaves unreferenced ones unselected', () => {
|
||||
const selection = forkDefaultCopySelection([
|
||||
copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }),
|
||||
copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }),
|
||||
copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }),
|
||||
])
|
||||
expect(selection).toEqual(new Set(['knowledge-base:kb-1']))
|
||||
})
|
||||
|
||||
it('seeds nothing when every candidate is unreferenced', () => {
|
||||
expect(
|
||||
forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })])
|
||||
).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('isForkRequiredComplete', () => {
|
||||
it('a required ref is satisfied by a mapping target', () => {
|
||||
const entries = [
|
||||
entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' }),
|
||||
]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('a required ref is satisfied by a copy selection (server accepts copy as resolving it)', () => {
|
||||
const entries = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set(['knowledge-base:kb-1']))).toBe(true)
|
||||
})
|
||||
|
||||
it('a required ref neither mapped nor copied blocks', () => {
|
||||
const entries = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
|
||||
})
|
||||
|
||||
it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => {
|
||||
const entries = [
|
||||
entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }),
|
||||
]
|
||||
// MCP servers are never copy candidates, so the copy set can't contain them; only a
|
||||
// mapping target resolves the entry.
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
|
||||
expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => {
|
||||
// A deleted source is dropped from the copy candidates (its label lookup fails), so the
|
||||
// only resolution is mapping the dead id to a live target resource.
|
||||
const entries = [
|
||||
entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }),
|
||||
]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false)
|
||||
expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('optional refs never block', () => {
|
||||
const entries = [entry({ kind: 'table', sourceId: 't1', required: false })]
|
||||
expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkRequiredKindsLabel', () => {
|
||||
it('names credentials and secrets by kind, together or alone', () => {
|
||||
expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe(
|
||||
'credentials and secrets'
|
||||
)
|
||||
expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials')
|
||||
expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets')
|
||||
})
|
||||
|
||||
it('falls back to "references" for any other (or empty) kind set', () => {
|
||||
expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references')
|
||||
expect(forkRequiredKindsLabel(new Set())).toBe('references')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkParentResolution', () => {
|
||||
const kb = entry({ kind: 'knowledge-base', sourceId: 'kb-1' })
|
||||
|
||||
it('is copied when the entry is selected for copy', () => {
|
||||
expect(forkParentResolution(kb, {}, new Set(['knowledge-base:kb-1']))).toBe('copied')
|
||||
})
|
||||
|
||||
it('is mapped with a persisted or in-session target, unresolved with neither', () => {
|
||||
expect(
|
||||
forkParentResolution(
|
||||
entry({ kind: 'knowledge-base', sourceId: 'kb-1', targetId: 'kb-tgt' }),
|
||||
{},
|
||||
new Set()
|
||||
)
|
||||
).toBe('mapped')
|
||||
expect(forkParentResolution(kb, { 'knowledge-base:kb-1': 'kb-tgt' }, new Set())).toBe('mapped')
|
||||
expect(forkParentResolution(kb, {}, new Set())).toBe('unresolved')
|
||||
})
|
||||
|
||||
it('toggling copy⇄map flips the resolution (the selector scope + seed follow it)', () => {
|
||||
// Copy-selected: the dependent selectors browse the SOURCE parent.
|
||||
const copying = new Set(['knowledge-base:kb-1'])
|
||||
expect(forkParentResolution(kb, {}, copying)).toBe('copied')
|
||||
// The user maps a target instead: the mapped entry drops out of the visible copyables
|
||||
// (copy-vs-map reconciliation), so its copying key disappears and the resolution flips.
|
||||
const targets = { 'knowledge-base:kb-1': 'kb-tgt' }
|
||||
const mappedKeys = forkMappedCopyableKeys([kb], targets)
|
||||
const copyingAfterMap = forkCopyingKeys(
|
||||
forkVisibleCopyables([copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })], mappedKeys),
|
||||
copying
|
||||
)
|
||||
expect(forkParentResolution(kb, targets, copyingAfterMap)).toBe('mapped')
|
||||
// Back to copy ('' target override): the copyable is visible + still selected again.
|
||||
const cleared = { 'knowledge-base:kb-1': '' }
|
||||
const copyingAfterClear = forkCopyingKeys(
|
||||
forkVisibleCopyables(
|
||||
[copyable({ kind: 'knowledge-base', sourceId: 'kb-1' })],
|
||||
forkMappedCopyableKeys([kb], cleared)
|
||||
),
|
||||
copying
|
||||
)
|
||||
expect(forkParentResolution(kb, cleared, copyingAfterClear)).toBe('copied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkRequiredPending', () => {
|
||||
it('is true when a required ref is neither mapped nor selected for copy', () => {
|
||||
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })]
|
||||
expect(forkRequiredPending(items, {}, new Set())).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when the required ref is selected for copy', () => {
|
||||
const items = [entry({ kind: 'knowledge-base', sourceId: 'kb-1', required: true })]
|
||||
expect(forkRequiredPending(items, {}, new Set(['knowledge-base:kb-1']))).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when the required ref is mapped', () => {
|
||||
const items = [entry({ kind: 'credential', sourceId: 'c1', required: true, targetId: 'c-tgt' })]
|
||||
expect(forkRequiredPending(items, {}, new Set())).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ForkCopyableUnmapped, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
/** `${kind}:${sourceId}` - the shared key for a mapping entry and its copy candidate. */
|
||||
export const forkRefKey = (ref: { kind: string; sourceId: string }): string =>
|
||||
`${ref.kind}:${ref.sourceId}`
|
||||
|
||||
/** Effective mapping target: the in-session override, else the persisted target, else ''. */
|
||||
export function effectiveForkTarget(
|
||||
entry: ForkMappingEntry,
|
||||
targets: Record<string, string>
|
||||
): string {
|
||||
return targets[forkRefKey(entry)] ?? entry.targetId ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of copyable resources that already have a mapping target (in-session or persisted). Maps win
|
||||
* over copy, so these drop out of the copy list - the copy-vs-map reconciliation.
|
||||
*/
|
||||
export function forkMappedCopyableKeys(
|
||||
entries: ForkMappingEntry[],
|
||||
targets: Record<string, string>
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (effectiveForkTarget(entry, targets) !== '') keys.add(forkRefKey(entry))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Copy candidates the user has not mapped (a mapped copyable is excluded - copy-vs-map reconcile). */
|
||||
export function forkVisibleCopyables(
|
||||
copyableUnmapped: ForkCopyableUnmapped[],
|
||||
mappedKeys: ReadonlySet<string>
|
||||
): ForkCopyableUnmapped[] {
|
||||
return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate)))
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one
|
||||
* clears its references, so the common case needs no clicks). Unreferenced candidates - used by
|
||||
* no synced workflow - start unselected: copying them is opt-in, so scratch data created in the
|
||||
* source is never pushed by surprise.
|
||||
*/
|
||||
export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const candidate of copyableUnmapped) {
|
||||
if (candidate.referenced) keys.add(forkRefKey(candidate))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** Keys of the visible copy candidates actually selected for copy. */
|
||||
export function forkCopyingKeys(
|
||||
visibleCopyables: ForkCopyableUnmapped[],
|
||||
copySelected: ReadonlySet<string>
|
||||
): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
for (const candidate of visibleCopyables) {
|
||||
const key = forkRefKey(candidate)
|
||||
if (copySelected.has(key)) keys.add(key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* How a mapping entry is resolved under the live selection, for its dependents' behavior:
|
||||
* - `copied`: selected for copy - dependents stay editable against the SOURCE parent (the copy
|
||||
* will contain the source's children), seeded from the source reference.
|
||||
* - `mapped`: has an effective target - dependents re-pick against the TARGET parent.
|
||||
* - `unresolved`: neither - dependents are disabled; the parent's own gate owns the block.
|
||||
* Mapped and copied are mutually exclusive by construction (a mapped copyable is excluded from
|
||||
* the copy candidates), so the branch order is not load-bearing.
|
||||
*/
|
||||
export type ForkParentResolution = 'mapped' | 'copied' | 'unresolved'
|
||||
|
||||
export function forkParentResolution(
|
||||
entry: ForkMappingEntry,
|
||||
targets: Record<string, string>,
|
||||
copyingKeys: ReadonlySet<string>
|
||||
): ForkParentResolution {
|
||||
if (copyingKeys.has(forkRefKey(entry))) return 'copied'
|
||||
return effectiveForkTarget(entry, targets) !== '' ? 'mapped' : 'unresolved'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every required reference is satisfied - it has a mapping target OR is selected for copy.
|
||||
* The server accepts a copy as resolving a required ref (promote.ts `willResolve`), so the client
|
||||
* gate must too. No double-count: a mapped copyable is excluded from the copy candidates, so the two
|
||||
* branches are mutually exclusive.
|
||||
*/
|
||||
export function isForkRequiredComplete(
|
||||
entries: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
copyingKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
return entries.every(
|
||||
(entry) =>
|
||||
!entry.required ||
|
||||
effectiveForkTarget(entry, targets) !== '' ||
|
||||
copyingKeys.has(forkRefKey(entry))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any reference in a kind is required AND still unmapped AND not selected for copy - drives
|
||||
* the mapping summary's amber "pending" badge. Mirrors {@link isForkRequiredComplete}'s satisfied rule.
|
||||
*/
|
||||
export function forkRequiredPending(
|
||||
items: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
copyingKeys: ReadonlySet<string>
|
||||
): boolean {
|
||||
return items.some(
|
||||
(entry) =>
|
||||
entry.required &&
|
||||
effectiveForkTarget(entry, targets) === '' &&
|
||||
!copyingKeys.has(forkRefKey(entry))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for the kinds still failing the required gate, for "Map all required {label} first"
|
||||
* messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's
|
||||
* failure toast so both name the obstacle identically. Credentials and secrets are named
|
||||
* explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in
|
||||
* the cleared-ref blockers (the collector excludes them), so they need their own wording. Any
|
||||
* other kind falls back to "references".
|
||||
*/
|
||||
export function forkRequiredKindsLabel(kinds: ReadonlySet<string>): string {
|
||||
const credentials = kinds.has('credential')
|
||||
const secrets = kinds.has('env-var')
|
||||
if (credentials && secrets) return 'credentials and secrets'
|
||||
if (credentials) return 'credentials'
|
||||
if (secrets) return 'secrets'
|
||||
return 'references'
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useSelectorOptions } from '@/hooks/selectors/use-selector-query'
|
||||
|
||||
interface DependentFieldSelectorProps {
|
||||
selectorKey: SelectorKey
|
||||
/** Full selector context, including the newly-chosen parent value. */
|
||||
context: Record<string, string>
|
||||
/** False until the parent (credential/KB) target is chosen. */
|
||||
enabled: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A controlled, standalone selector for the sync page's pre-sync reconfigure: fetches
|
||||
* options via the shared selector data layer (the same `useSelectorOptions` registry the
|
||||
* canvas selectors use) without the canvas store/blockId coupling. Mirrors
|
||||
* {@link ConnectorSelectorField}.
|
||||
*/
|
||||
export function DependentFieldSelector({
|
||||
selectorKey,
|
||||
context,
|
||||
enabled,
|
||||
value,
|
||||
onChange,
|
||||
title,
|
||||
}: DependentFieldSelectorProps) {
|
||||
const selectorContext = useMemo<SelectorContext>(() => {
|
||||
const ctx: SelectorContext = {}
|
||||
Object.assign(ctx, context)
|
||||
return ctx
|
||||
}, [context])
|
||||
|
||||
const { data: options = [], isLoading } = useSelectorOptions(selectorKey, {
|
||||
context: selectorContext,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => options.map((option) => ({ label: option.label, value: option.id })),
|
||||
[options]
|
||||
)
|
||||
|
||||
if (isLoading && enabled) {
|
||||
return (
|
||||
<div className='flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 font-medium text-[var(--text-muted)] text-small dark:bg-[var(--surface-4)]'>
|
||||
<Loader className='size-3.5' animate />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={(next) => onChange(next)}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${title.toLowerCase()}...`}
|
||||
placeholder={`Select ${title.toLowerCase()}`}
|
||||
disabled={!enabled}
|
||||
emptyMessage={`No ${title.toLowerCase()} found`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveCopyDependentValue,
|
||||
effectiveDependentValue,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
|
||||
|
||||
const field = (overrides: Partial<ForkDependentReconfig> = {}): ForkDependentReconfig => ({
|
||||
parentKind: 'credential',
|
||||
parentSourceId: 'cred-1',
|
||||
parentContextKey: 'oauthCredential',
|
||||
targetWorkflowId: 'wf-1',
|
||||
targetBlockId: 'blk-1',
|
||||
blockName: 'Gmail',
|
||||
subBlockKey: 'folder',
|
||||
selectorKey: 'gmail.labels',
|
||||
title: 'Label',
|
||||
currentValue: 'INBOX',
|
||||
required: false,
|
||||
consumesContextKeys: [],
|
||||
context: {},
|
||||
sourceValue: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('dependentKey', () => {
|
||||
it('keys by target workflow + block + subblock', () => {
|
||||
expect(
|
||||
dependentKey(field({ targetWorkflowId: 'w', targetBlockId: 'b', subBlockKey: 's' }))
|
||||
).toBe('w:b:s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveDependentValue', () => {
|
||||
it('returns the in-session re-pick when present', () => {
|
||||
const f = field()
|
||||
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_42' }, false)).toBe('Label_42')
|
||||
})
|
||||
|
||||
it('returns the stored currentValue when no re-pick and the parent is unchanged', () => {
|
||||
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, false)).toBe('INBOX')
|
||||
})
|
||||
|
||||
it('returns blank when the parent changed (the stored value no longer resolves)', () => {
|
||||
expect(effectiveDependentValue(field({ currentValue: 'INBOX' }), {}, true)).toBe('')
|
||||
})
|
||||
|
||||
it('an in-session re-pick wins even when the parent changed', () => {
|
||||
const f = field({ currentValue: 'INBOX' })
|
||||
expect(effectiveDependentValue(f, { [dependentKey(f)]: 'Label_99' }, true)).toBe('Label_99')
|
||||
})
|
||||
|
||||
it('an explicit empty re-pick is respected (not treated as absent)', () => {
|
||||
const f = field({ currentValue: 'INBOX' })
|
||||
expect(effectiveDependentValue(f, { [dependentKey(f)]: '' }, false)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('effectiveCopyDependentValue', () => {
|
||||
const copyField = (overrides: Partial<ForkDependentReconfig> = {}) =>
|
||||
field({
|
||||
parentKind: 'knowledge-base',
|
||||
parentSourceId: 'kb-src',
|
||||
parentContextKey: 'knowledgeBaseId',
|
||||
subBlockKey: 'documentSelector',
|
||||
selectorKey: 'knowledge.documents',
|
||||
title: 'Document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('seeds from the raw source reference when nothing is stored (the copy will contain it)', () => {
|
||||
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, {})).toBe('doc-src')
|
||||
})
|
||||
|
||||
it('prefers the stored value over the source reference (a saved re-pick survives reload)', () => {
|
||||
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, {})).toBe('doc-saved')
|
||||
})
|
||||
|
||||
it('an in-session re-pick wins over both', () => {
|
||||
const f = copyField({ currentValue: 'doc-saved', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: 'doc-picked' })).toBe('doc-picked')
|
||||
})
|
||||
|
||||
it('an explicit empty re-pick is respected (a required field then gates as usual)', () => {
|
||||
const f = copyField({ currentValue: '', sourceValue: 'doc-src' })
|
||||
expect(effectiveCopyDependentValue(f, { [dependentKey(f)]: '' })).toBe('')
|
||||
})
|
||||
|
||||
it('is blank when the source never referenced anything and nothing was stored', () => {
|
||||
const f = copyField({ currentValue: '', sourceValue: '' })
|
||||
expect(effectiveCopyDependentValue(f, {})).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
|
||||
|
||||
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
|
||||
export function dependentKey(dependent: ForkDependentReconfig): string {
|
||||
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
|
||||
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
|
||||
* stored value was for the previous parent and won't resolve against the new one. Shared by the
|
||||
* sync gate + payload build and the per-block selector so the rule can't drift between them.
|
||||
*/
|
||||
export function effectiveDependentValue(
|
||||
field: ForkDependentReconfig,
|
||||
reconfig: Record<string, string>,
|
||||
parentChanged: boolean
|
||||
): string {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
if (repicked !== undefined) return repicked
|
||||
return parentChanged ? '' : field.currentValue
|
||||
}
|
||||
|
||||
/**
|
||||
* The value sent + displayed for a dependent whose parent is resolved by COPY: the user's
|
||||
* in-session re-pick, else the stored value, else the field's raw SOURCE reference. The copy
|
||||
* brings the source parent's children along (a copied KB carries its referenced documents), so
|
||||
* the source reference is exactly what the copied parent will contain - the selector browses the
|
||||
* SOURCE parent and this seed resolves there. An explicit empty re-pick is respected (it gates a
|
||||
* required field as usual).
|
||||
*/
|
||||
export function effectiveCopyDependentValue(
|
||||
field: ForkDependentReconfig,
|
||||
reconfig: Record<string, string>
|
||||
): string {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
if (repicked !== undefined) return repicked
|
||||
return field.currentValue || field.sourceValue
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
'use client'
|
||||
|
||||
import { type Dispatch, Fragment, type SetStateAction, useMemo, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
ChevronDown,
|
||||
ChipCombobox,
|
||||
ChipSwitch,
|
||||
CollapsibleCard,
|
||||
cn,
|
||||
FieldDivider,
|
||||
Label,
|
||||
} from '@sim/emcn'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type {
|
||||
ForkCopyableUnmapped,
|
||||
ForkDependentReconfig,
|
||||
ForkMappingEntry,
|
||||
ForkResourceUsage,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import {
|
||||
FileKindRow,
|
||||
ResourceKindRow,
|
||||
} from '@/ee/workspace-forking/components/fork-resource-picker/fork-resource-picker'
|
||||
import { forkBlockerResolution } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveCopyDependentValue,
|
||||
effectiveDependentValue,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
|
||||
import type {
|
||||
ForkKindSummary,
|
||||
ForkMappingGroup,
|
||||
ForkSyncController,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync'
|
||||
import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
|
||||
/**
|
||||
* Copyable kinds as expandable rows in the "Copy resources" section, ordered + labeled to match
|
||||
* the fork modal's resource picker exactly. Files nest in a folder ▸ file tree; every other kind
|
||||
* is a flat list.
|
||||
*/
|
||||
const COPYABLE_KIND_SECTIONS: ReadonlyArray<{
|
||||
kind: ForkCopyableUnmapped['kind']
|
||||
label: string
|
||||
}> = [
|
||||
{ kind: 'file', label: 'Files' },
|
||||
{ kind: 'table', label: 'Tables' },
|
||||
{ kind: 'knowledge-base', label: 'Knowledge bases' },
|
||||
{ kind: 'custom-tool', label: 'Custom tools' },
|
||||
{ kind: 'skill', label: 'Skills' },
|
||||
{ kind: 'mcp-server', label: 'MCP servers' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Sentinel option value for the "New copy" entry - the displayed resolution while a copyable
|
||||
* is copy-selected, and the way back to the copy flow after mapping. Handled via onSelect,
|
||||
* never sent.
|
||||
*/
|
||||
const NEW_COPY_VALUE = '__new_copy__'
|
||||
|
||||
/** Fixed target-picker width so every mapping row's control lines up as one column (mirrors General). */
|
||||
const MAPPING_TARGET_TRIGGER_CLASS = 'w-[240px] flex-shrink-0'
|
||||
|
||||
interface DependentBlock {
|
||||
targetBlockId: string
|
||||
blockName: string
|
||||
fields: ForkDependentReconfig[]
|
||||
}
|
||||
|
||||
interface WorkflowDependents {
|
||||
workflowId: string
|
||||
workflowName: string
|
||||
blocks: DependentBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket an entry's dependents per workflow, then per block within it - the
|
||||
* workflow → block hierarchy the workflow cards render from.
|
||||
*/
|
||||
function groupDependentsByWorkflow(
|
||||
workflows: ForkResourceUsage['workflows'],
|
||||
dependents: ForkDependentReconfig[]
|
||||
): WorkflowDependents[] {
|
||||
const byWorkflow = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const dependent of dependents) {
|
||||
const list = byWorkflow.get(dependent.targetWorkflowId)
|
||||
if (list) list.push(dependent)
|
||||
else byWorkflow.set(dependent.targetWorkflowId, [dependent])
|
||||
}
|
||||
return workflows.map((workflow) => {
|
||||
const byBlock = new Map<string, DependentBlock>()
|
||||
for (const field of byWorkflow.get(workflow.workflowId) ?? []) {
|
||||
let block = byBlock.get(field.targetBlockId)
|
||||
if (!block) {
|
||||
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
|
||||
byBlock.set(field.targetBlockId, block)
|
||||
}
|
||||
block.fields.push(field)
|
||||
}
|
||||
return {
|
||||
workflowId: workflow.workflowId,
|
||||
workflowName: workflow.workflowName,
|
||||
blocks: Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Chain state for one block: the SelectorContext values its parent fields provide. */
|
||||
function blockChainState(
|
||||
block: DependentBlock,
|
||||
effectiveValue: (field: ForkDependentReconfig) => string
|
||||
) {
|
||||
const providedValues: Record<string, string> = {}
|
||||
const providedContextKeys = new Set<string>()
|
||||
for (const field of block.fields) {
|
||||
if (field.providesContextKey) {
|
||||
providedContextKeys.add(field.providesContextKey)
|
||||
const value = effectiveValue(field)
|
||||
if (value) providedValues[field.providesContextKey] = value
|
||||
}
|
||||
}
|
||||
return { providedValues, providedContextKeys }
|
||||
}
|
||||
|
||||
/** Store a re-pick and invalidate in-block children chained off the changed field. */
|
||||
function applyDependentRepick(
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>,
|
||||
field: ForkDependentReconfig,
|
||||
blockFields: ForkDependentReconfig[],
|
||||
value: string
|
||||
) {
|
||||
setReconfig((prev) => {
|
||||
const nextState = { ...prev, [dependentKey(field)]: value }
|
||||
// A changed parent invalidates its children's stale re-picks.
|
||||
const providedKey = field.providesContextKey
|
||||
if (providedKey) {
|
||||
for (const sibling of blockFields) {
|
||||
if (sibling.consumesContextKeys.includes(providedKey)) {
|
||||
delete nextState[dependentKey(sibling)]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextState
|
||||
})
|
||||
}
|
||||
|
||||
interface DependentSelectorProps {
|
||||
field: ForkDependentReconfig
|
||||
block: DependentBlock
|
||||
target: string
|
||||
parentChanged: boolean
|
||||
/** True when the parent is resolved by COPY: browse the SOURCE parent, seeded from the source. */
|
||||
copying: boolean
|
||||
workspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* One depends-on field's selector. Under a MAPPED parent it browses the TARGET parent
|
||||
* (pre-filled from the stored value, blank after a parent change) and is disabled until the
|
||||
* parent target is set. Under a COPY-resolved parent it browses the SOURCE parent (the copy
|
||||
* will contain exactly those children), pre-filled with the source reference. Either way it
|
||||
* stays disabled until every chained in-block parent has a value, and a re-pick invalidates
|
||||
* chained children.
|
||||
*/
|
||||
function DependentSelector({
|
||||
field,
|
||||
block,
|
||||
target,
|
||||
parentChanged,
|
||||
copying,
|
||||
workspaceId,
|
||||
sourceWorkspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: DependentSelectorProps) {
|
||||
const effectiveValue = (f: ForkDependentReconfig) =>
|
||||
copying
|
||||
? effectiveCopyDependentValue(f, reconfig)
|
||||
: effectiveDependentValue(f, reconfig, parentChanged)
|
||||
const { providedValues, providedContextKeys } = blockChainState(block, effectiveValue)
|
||||
// Disabled until every in-block parent it depends on has a value, so a child never queries
|
||||
// a stale upstream value.
|
||||
const ready = field.consumesContextKeys.every(
|
||||
(key) => !providedContextKeys.has(key) || providedValues[key] !== undefined
|
||||
)
|
||||
// A copy-resolved parent has no target id until the sync runs - scope to the SOURCE parent
|
||||
// instead (its children are what the copy brings), keeping the selector fully editable.
|
||||
const parentValue = copying ? field.parentSourceId : target
|
||||
return (
|
||||
<DependentFieldSelector
|
||||
selectorKey={field.selectorKey as SelectorKey}
|
||||
context={{
|
||||
...field.context,
|
||||
...providedValues,
|
||||
// Owning workspace, for workspace-scoped selectors like table.columns.
|
||||
workspaceId: copying ? sourceWorkspaceId : workspaceId,
|
||||
[field.parentContextKey]: parentValue,
|
||||
}}
|
||||
enabled={parentValue !== '' && ready}
|
||||
value={effectiveValue(field)}
|
||||
onChange={(value) => applyDependentRepick(setReconfig, field, block.fields, value)}
|
||||
title={field.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface DependentWorkflowCardProps {
|
||||
workflow: WorkflowDependents
|
||||
target: string
|
||||
parentChanged: boolean
|
||||
/** True when the parent is resolved by COPY - the selectors browse the SOURCE parent. */
|
||||
copying: boolean
|
||||
workspaceId: string
|
||||
sourceWorkspaceId: string
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* One workflow's dependent fields as a collapsible card (the same `CollapsibleCard` the table
|
||||
* workflow sidebar's input mapping and the enrichment config use): the header names the
|
||||
* workflow; the body groups fields under block → optional tool → plain field label.
|
||||
* Cards holding a required field start expanded - a required field is what gates Sync.
|
||||
*/
|
||||
function DependentWorkflowCard({
|
||||
workflow,
|
||||
target,
|
||||
parentChanged,
|
||||
copying,
|
||||
workspaceId,
|
||||
sourceWorkspaceId,
|
||||
reconfig,
|
||||
setReconfig,
|
||||
}: DependentWorkflowCardProps) {
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => !workflow.blocks.some((block) => block.fields.some((field) => field.required))
|
||||
)
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={workflow.workflowName}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<div className='flex flex-col gap-3'>
|
||||
{workflow.blocks.map((block) => {
|
||||
const topLevel = block.fields.filter((field) => !field.toolName)
|
||||
const byTool = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const field of block.fields) {
|
||||
if (!field.toolName) continue
|
||||
const list = byTool.get(field.toolName)
|
||||
if (list) list.push(field)
|
||||
else byTool.set(field.toolName, [field])
|
||||
}
|
||||
const toolGroups = Array.from(byTool.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return (
|
||||
<div key={block.targetBlockId} className='flex flex-col gap-2'>
|
||||
<Label className='text-small'>{block.blockName}</Label>
|
||||
{topLevel.map((field) => (
|
||||
<div key={dependentKey(field)} className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-muted)] text-caption'>
|
||||
{field.title}
|
||||
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
|
||||
</Label>
|
||||
<DependentSelector
|
||||
field={field}
|
||||
block={block}
|
||||
target={target}
|
||||
parentChanged={parentChanged}
|
||||
copying={copying}
|
||||
workspaceId={workspaceId}
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{toolGroups.map(([toolName, fields]) => (
|
||||
<div key={toolName} className='flex flex-col gap-1.5 pl-2'>
|
||||
<span className='text-[var(--text-muted)] text-small'>{toolName}</span>
|
||||
{fields.map((field) => (
|
||||
<div key={dependentKey(field)} className='flex flex-col gap-1'>
|
||||
<Label className='text-[var(--text-muted)] text-caption'>
|
||||
{field.title}
|
||||
{field.required ? (
|
||||
<span className='text-[var(--text-error)]'> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
<DependentSelector
|
||||
field={field}
|
||||
block={block}
|
||||
target={target}
|
||||
parentChanged={parentChanged}
|
||||
copying={copying}
|
||||
workspaceId={workspaceId}
|
||||
sourceWorkspaceId={sourceWorkspaceId}
|
||||
reconfig={reconfig}
|
||||
setReconfig={setReconfig}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
interface MappingEntryProps {
|
||||
controller: ForkSyncController
|
||||
group: ForkMappingGroup
|
||||
entry: ForkMappingEntry
|
||||
}
|
||||
|
||||
/**
|
||||
* One mapping entry: the source ↔ target picker row (with a "Copy instead" entry for copy
|
||||
* candidates and per-source taken-target disabling on push), then one collapsible card per
|
||||
* workflow the resource is used in, holding that workflow's dependent field selectors.
|
||||
* Workflows with nothing to configure are named in a muted note so the usage stays visible.
|
||||
*/
|
||||
function MappingEntry({ controller, group, entry }: MappingEntryProps) {
|
||||
const target = controller.targetFor(entry)
|
||||
const takenOwners = controller.takenOwnersFor(entry, group.items)
|
||||
const parentChanged = controller.parentChangedFor(entry)
|
||||
const entryRefKey = forkRefKey(entry)
|
||||
const copying = controller.copyingKeys.has(entryRefKey)
|
||||
|
||||
const usages = controller.usagesForEntry(entry)
|
||||
const dependents = controller.dependentsForEntry(entry)
|
||||
// Group once per (usages, dependents) change - both keep stable references from the
|
||||
// controller's memoized maps, so this skips recompute across the page's frequent re-renders.
|
||||
const workflows = useMemo(
|
||||
() => groupDependentsByWorkflow(usages, dependents),
|
||||
[usages, dependents]
|
||||
)
|
||||
const configurable = workflows.filter((workflow) => workflow.blocks.length > 0)
|
||||
const usedOnly = workflows.filter((workflow) => workflow.blocks.length === 0)
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<Label className='min-w-0 truncate'>{entry.sourceLabel}</Label>
|
||||
<div className={MAPPING_TARGET_TRIGGER_CLASS}>
|
||||
<ChipCombobox
|
||||
className='w-full'
|
||||
align='start'
|
||||
options={[
|
||||
// While copy-resolved, the closed control shows the copy by NAME (the copy
|
||||
// keeps the source's name) via a hidden display-only option; the list itself
|
||||
// stays unambiguous.
|
||||
...(controller.copyableKeys.has(entryRefKey) && copying
|
||||
? [{ label: entry.sourceLabel, value: NEW_COPY_VALUE, hidden: true }]
|
||||
: []),
|
||||
// The way back to the copy flow after mapping - clears the target via onSelect.
|
||||
...(controller.copyableKeys.has(entryRefKey) && target !== ''
|
||||
? [
|
||||
{
|
||||
label: 'New copy',
|
||||
value: NEW_COPY_VALUE,
|
||||
onSelect: () => controller.setTarget(entry, ''),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...entry.candidates.map((candidate) => {
|
||||
const owner = takenOwners.get(candidate.id)
|
||||
return {
|
||||
label: owner ? `${candidate.label} · mapped to ${owner}` : candidate.label,
|
||||
value: candidate.id,
|
||||
disabled: owner !== undefined,
|
||||
}
|
||||
}),
|
||||
]}
|
||||
value={copying ? NEW_COPY_VALUE : target || undefined}
|
||||
onChange={(value) => controller.setTarget(entry, value)}
|
||||
placeholder='Select target'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{entry.candidatesTruncated ? (
|
||||
<p className='text-[var(--text-muted)] text-small'>
|
||||
More options than shown — search by name.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{configurable.map((workflow) => (
|
||||
<DependentWorkflowCard
|
||||
key={workflow.workflowId}
|
||||
workflow={workflow}
|
||||
target={target}
|
||||
parentChanged={parentChanged}
|
||||
copying={copying}
|
||||
workspaceId={controller.targetWorkspaceId}
|
||||
sourceWorkspaceId={controller.sourceWorkspaceId}
|
||||
reconfig={controller.reconfig}
|
||||
setReconfig={controller.setReconfig}
|
||||
/>
|
||||
))}
|
||||
{usedOnly.length > 0 ? (
|
||||
<p className='text-[var(--text-tertiary)] text-caption'>
|
||||
Also used in {usedOnly.map((workflow) => workflow.workflowName).join(', ')} — nothing to
|
||||
configure there.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Badge copy + color for one kind's mapping status (shared badge rules with the old summary). */
|
||||
function kindStatusBadge(summary: ForkKindSummary): {
|
||||
label: string
|
||||
variant: 'green' | 'amber' | 'gray-secondary'
|
||||
} {
|
||||
const { total, mapped, copied, requiredPending, reconfigPending } = summary
|
||||
const resolved = mapped + copied
|
||||
const complete = resolved === total && !reconfigPending
|
||||
const label = complete
|
||||
? mapped === total
|
||||
? 'Fully mapped'
|
||||
: copied === total
|
||||
? 'Copied'
|
||||
: 'Mapped & copied'
|
||||
: reconfigPending && resolved === total
|
||||
? 'Needs setup'
|
||||
: copied > 0
|
||||
? `${resolved}/${total} ready`
|
||||
: `${mapped}/${total} mapped`
|
||||
const variant = complete
|
||||
? 'green'
|
||||
: requiredPending || reconfigPending
|
||||
? 'amber'
|
||||
: 'gray-secondary'
|
||||
return { label, variant }
|
||||
}
|
||||
|
||||
interface MappingKindRowProps {
|
||||
controller: ForkSyncController
|
||||
group: ForkMappingGroup
|
||||
summary: ForkKindSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* One resource kind in the Mappings section: a chevron header row with the kind's status badge
|
||||
* (the summary IS the entry), expanding to that kind's mapping entries. Mirrors the expandable
|
||||
* kind rows of the Copy resources section so the two sections share one interaction rhythm.
|
||||
*/
|
||||
function MappingKindRow({ controller, group, summary }: MappingKindRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const badge = kindStatusBadge(summary)
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className='flex w-full items-center gap-2 text-left text-[var(--text-body)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{group.label}</span>
|
||||
<Badge variant={badge.variant} size='sm' dot>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className='flex flex-col pt-3 pb-1'>
|
||||
{group.items.map((entry, index) => (
|
||||
<Fragment key={forkRefKey(entry)}>
|
||||
{index > 0 ? <FieldDivider /> : null}
|
||||
<MappingEntry controller={controller} group={group} entry={entry} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CopyKindSectionsProps {
|
||||
controller: ForkSyncController
|
||||
byKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* One expandable row per copyable kind present in `byKind` - shared by the referenced group
|
||||
* and the unreferenced "Not used by any workflow" group so both render exactly like the fork
|
||||
* picker (files as a folder tree, every other kind flat).
|
||||
*/
|
||||
function CopyKindSections({ controller, byKind }: CopyKindSectionsProps) {
|
||||
return (
|
||||
<>
|
||||
{COPYABLE_KIND_SECTIONS.map((section) => {
|
||||
const candidates = byKind.get(section.kind)
|
||||
if (!candidates || candidates.length === 0) return null
|
||||
// The picker rows track item ids; copy selection is keyed `${kind}:${id}`
|
||||
// (matching `forkRefKey`), so derive the per-kind selected-id subset and
|
||||
// re-prefix on toggle.
|
||||
const selectedIds = new Set(
|
||||
candidates
|
||||
.filter((candidate) => controller.copySelected.has(forkRefKey(candidate)))
|
||||
.map((candidate) => candidate.sourceId)
|
||||
)
|
||||
const toggleMany = (ids: string[], checked: boolean) =>
|
||||
controller.toggleCopyKeys(
|
||||
ids.map((id) => `${section.kind}:${id}`),
|
||||
checked
|
||||
)
|
||||
const toggleAll = (selectAll: boolean) =>
|
||||
toggleMany(
|
||||
candidates.map((candidate) => candidate.sourceId),
|
||||
selectAll
|
||||
)
|
||||
return section.kind === 'file' ? (
|
||||
<FileKindRow
|
||||
key={section.kind}
|
||||
label={section.label}
|
||||
files={candidates.map((candidate) => ({
|
||||
id: candidate.sourceId,
|
||||
label: candidate.label,
|
||||
folderId: candidate.parentId,
|
||||
folderName: candidate.parentLabel,
|
||||
}))}
|
||||
selected={selectedIds}
|
||||
onToggleAll={toggleAll}
|
||||
onToggleItem={(id, checked) => toggleMany([id], checked)}
|
||||
onToggleMany={toggleMany}
|
||||
disabled={controller.submitting}
|
||||
/>
|
||||
) : (
|
||||
<ResourceKindRow
|
||||
key={section.kind}
|
||||
label={section.label}
|
||||
items={candidates.map((candidate) => ({
|
||||
id: candidate.sourceId,
|
||||
label: candidate.label,
|
||||
}))}
|
||||
selected={selectedIds}
|
||||
onToggleMany={toggleMany}
|
||||
onToggleItem={(id, checked) => toggleMany([id], checked)}
|
||||
disabled={controller.submitting}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface ForkSyncViewProps {
|
||||
controller: ForkSyncController
|
||||
onDirectionChange: (direction: ForkDirection) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent fork edge's sync experience as page sections: pick a direction, review the
|
||||
* deployed-workflow changes, resolve the per-kind mappings (each kind an expandable row whose
|
||||
* status badge doubles as the summary), choose which unmapped resources to copy, and clear any
|
||||
* blocking references. The page header's Sync action commits it (after the overwrite confirm).
|
||||
*/
|
||||
export function ForkSyncView({ controller, onDirectionChange }: ForkSyncViewProps) {
|
||||
const detailsError = controller.errorMessage ?? controller.diffErrorMessage
|
||||
const headsUp = controller.mcpReauthCount > 0 || controller.inlineSecretCount > 0
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-7'>
|
||||
<SettingsSection label='Sync direction'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<ChipSwitch
|
||||
value={controller.direction}
|
||||
onChange={onDirectionChange}
|
||||
aria-label='Sync direction'
|
||||
options={[
|
||||
{ value: 'push', label: 'Push' },
|
||||
{ value: 'pull', label: 'Pull' },
|
||||
]}
|
||||
/>
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
{controller.direction === 'push'
|
||||
? `Push this workspace's deployed workflows to "${controller.otherWorkspaceName}", overwriting it.`
|
||||
: `Pull deployed workflows from "${controller.otherWorkspaceName}", overwriting this workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Surface a failed/pending fetch so the page never renders blank below the direction. */}
|
||||
{detailsError ? (
|
||||
<SettingsSection label='Sync details'>
|
||||
<div className='text-[var(--text-error)] text-small'>{detailsError}</div>
|
||||
</SettingsSection>
|
||||
) : !controller.hasDiff ? (
|
||||
<div className='text-[var(--text-muted)] text-small'>Loading sync details…</div>
|
||||
) : null}
|
||||
|
||||
{/* Always shown once the diff loads so the user sees the section even with nothing
|
||||
deployed - an empty change list means the source has no deployed workflows (every
|
||||
deployed workflow appears here, changed or not), so the muted state nudges a deploy. */}
|
||||
{controller.hasDiff ? (
|
||||
<SettingsSection label='Deployed workflows'>
|
||||
{controller.workflowChanges.length > 0 ? (
|
||||
<div className='flex flex-col gap-1'>
|
||||
{controller.workflowChanges.map((change, index) => {
|
||||
const renamed = change.currentName !== change.otherName
|
||||
return (
|
||||
<div
|
||||
key={`${change.action}:${change.currentName}:${index}`}
|
||||
className='flex min-w-0 items-center gap-1.5'
|
||||
>
|
||||
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
|
||||
{change.currentName}
|
||||
</span>
|
||||
{renamed ? (
|
||||
<>
|
||||
<ArrowRight className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='min-w-0 truncate text-[var(--text-secondary)] text-sm'>
|
||||
{change.otherName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
{controller.direction === 'push'
|
||||
? `No deployed workflows. Deploy workflows to push changes to ${controller.otherWorkspaceName}.`
|
||||
: `No deployed workflows in ${controller.otherWorkspaceName} to pull.`}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{headsUp ? (
|
||||
<SettingsSection label='Heads up'>
|
||||
{controller.mcpReauthCount > 0 ? (
|
||||
<div className='text-[var(--text-muted)] text-small'>
|
||||
{controller.mcpReauthCount} MCP server(s) use OAuth and must be re-authorized in the
|
||||
target workspace.
|
||||
</div>
|
||||
) : null}
|
||||
{controller.inlineSecretCount > 0 ? (
|
||||
<div className='mt-1 text-[var(--text-muted)] text-small'>
|
||||
{controller.inlineSecretCount} inline secret(s) can't be auto-mapped — set them in the
|
||||
target workspace.
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.hasMapping ? (
|
||||
<SettingsSection label='Mappings'>
|
||||
{controller.groups.length > 0 ? (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{controller.groups.map((group) => {
|
||||
const summary = controller.kindSummaries.find((item) => item.kind === group.kind)
|
||||
if (!summary) return null
|
||||
return (
|
||||
<MappingKindRow
|
||||
key={group.kind}
|
||||
controller={controller}
|
||||
group={group}
|
||||
summary={summary}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
This workspace's deployed workflows have no mappable references.
|
||||
</SettingsEmptyState>
|
||||
)}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.hasVisibleCopyables ? (
|
||||
<SettingsSection label='Copy resources'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{controller.referencedByKind.size > 0 ? (
|
||||
<CopyKindSections controller={controller} byKind={controller.referencedByKind} />
|
||||
) : null}
|
||||
{controller.unreferencedByKind.size > 0 ? (
|
||||
<>
|
||||
{controller.referencedByKind.size > 0 ? (
|
||||
<div className='mt-2 text-[var(--text-muted)] text-caption'>
|
||||
Not used by any workflow
|
||||
</div>
|
||||
) : null}
|
||||
<CopyKindSections controller={controller} byKind={controller.unreferencedByKind} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.blockingRefs.length > 0 ? (
|
||||
<SettingsSection label='Blocking sync'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{controller.blockingRefs.map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
|
||||
className='min-w-0 text-[var(--text-secondary)] text-small'
|
||||
>
|
||||
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> would lose{' '}
|
||||
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
|
||||
{ref.workflowName} — {forkBlockerResolution(ref)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
{controller.dependentClears.length > 0 ? (
|
||||
<SettingsSection label='Will be cleared'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{controller.dependentClears.map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.targetWorkflowId}:${ref.blockId}:${ref.kind}:${ref.sourceId}:${ref.fieldLabel}:${index}`}
|
||||
className='min-w-0 text-[var(--text-secondary)] text-small'
|
||||
>
|
||||
<span className='text-[var(--text-body)]'>{ref.blockLabel}</span> will lose{' '}
|
||||
<span className='text-[var(--text-body)]'>{ref.fieldLabel}</span> in{' '}
|
||||
{ref.workflowName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className='text-[var(--text-muted)] text-caption'>
|
||||
Re-pick these in the target after the sync.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
'use client'
|
||||
|
||||
import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import type {
|
||||
ForkClearedRef,
|
||||
ForkCopyableUnmapped,
|
||||
ForkDependentReconfig,
|
||||
ForkMappingEntry,
|
||||
ForkResourceUsage,
|
||||
ForkWorkflowChange,
|
||||
} from '@/lib/api/contracts/workspace-fork'
|
||||
import {
|
||||
selectVisibleClearedRefs,
|
||||
splitForkClearedRefs,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list'
|
||||
import {
|
||||
effectiveForkTarget,
|
||||
type ForkParentResolution,
|
||||
forkCopyingKeys,
|
||||
forkDefaultCopySelection,
|
||||
forkMappedCopyableKeys,
|
||||
forkParentResolution,
|
||||
forkRefKey,
|
||||
forkRequiredKindsLabel,
|
||||
forkRequiredPending,
|
||||
forkVisibleCopyables,
|
||||
isForkRequiredComplete,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
|
||||
import {
|
||||
dependentKey,
|
||||
effectiveCopyDependentValue,
|
||||
effectiveDependentValue,
|
||||
} from '@/ee/workspace-forking/components/fork-sync/dependent-value'
|
||||
import {
|
||||
type ForkDirection,
|
||||
useForkDiff,
|
||||
useForkMapping,
|
||||
usePromoteFork,
|
||||
useUpdateForkMapping,
|
||||
} from '@/ee/workspace-forking/hooks/workspace-fork'
|
||||
|
||||
/**
|
||||
* The mapping kinds that can be a standalone mapping entry. `knowledge-document` is excluded:
|
||||
* the mapping view (`getForkMappingView`) skips documents — they ride their parent KB via the
|
||||
* reconfigure flow — so a `knowledge-document` mapping section is never reachable.
|
||||
*/
|
||||
export type MappableMappingKind = Exclude<ForkMappingEntry['kind'], 'knowledge-document'>
|
||||
|
||||
/** Section label + display order per mapping kind (one mapping group per kind). */
|
||||
const MAPPING_SECTION: Record<MappableMappingKind, { label: string; order: number }> = {
|
||||
credential: { label: 'Credentials', order: 0 },
|
||||
'env-var': { label: 'Secrets', order: 1 },
|
||||
table: { label: 'Tables', order: 2 },
|
||||
'knowledge-base': { label: 'Knowledge bases', order: 3 },
|
||||
file: { label: 'Files', order: 4 },
|
||||
'mcp-server': { label: 'MCP servers', order: 5 },
|
||||
'custom-tool': { label: 'Custom tools', order: 6 },
|
||||
skill: { label: 'Skills', order: 7 },
|
||||
}
|
||||
|
||||
/** Shared empty owners map for the pull direction so the options mapper never re-allocates. */
|
||||
const EMPTY_TARGET_OWNERS: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
/**
|
||||
* Stable empty arrays so an entry with no usages/dependents keeps a constant prop reference,
|
||||
* letting the workflow-card grouping memos skip recompute across the page's frequent re-renders.
|
||||
*/
|
||||
const EMPTY_USAGES: ForkResourceUsage['workflows'] = []
|
||||
const EMPTY_DEPENDENTS: ForkDependentReconfig[] = []
|
||||
|
||||
/** Target workflows this sync archives, previewed in the confirm before "and X more". */
|
||||
export const ARCHIVED_PREVIEW_LIMIT = 5
|
||||
|
||||
export interface ForkMappingGroup {
|
||||
kind: MappableMappingKind
|
||||
label: string
|
||||
items: ForkMappingEntry[]
|
||||
}
|
||||
|
||||
/** Per-kind mapping status for the Mappings section's summary badges. */
|
||||
export interface ForkKindSummary {
|
||||
kind: MappableMappingKind
|
||||
total: number
|
||||
mapped: number
|
||||
copied: number
|
||||
requiredPending: boolean
|
||||
reconfigPending: boolean
|
||||
}
|
||||
|
||||
export interface ForkSyncController {
|
||||
direction: ForkDirection
|
||||
otherWorkspaceName: string
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
errorMessage: string | null
|
||||
/** Diff fetch failure, surfaced inline (the mapping may still have loaded). */
|
||||
diffErrorMessage: string | null
|
||||
/** True once the diff payload for ANY direction is present (placeholder included). */
|
||||
hasDiff: boolean
|
||||
/** True once the mapping payload is present (placeholder included), gating the Mappings section. */
|
||||
hasMapping: boolean
|
||||
groups: ForkMappingGroup[]
|
||||
/** Per-kind mapping status, aligned with `groups` (same kinds, same order). */
|
||||
kindSummaries: ForkKindSummary[]
|
||||
/** Effective (in-session override, else persisted/suggested) target for an entry. */
|
||||
targetFor: (entry: ForkMappingEntry) => string
|
||||
/** Set an entry's target ('' clears it) and drop its dependents' stale re-picks. */
|
||||
setTarget: (entry: ForkMappingEntry, value: string) => void
|
||||
/** Targets already claimed by another source in the same kind (push targets are unique; pull never disables). */
|
||||
takenOwnersFor: (
|
||||
entry: ForkMappingEntry,
|
||||
items: ForkMappingEntry[]
|
||||
) => ReadonlyMap<string, string>
|
||||
/** Every workflow an entry's resource is used in (diff-fed; empty until the diff loads). */
|
||||
usagesForEntry: (entry: ForkMappingEntry) => ForkResourceUsage['workflows']
|
||||
/** An entry's dependent fields (its credential/KB/table's selectors), from the diff. */
|
||||
dependentsForEntry: (entry: ForkMappingEntry) => ForkDependentReconfig[]
|
||||
/**
|
||||
* Whether an entry needs an in-place reconfigure: its effective target changed in-session,
|
||||
* or it's an unconfirmed suggestion (accepting it as-is still remaps + clears the dependents).
|
||||
*/
|
||||
parentChangedFor: (entry: ForkMappingEntry) => boolean
|
||||
/** The workspace a MAPPED parent's dependent selectors query against (direction-aware from the diff). */
|
||||
targetWorkspaceId: string
|
||||
/**
|
||||
* The sync's source workspace (push: this one; pull: the other), which a COPY-resolved
|
||||
* parent's dependent selectors browse - the copy will contain the source parent's children,
|
||||
* so the source is the truthful catalog to pick from.
|
||||
*/
|
||||
sourceWorkspaceId: string
|
||||
/** In-session dependent re-picks, keyed by `dependentKey`. */
|
||||
reconfig: Record<string, string>
|
||||
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
|
||||
/** Keys the backend offers as copy candidates, for the entry rows' "Copy instead" affordance. */
|
||||
copyableKeys: ReadonlySet<string>
|
||||
/** Copyables actually selected for copy (visible + checked), keyed `${kind}:${sourceId}`. */
|
||||
copyingKeys: ReadonlySet<string>
|
||||
/** The raw copy selection (visible-ness not applied), for per-kind selected-id derivation. */
|
||||
copySelected: ReadonlySet<string>
|
||||
toggleCopyKeys: (keys: string[], checked: boolean) => void
|
||||
/** Visible copy candidates split by referenced-ness, grouped per kind for the section rows. */
|
||||
referencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
|
||||
unreferencedByKind: ReadonlyMap<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>
|
||||
hasVisibleCopyables: boolean
|
||||
/** Would-clear references that BLOCK sync (mirrors the server's zero-cleared-refs gate). */
|
||||
blockingRefs: ForkClearedRef[]
|
||||
/** Informational would-clear dependents (owned by the reconfigure flow; never block). */
|
||||
dependentClears: ForkClearedRef[]
|
||||
/** Deployed-workflow change list (update → create → archive, then by name). */
|
||||
workflowChanges: ForkWorkflowChange[]
|
||||
/** Names of target workflows this sync archives, for the confirm modal. */
|
||||
archivedWorkflowNames: string[]
|
||||
mcpReauthCount: number
|
||||
inlineSecretCount: number
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
save: () => void
|
||||
discard: () => void
|
||||
submitting: boolean
|
||||
syncDisabled: boolean
|
||||
/** Names the failing gate for the disabled Sync chip's tooltip; undefined when enabled. */
|
||||
syncDisabledReason: string | undefined
|
||||
/** Persist the effective mapping + dependents + copy selection, then promote. */
|
||||
sync: () => Promise<void>
|
||||
}
|
||||
|
||||
const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry)
|
||||
|
||||
/**
|
||||
* Whether a mapping entry needs an in-place reconfigure: its effective target was changed
|
||||
* in-session, or it's an unconfirmed suggestion (accepting it as-is still remaps + clears
|
||||
* the dependents). Pure over (entry, in-session targets) so the inline render, the Sync
|
||||
* gate, and the payload build share one predicate instead of drifting copies.
|
||||
*/
|
||||
function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record<string, string>): boolean {
|
||||
const next = targets[entryKey(entry)] ?? entry.targetId ?? ''
|
||||
if (next === '') return false
|
||||
return entry.suggested || next !== (entry.targetId ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Targets already taken by OTHER sources in the same kind, each mapped to the owning
|
||||
* source's label (for a hint). Used to disable those targets on PUSH: a push row is unique
|
||||
* on the parent (target) side, so a parent target can back only one source - a second source
|
||||
* picking it would be silently dropped on save. Pull is the inverse (many parent sources may
|
||||
* share one fork target, which resolves correctly), so pull passes the empty map and never
|
||||
* disables. Excludes `exclude` so a source never disables its own current selection.
|
||||
*/
|
||||
function takenTargetOwners(
|
||||
items: ForkMappingEntry[],
|
||||
targets: Record<string, string>,
|
||||
exclude: ForkMappingEntry
|
||||
): Map<string, string> {
|
||||
const owners = new Map<string, string>()
|
||||
for (const item of items) {
|
||||
if (entryKey(item) === entryKey(exclude)) continue
|
||||
const target = targets[entryKey(item)] ?? item.targetId ?? ''
|
||||
if (target !== '') owners.set(target, item.sourceLabel)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
/**
|
||||
* The full sync surface's state for one fork edge, in the chosen direction: the editable
|
||||
* resource mapping (in-session target overrides + dependent re-picks, persisted via Save or
|
||||
* as part of Sync), the copy-resources selection (seeded once the diff settles), the reactive
|
||||
* would-clear blockers, the per-kind status summaries, the Sync gate, and the promote run
|
||||
* itself. A direction switch drops every in-session choice — the mapping set, copy candidates,
|
||||
* and blockers all depend on the direction — and the copy selection re-seeds only from a
|
||||
* settled (non-placeholder) diff so a stale payload can't latch wrong keys.
|
||||
*/
|
||||
export function useForkSync(params: {
|
||||
workspaceId: string
|
||||
otherWorkspaceId?: string
|
||||
otherWorkspaceName: string
|
||||
direction: ForkDirection
|
||||
enabled: boolean
|
||||
}): ForkSyncController {
|
||||
const { workspaceId, otherWorkspaceId, otherWorkspaceName, direction, enabled } = params
|
||||
|
||||
// User's IN-SESSION mapping overrides only - NOT the source of truth. The displayed/persisted
|
||||
// target falls back to each entry's stored `targetId` (see `targetFor`), so a reopened edge
|
||||
// shows its remembered mappings even though React Query's structural sharing keeps `entries`
|
||||
// referentially stable (a target-seeding effect gated on `entries` would never re-run there).
|
||||
const [targets, setTargets] = useState<Record<string, string>>({})
|
||||
// In-session re-picks for dependent fields whose parent the user swapped, keyed by
|
||||
// `dependentKey`. Folded into the full effective set sent on save/sync, which the server
|
||||
// persists as the stored mapping - so the selection survives every future sync without
|
||||
// re-picking.
|
||||
const [reconfig, setReconfig] = useState<Record<string, string>>({})
|
||||
// Referenced-but-unmapped resources the user chose to copy into the target (keyed by
|
||||
// `${kind}:${sourceId}`); default-selected once the diff loads. Selected ones are copied on
|
||||
// sync so their references resolve to the copy instead of being cleared.
|
||||
const [copySelected, setCopySelected] = useState<Set<string>>(new Set())
|
||||
const [copyDefaulted, setCopyDefaulted] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Drop every in-session choice when the direction (or edge) changes - the mapping set,
|
||||
// copy candidates, and blockers all depend on it.
|
||||
useEffect(() => {
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
setCopySelected(new Set())
|
||||
setCopyDefaulted(false)
|
||||
}, [direction, otherWorkspaceId])
|
||||
|
||||
const mapping = useForkMapping({ workspaceId, otherWorkspaceId, direction, enabled })
|
||||
const diff = useForkDiff({ workspaceId, otherWorkspaceId, direction, enabled })
|
||||
const updateMapping = useUpdateForkMapping()
|
||||
const promote = usePromoteFork()
|
||||
|
||||
const entries = useMemo<ForkMappingEntry[]>(() => mapping.data?.entries ?? [], [mapping.data])
|
||||
const dependentReconfigs = useMemo(
|
||||
() => diff.data?.dependentReconfigs ?? [],
|
||||
[diff.data?.dependentReconfigs]
|
||||
)
|
||||
const resourceUsages = useMemo(() => diff.data?.resourceUsages ?? [], [diff.data?.resourceUsages])
|
||||
const copyableUnmapped = useMemo(
|
||||
() => diff.data?.copyableUnmapped ?? [],
|
||||
[diff.data?.copyableUnmapped]
|
||||
)
|
||||
const clearedRefs = useMemo(() => diff.data?.clearedRefs ?? [], [diff.data?.clearedRefs])
|
||||
|
||||
// Keys the backend offers as copy candidates, so the entry rows show a "Copy instead"
|
||||
// affordance only for those - clearing a name-match suggestion returns the ref to the copy
|
||||
// list (it re-enters `visibleCopyables` once its effective target is '').
|
||||
const copyableKeys = useMemo(
|
||||
() => new Set(copyableUnmapped.map((candidate) => forkRefKey(candidate))),
|
||||
[copyableUnmapped]
|
||||
)
|
||||
|
||||
// Copy-vs-map reconciliation: a copyable resource the user has given an effective (in-session
|
||||
// or persisted) mapping target must NOT also appear in the copy list - the user picked map, not
|
||||
// copy. `forkRefKey` shares the `${kind}:${sourceId}` keyspace across entries and candidates,
|
||||
// so a mapped entry's key directly excludes its copy candidate. The server enforces the same
|
||||
// precedence: a mapped resource resolves != null, so it never reaches the plan's
|
||||
// `copyableUnmapped`, and a copy request for it is dropped by `buildPromoteCopySelection`.
|
||||
const mappedCopyableKeys = useMemo(
|
||||
() => forkMappedCopyableKeys(entries, targets),
|
||||
[entries, targets]
|
||||
)
|
||||
|
||||
const visibleCopyables = useMemo(
|
||||
() => forkVisibleCopyables(copyableUnmapped, mappedCopyableKeys),
|
||||
[copyableUnmapped, mappedCopyableKeys]
|
||||
)
|
||||
|
||||
// Copyables actually selected for copy (visible + checked), keyed for an O(1) lookup so a
|
||||
// copyable mapping entry can show a "will be copied" note.
|
||||
const copyingKeys = useMemo(
|
||||
() => forkCopyingKeys(visibleCopyables, copySelected),
|
||||
[visibleCopyables, copySelected]
|
||||
)
|
||||
|
||||
// Group the visible copy candidates by kind so each renders as its own expandable section
|
||||
// (chevron + tri-state select-all + count), matching the fork picker. Referenced and
|
||||
// unreferenced candidates group separately: unreferenced ones (used by no synced workflow)
|
||||
// render under a muted "Not used by any workflow" grouping and default to unselected.
|
||||
const { referencedByKind, unreferencedByKind } = useMemo(() => {
|
||||
const referenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
|
||||
const unreferenced = new Map<ForkCopyableUnmapped['kind'], ForkCopyableUnmapped[]>()
|
||||
for (const candidate of visibleCopyables) {
|
||||
const groups = candidate.referenced ? referenced : unreferenced
|
||||
const list = groups.get(candidate.kind)
|
||||
if (list) list.push(candidate)
|
||||
else groups.set(candidate.kind, [candidate])
|
||||
}
|
||||
return { referencedByKind: referenced, unreferencedByKind: unreferenced }
|
||||
}, [visibleCopyables])
|
||||
|
||||
// Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case
|
||||
// (bring the referenced resources along) needs no clicks; the user can deselect to clear
|
||||
// instead. Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying
|
||||
// them is opt-in since nothing references them. Seed ONLY from a settled diff for the current
|
||||
// direction: on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps
|
||||
// the previous direction's payload (placeholderData) until the new fetch resolves - seeding
|
||||
// from it would latch against stale keys and leave the real copyables unchecked, clearing
|
||||
// their references on Sync.
|
||||
useEffect(() => {
|
||||
if (!enabled || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return
|
||||
setCopyDefaulted(true)
|
||||
setCopySelected(forkDefaultCopySelection(copyableUnmapped))
|
||||
}, [enabled, diff.isPlaceholderData, copyableUnmapped, copyDefaulted])
|
||||
|
||||
// Group dependents by their parent (kind:sourceId) once, so each mapping entry gets a
|
||||
// STABLE `dependents` array reference - a fresh `.filter` per render would defeat the
|
||||
// workflow-card grouping memos.
|
||||
const dependentsByParent = useMemo(() => {
|
||||
const map = new Map<string, ForkDependentReconfig[]>()
|
||||
for (const dependent of dependentReconfigs) {
|
||||
const key = `${dependent.parentKind}:${dependent.parentSourceId}`
|
||||
const list = map.get(key)
|
||||
if (list) list.push(dependent)
|
||||
else map.set(key, [dependent])
|
||||
}
|
||||
return map
|
||||
}, [dependentReconfigs])
|
||||
|
||||
// Effective target for an entry: the user's in-session override if present, else the
|
||||
// persisted mapping from the server. Read directly from `entries` so a reopened edge
|
||||
// reflects stored mappings without a seeding effect.
|
||||
const targetFor = (entry: ForkMappingEntry) => effectiveForkTarget(entry, targets)
|
||||
|
||||
const usagesForEntry = (entry: ForkMappingEntry): ForkResourceUsage['workflows'] =>
|
||||
resourceUsages.find(
|
||||
(usage) => usage.parentKind === entry.kind && usage.parentSourceId === entry.sourceId
|
||||
)?.workflows ?? EMPTY_USAGES
|
||||
|
||||
const dependentsForEntry = (entry: ForkMappingEntry): ForkDependentReconfig[] =>
|
||||
dependentsByParent.get(entryKey(entry)) ?? EMPTY_DEPENDENTS
|
||||
|
||||
const parentChangedFor = (entry: ForkMappingEntry): boolean =>
|
||||
shouldReconfigureEntry(entry, targets)
|
||||
|
||||
// Set an entry's in-session mapping target. A `value` of '' explicitly clears it, overriding
|
||||
// any name-match suggestion (effectiveForkTarget's `??` treats '' as present, so the suggestion
|
||||
// no longer wins) - so the resource re-enters `visibleCopyables` and is copy-selectable again.
|
||||
// Changing the parent invalidates its dependents' in-session re-picks (chosen against the old
|
||||
// account), so drop them.
|
||||
const setTarget = (entry: ForkMappingEntry, value: string) => {
|
||||
setTargets((prev) => ({ ...prev, [entryKey(entry)]: value }))
|
||||
setReconfig((prev) => {
|
||||
let changed = false
|
||||
const next = { ...prev }
|
||||
for (const dependent of dependentsForEntry(entry)) {
|
||||
const key = dependentKey(dependent)
|
||||
if (key in next) {
|
||||
delete next[key]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
const takenOwnersFor = (
|
||||
entry: ForkMappingEntry,
|
||||
items: ForkMappingEntry[]
|
||||
): ReadonlyMap<string, string> =>
|
||||
direction === 'push' ? takenTargetOwners(items, targets, entry) : EMPTY_TARGET_OWNERS
|
||||
|
||||
const toggleCopyKeys = (keys: string[], checked: boolean) =>
|
||||
setCopySelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const key of keys) {
|
||||
if (checked) next.add(key)
|
||||
else next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Group mappings by resource type - one accordion row per kind, required types first.
|
||||
const groups = useMemo<ForkMappingGroup[]>(() => {
|
||||
const byKind = new Map<MappableMappingKind, ForkMappingEntry[]>()
|
||||
for (const entry of entries) {
|
||||
// The mapping view never emits a document entry (it rides its KB), so the group is
|
||||
// unreachable - skip defensively so the narrowed `MAPPING_SECTION` lookup stays sound.
|
||||
if (entry.kind === 'knowledge-document') continue
|
||||
const list = byKind.get(entry.kind)
|
||||
if (list) list.push(entry)
|
||||
else byKind.set(entry.kind, [entry])
|
||||
}
|
||||
return Array.from(byKind, ([kind, items]) => ({
|
||||
kind,
|
||||
label: MAPPING_SECTION[kind].label,
|
||||
items: items.slice().sort((a, b) => a.sourceLabel.localeCompare(b.sourceLabel)),
|
||||
})).sort((a, b) => MAPPING_SECTION[a.kind].order - MAPPING_SECTION[b.kind].order)
|
||||
}, [entries])
|
||||
|
||||
// The mapping entry each dependent hangs off, indexed by `kind:sourceId` (matching `entryKey`)
|
||||
// so the per-field lookups below are O(1) instead of rescanning `entries` for every dependent -
|
||||
// and several times per field across the Sync gate, the value helper, and the payload build.
|
||||
const entriesByParent = useMemo(() => {
|
||||
const map = new Map<string, ForkMappingEntry>()
|
||||
for (const entry of entries) map.set(entryKey(entry), entry)
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
const entryForDependent = (field: ForkDependentReconfig) =>
|
||||
entriesByParent.get(`${field.parentKind}:${field.parentSourceId}`)
|
||||
|
||||
const resolutionFor = (entry: ForkMappingEntry): ForkParentResolution =>
|
||||
forkParentResolution(entry, targets, copyingKeys)
|
||||
|
||||
// The value sent + displayed for a dependent (delegates to the shared per-resolution rule):
|
||||
// the user's re-pick, else - under a MAPPED parent - the stored value (blank when the parent
|
||||
// target changed in-session), or - under a COPY-resolved parent - the stored value falling
|
||||
// back to the raw source reference (which the copied parent will contain). Callers that
|
||||
// already resolved the parent pass both in to skip repeat lookups.
|
||||
const dependentValueFor = (
|
||||
field: ForkDependentReconfig,
|
||||
parent = entryForDependent(field),
|
||||
resolution: ForkParentResolution = parent ? resolutionFor(parent) : 'unresolved'
|
||||
): string => {
|
||||
if (resolution === 'copied') return effectiveCopyDependentValue(field, reconfig)
|
||||
return effectiveDependentValue(
|
||||
field,
|
||||
reconfig,
|
||||
parent ? shouldReconfigureEntry(parent, targets) : false
|
||||
)
|
||||
}
|
||||
|
||||
// A required reference is satisfied when it has a mapping target OR the user selected it for
|
||||
// copy (the server accepts a copy as resolving a required ref). See `isForkRequiredComplete`.
|
||||
const requiredComplete = isForkRequiredComplete(entries, targets, copyingKeys)
|
||||
|
||||
// Every required dependent whose parent is RESOLVED must have a value before sync. Under a
|
||||
// mapped parent the user re-picks against the target; under a copy-resolved parent the field
|
||||
// pre-fills with the source reference (the copy carries it), so it's satisfied out of the box
|
||||
// and gates only when explicitly emptied. A dependent whose parent is unresolved can't be
|
||||
// picked yet (its selector is disabled) and is gated by `requiredComplete` on the parent
|
||||
// instead, so it's skipped here.
|
||||
const reconfigComplete = dependentReconfigs.every((field) => {
|
||||
if (!field.required) return true
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) return true
|
||||
const resolution = resolutionFor(parent)
|
||||
if (resolution === 'unresolved') return true
|
||||
return dependentValueFor(field, parent, resolution) !== ''
|
||||
})
|
||||
|
||||
// Kinds with a required DEPENDENT that still has no value (its parent is resolved): these
|
||||
// block Sync via `reconfigComplete`, so the summary badge for that kind must not read
|
||||
// "Fully mapped".
|
||||
const reconfigPendingByKind = new Set<MappableMappingKind>()
|
||||
for (const field of dependentReconfigs) {
|
||||
if (!field.required) continue
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) continue
|
||||
const resolution = resolutionFor(parent)
|
||||
if (resolution === 'unresolved') continue
|
||||
if (dependentValueFor(field, parent, resolution) === '') {
|
||||
reconfigPendingByKind.add(parent.kind as MappableMappingKind)
|
||||
}
|
||||
}
|
||||
|
||||
// The references this sync would blank, reactively narrowed to the current selection. A
|
||||
// resource is "resolved" once it has a mapping target OR is selected for copy - the same
|
||||
// predicate drives a `reference` (its own resource) and a `dependent` (its PARENT resource),
|
||||
// so mapping or copying a parent KB makes its child document drop off. Then split:
|
||||
// `reference`/`workflow` entries are BLOCKERS (Sync stays disabled while any remain -
|
||||
// mirroring the server's zero-cleared-refs gate); `dependent` entries stay informational
|
||||
// (the reconfigure flow owns them).
|
||||
const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => {
|
||||
const isResolved = (kind: string, sourceId: string) => {
|
||||
const key = `${kind}:${sourceId}`
|
||||
const entry = entriesByParent.get(key)
|
||||
const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false
|
||||
return mapped || copyingKeys.has(key)
|
||||
}
|
||||
return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved))
|
||||
}, [clearedRefs, entriesByParent, targets, copyingKeys])
|
||||
|
||||
// Per-kind status for the Mappings summary: "Fully mapped" or "n/total mapped", flagged when
|
||||
// a REQUIRED target is still missing (which blocks Sync). Reads the effective
|
||||
// (override-or-persisted) target so it reflects both remembered mappings and in-session edits.
|
||||
const kindSummaries: ForkKindSummary[] = groups.map((group) => {
|
||||
const total = group.items.length
|
||||
const mapped = group.items.filter((entry) => targetFor(entry) !== '').length
|
||||
// Copy-selected items are resolved too (their refs are kept), so they count toward
|
||||
// completion and render as "copied" rather than unconfigured. mapped/copied are disjoint:
|
||||
// a mapped copyable is excluded from the copy candidates, so copyingKeys never overlaps a
|
||||
// mapped entry.
|
||||
const copied = group.items.filter((entry) => copyingKeys.has(entryKey(entry))).length
|
||||
// Mirror the Sync gate: a required ref selected for copy is satisfied, so it is not
|
||||
// "pending".
|
||||
const requiredPending = forkRequiredPending(group.items, targets, copyingKeys)
|
||||
const reconfigPending = reconfigPendingByKind.has(group.kind)
|
||||
return { kind: group.kind, total, mapped, copied, requiredPending, reconfigPending }
|
||||
})
|
||||
|
||||
// Kinds whose required gate is still failing, so the Sync tooltip can name the actual
|
||||
// obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector
|
||||
// excludes required kinds), so the required gate must not borrow the blocker message -
|
||||
// it would point at a "Blocking sync" section that isn't rendered.
|
||||
const pendingRequiredKinds = new Set<string>(
|
||||
kindSummaries.filter((summary) => summary.requiredPending).map((summary) => summary.kind)
|
||||
)
|
||||
|
||||
// Sync details still settling for the current direction: loading, a failed/empty mapping
|
||||
// (`!mapping.data` must not read as "nothing required"), or the PREVIOUS direction's
|
||||
// placeholder after a switch (syncing on it would send stale mappings/copies and clear
|
||||
// references). Until `diff.data` arrives `dependentReconfigs` is empty, so `reconfigComplete`
|
||||
// is vacuously true.
|
||||
const dataPending =
|
||||
mapping.isLoading ||
|
||||
!mapping.data ||
|
||||
mapping.isPlaceholderData ||
|
||||
!diff.data ||
|
||||
diff.isPlaceholderData
|
||||
// A failed fetch also gates Sync: a failed REFETCH keeps the last successful payload in
|
||||
// `data` (so `dataPending` stays false), and every gate below would be judging that stale
|
||||
// snapshot while the page shows the load error.
|
||||
const dataError = mapping.isError || diff.isError
|
||||
// Zero-blockers invariant (mirrors the server gate): Sync stays disabled while ANY reference
|
||||
// would clear in a synced target workflow. `requiredComplete` covers the mapping entries
|
||||
// (credentials/secrets and unresolved resource refs); `blockingRefs` additionally covers
|
||||
// workflow-to-workflow references, which have no mapping entry to resolve.
|
||||
const syncBlocked = blockingRefs.length > 0
|
||||
const syncDisabled =
|
||||
submitting ||
|
||||
!otherWorkspaceId ||
|
||||
!requiredComplete ||
|
||||
!reconfigComplete ||
|
||||
syncBlocked ||
|
||||
dataPending ||
|
||||
dataError
|
||||
|
||||
// A load failure outranks the gates - they're computed from the stale (or absent) payload,
|
||||
// so naming one would mislead. Then priority mirrors the resolution flow: clear the
|
||||
// blockers, map the required resources, reconfigure their dependents - each failing gate
|
||||
// names ITS obstacle (an unmapped credential/secret is a required-mapping failure, not a
|
||||
// cleared-ref blocker; see `pendingRequiredKinds`).
|
||||
const syncDisabledReason = dataError
|
||||
? "Couldn't load sync details — reload the page to retry"
|
||||
: syncBlocked
|
||||
? 'Resolve every blocking reference first — map it, copy it, or fix it in the source'
|
||||
: !requiredComplete
|
||||
? `Map all required ${forkRequiredKindsLabel(pendingRequiredKinds)} first`
|
||||
: !reconfigComplete
|
||||
? 'Reconfigure all required fields first'
|
||||
: dataPending
|
||||
? 'Loading sync details…'
|
||||
: undefined
|
||||
|
||||
const workflowChanges = useMemo<ForkWorkflowChange[]>(() => {
|
||||
const order: Record<ForkWorkflowChange['action'], number> = { update: 0, create: 1, archive: 2 }
|
||||
return [...(diff.data?.workflows ?? [])].sort(
|
||||
(a, b) => order[a.action] - order[b.action] || a.currentName.localeCompare(b.currentName)
|
||||
)
|
||||
}, [diff.data?.workflows])
|
||||
|
||||
// Target workflows this sync archives (their source was deleted), named in the confirm modal
|
||||
// so the overwrite warning is concrete.
|
||||
const archivedWorkflowNames = useMemo(
|
||||
() =>
|
||||
workflowChanges
|
||||
.filter((change) => change.action === 'archive')
|
||||
.map((change) => change.currentName),
|
||||
[workflowChanges]
|
||||
)
|
||||
|
||||
// Send the full stored mapping for every dependent whose parent is RESOLVED - mapped (its
|
||||
// effective value: re-pick, stored, or blank-after-change) or copy-selected (re-pick, stored,
|
||||
// or the source reference; promote translates a source document id to its copied counterpart
|
||||
// at write time). The server persists this verbatim as the stored mapping; fields whose
|
||||
// parent is unresolved are omitted (they can't be configured). This is the whole "what's in
|
||||
// the mapping goes in" contract, shared by Save and Sync so the two persist identically.
|
||||
const buildDependentValues = () =>
|
||||
dependentReconfigs.flatMap((field) => {
|
||||
const parent = entryForDependent(field)
|
||||
if (!parent) return []
|
||||
const resolution = resolutionFor(parent)
|
||||
if (resolution === 'unresolved') return []
|
||||
return [
|
||||
{
|
||||
workflowId: field.targetWorkflowId,
|
||||
blockId: field.targetBlockId,
|
||||
subBlockKey: field.subBlockKey,
|
||||
value: dependentValueFor(field, parent, resolution),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const buildMappingEntries = () =>
|
||||
entries.map((entry) => ({
|
||||
resourceType: entry.resourceType,
|
||||
sourceId: entry.sourceId,
|
||||
targetId: targetFor(entry) || null,
|
||||
}))
|
||||
|
||||
// Dirty only on a real change from the stored/suggested target - so a freshly loaded
|
||||
// mapping (even with name-match suggestions shown) isn't dirty until the user edits.
|
||||
const targetsDirty = useMemo(
|
||||
() =>
|
||||
entries.some((entry) => {
|
||||
const key = entryKey(entry)
|
||||
return key in targets && targets[key] !== (entry.targetId ?? '')
|
||||
}),
|
||||
[entries, targets]
|
||||
)
|
||||
|
||||
// A dependent re-pick that differs from its stored value also dirties the editor. A re-pick
|
||||
// under a changed parent is covered by `targetsDirty` (the parent override is the change).
|
||||
const reconfigDirty = useMemo(
|
||||
() =>
|
||||
dependentReconfigs.some((field) => {
|
||||
const repicked = reconfig[dependentKey(field)]
|
||||
return repicked !== undefined && repicked !== field.currentValue
|
||||
}),
|
||||
[dependentReconfigs, reconfig]
|
||||
)
|
||||
|
||||
const dirty = targetsDirty || reconfigDirty
|
||||
|
||||
const save = () => {
|
||||
if (!otherWorkspaceId || !dirty || updateMapping.isPending) return
|
||||
updateMapping.mutate(
|
||||
{
|
||||
workspaceId,
|
||||
body: {
|
||||
otherWorkspaceId,
|
||||
direction,
|
||||
// Persist the full effective set (WYSIWYG). Only include dependentValues once the
|
||||
// diff has loaded; omitted before that so an early save can't wipe the store from
|
||||
// an unknown set.
|
||||
entries: buildMappingEntries(),
|
||||
...(diff.data ? { dependentValues: buildDependentValues() } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
toast.success('Mapping saved')
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
setTargets({})
|
||||
setReconfig({})
|
||||
}
|
||||
|
||||
const sync = async () => {
|
||||
if (!otherWorkspaceId) return
|
||||
setSubmitting(true)
|
||||
// Capture every payload from the state at confirm time, before any await - the page's
|
||||
// controls stay mounted during the run (unlike the old modal, which blocked its UI), so a
|
||||
// mid-flight edit must not leak into the promote body.
|
||||
const mappingEntries = buildMappingEntries()
|
||||
const dependentValues = diff.data ? buildDependentValues() : null
|
||||
// Copy the referenced-but-unmapped resources the user kept selected, excluding any the
|
||||
// user mapped in-session (reconciliation: maps win). The backend validates each id
|
||||
// against the plan's copy candidates too, so a mapped/stale id is dropped server-side
|
||||
// regardless.
|
||||
const selectedCopyables = visibleCopyables.filter((candidate) =>
|
||||
copySelected.has(forkRefKey(candidate))
|
||||
)
|
||||
try {
|
||||
await updateMapping.mutateAsync({
|
||||
workspaceId,
|
||||
body: { otherWorkspaceId, direction, entries: mappingEntries },
|
||||
})
|
||||
const copyResources = {
|
||||
knowledgeBases: selectedCopyables
|
||||
.filter((c) => c.kind === 'knowledge-base')
|
||||
.map((c) => c.sourceId),
|
||||
tables: selectedCopyables.filter((c) => c.kind === 'table').map((c) => c.sourceId),
|
||||
customTools: selectedCopyables
|
||||
.filter((c) => c.kind === 'custom-tool')
|
||||
.map((c) => c.sourceId),
|
||||
skills: selectedCopyables.filter((c) => c.kind === 'skill').map((c) => c.sourceId),
|
||||
// Files are identified by storage key (the copyable candidate's sourceId is the key).
|
||||
files: selectedCopyables.filter((c) => c.kind === 'file').map((c) => c.sourceId),
|
||||
mcpServers: selectedCopyables.filter((c) => c.kind === 'mcp-server').map((c) => c.sourceId),
|
||||
}
|
||||
|
||||
const result = await promote.mutateAsync({
|
||||
workspaceId,
|
||||
body: {
|
||||
otherWorkspaceId,
|
||||
direction,
|
||||
// Once the diff has loaded, ALWAYS send the full effective set - including `[]`,
|
||||
// which means "every dependent went away" and must reconcile/clear the live replace
|
||||
// targets' stored rows. Collapsing `[]` into omission would make the backend
|
||||
// PRESERVE stale rows. Only omit before the diff loads (set unknown), so the
|
||||
// existing store is left untouched.
|
||||
...(dependentValues !== null ? { dependentValues } : {}),
|
||||
...(selectedCopyables.length > 0 ? { copyResources } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.promoteRunId) {
|
||||
if (result.blockers.length > 0) {
|
||||
// The server's authoritative gate re-found would-clear references (something changed
|
||||
// between the preview and Sync). The mutation's settled invalidation refetches the
|
||||
// diff, so the refreshed blocker list is already on its way in.
|
||||
const count = result.blockers.length
|
||||
toast.error(
|
||||
`Sync blocked: ${count} reference${count === 1 ? '' : 's'} would break in the target. Review the updated list and try again.`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (result.unmappedRequired.length > 0) {
|
||||
// Name the actual blocking kinds rather than always blaming credentials: the server
|
||||
// blocks on required REFERENCES (credentials and/or secrets); required dependents are
|
||||
// gated client-side before this runs (see the Sync chip's disabled tooltip).
|
||||
const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind))
|
||||
toast.error(`Map all required ${forkRequiredKindsLabel(kinds)} first`)
|
||||
return
|
||||
}
|
||||
toast.error('Sync did not complete')
|
||||
return
|
||||
}
|
||||
|
||||
const target = otherWorkspaceName || 'the workspace'
|
||||
const label = direction === 'pull' ? `Pulled from "${target}"` : `Pushed to "${target}"`
|
||||
// A sync only commits once every reference is mapped/copied and every required dependent
|
||||
// has a value (the zero-cleared-refs invariant + `reconfigComplete`), so the old
|
||||
// "re-check X block - something may have been cleared" warnings only fired on
|
||||
// preview-vs-commit races and read as false alarms. Those rare cases still land in the
|
||||
// Activity entry (needsConfiguration/clearedOptional are recorded there) and a
|
||||
// needs-config workflow visibly stays undeployed. Deploy FAILURES remain a real,
|
||||
// actionable outcome, so they keep a warning.
|
||||
if (result.deployFailed > 0) {
|
||||
const n = result.deployFailed
|
||||
toast.warning(
|
||||
`${label}, but ${n} workflow${n === 1 ? '' : 's'} failed to deploy — open and redeploy ${n === 1 ? 'it' : 'them'}.`
|
||||
)
|
||||
} else {
|
||||
toast.success(label)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Sync failed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
direction,
|
||||
otherWorkspaceName,
|
||||
isLoading: enabled && mapping.isLoading,
|
||||
isError: mapping.isError,
|
||||
errorMessage: mapping.isError ? getErrorMessage(mapping.error, 'Failed to load mapping') : null,
|
||||
diffErrorMessage: diff.isError
|
||||
? getErrorMessage(diff.error, "Couldn't load sync details. Reload the page to retry.")
|
||||
: null,
|
||||
hasDiff: Boolean(diff.data),
|
||||
hasMapping: Boolean(mapping.data),
|
||||
groups,
|
||||
kindSummaries,
|
||||
targetFor,
|
||||
setTarget,
|
||||
takenOwnersFor,
|
||||
usagesForEntry,
|
||||
dependentsForEntry,
|
||||
parentChangedFor,
|
||||
targetWorkspaceId: diff.data?.targetWorkspaceId ?? '',
|
||||
sourceWorkspaceId: diff.data?.sourceWorkspaceId ?? '',
|
||||
reconfig,
|
||||
setReconfig,
|
||||
copyableKeys,
|
||||
copyingKeys,
|
||||
copySelected,
|
||||
toggleCopyKeys,
|
||||
referencedByKind,
|
||||
unreferencedByKind,
|
||||
hasVisibleCopyables: visibleCopyables.length > 0,
|
||||
blockingRefs,
|
||||
dependentClears,
|
||||
workflowChanges,
|
||||
archivedWorkflowNames,
|
||||
mcpReauthCount: diff.data?.mcpReauthServerIds.length ?? 0,
|
||||
inlineSecretCount: diff.data?.inlineSecretSources.length ?? 0,
|
||||
dirty,
|
||||
saving: updateMapping.isPending,
|
||||
save,
|
||||
discard,
|
||||
submitting,
|
||||
syncDisabled,
|
||||
syncDisabledReason,
|
||||
sync,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user