Skip to content

Commit f4f688f

Browse files
committed
make dependsOn subblock mapping cleanly stored
1 parent a2828a3 commit f4f688f

19 files changed

Lines changed: 1168 additions & 530 deletions

File tree

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

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,54 @@ import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
44
import { parseRequest } from '@/lib/api/server'
55
import { getSession } from '@/lib/auth'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import {
8+
coerceObjectArray,
9+
isRecord,
10+
type SubBlockRecord,
11+
} from '@/lib/workflows/persistence/remap-internal-ids'
12+
import { loadTargetDraftSubBlocks } from '@/lib/workspaces/fork/copy/copy-workflows'
713
import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
814
import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
915
import { loadForkBlockMap } from '@/lib/workspaces/fork/mapping/block-map-store'
10-
import { collectForkDependentReconfigs } from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
16+
import {
17+
collectForkDependentReconfigs,
18+
collectForkResourceUsages,
19+
} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
20+
import {
21+
forkDependentValueKey,
22+
loadForkDependentValues,
23+
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
1124
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
1225
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
1326

27+
/** A nested dependent key `toolInput[index].paramId` (matches the override/needs-config format). */
28+
const NESTED_DEPENDENT_KEY = /^([^[]+)\[(\d+)\]\.(.+)$/
29+
30+
/**
31+
* Read a dependent field's currently-configured value from a target block's draft subBlocks,
32+
* handling the nested `toolInput[index].paramId` shape used for tool-input dependents. Seeds the
33+
* diff pre-fill from the TARGET (never the source, which would overwrite the target's own
34+
* selection on an edge that predates the stored mapping). Returns '' when unset.
35+
*/
36+
function readTargetDraftDependentValue(
37+
blockSubBlocks: SubBlockRecord | undefined,
38+
subBlockKey: string
39+
): string {
40+
if (!blockSubBlocks) return ''
41+
const nested = NESTED_DEPENDENT_KEY.exec(subBlockKey)
42+
if (nested) {
43+
const [, toolInputId, indexStr, paramId] = nested
44+
const { array } = coerceObjectArray(blockSubBlocks[toolInputId]?.value)
45+
const tool = array?.[Number(indexStr)]
46+
if (!isRecord(tool)) return ''
47+
const params = isRecord(tool.params) ? tool.params : {}
48+
const value = params[paramId]
49+
return typeof value === 'string' ? value : ''
50+
}
51+
const value = blockSubBlocks[subBlockKey]?.value
52+
return typeof value === 'string' ? value : ''
53+
}
54+
1455
export const GET = withRouteHandler(
1556
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
1657
const session = await getSession()
@@ -45,6 +86,41 @@ export const GET = withRouteHandler(
4586
const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId)
4687
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
4788

89+
// Stored dependent values are the source of truth for what each selector is set to. Overlay
90+
// them as each field's currentValue so the modal pre-fills what the user actually saved. For
91+
// an edge that predates the store the fallback is the TARGET's own configured value (loaded
92+
// from its draft) - never the source's, which would overwrite the target's selection on the
93+
// first sync. Both the stored read and the draft read are scoped to the plan's replace
94+
// targets, the only workflows with dependents to reconfigure.
95+
const replaceTargetIds = plan.items
96+
.filter((item) => item.mode === 'replace')
97+
.map((item) => item.targetWorkflowId)
98+
const [storedValues, targetDraftByWorkflow] = await Promise.all([
99+
loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
100+
loadTargetDraftSubBlocks(db, replaceTargetIds),
101+
])
102+
const storedByKey = new Map(
103+
storedValues.map((entry) => [
104+
forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey),
105+
entry.value,
106+
])
107+
)
108+
const dependentReconfigs = collectForkDependentReconfigs(
109+
plan.items,
110+
sourceStates,
111+
resolveBlockId
112+
).map((field) => ({
113+
...field,
114+
currentValue:
115+
storedByKey.get(
116+
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
117+
) ??
118+
readTargetDraftDependentValue(
119+
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
120+
field.subBlockKey
121+
),
122+
}))
123+
48124
const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
49125
kind: reference.kind,
50126
sourceId: reference.sourceId,
@@ -90,7 +166,8 @@ export const GET = withRouteHandler(
90166
unmappedOptional: plan.unmappedOptional.map(toRef),
91167
mcpReauthServerIds: plan.mcpReauthServerIds,
92168
inlineSecretSources: plan.inlineSecretSources,
93-
dependentReconfigs: collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId),
169+
dependentReconfigs,
170+
resourceUsages: collectForkResourceUsages(plan.items, sourceStates),
94171
})
95172
}
96173
)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const POST = withRouteHandler(
2525
const parsed = await parseRequest(promoteForkContract, req, context)
2626
if (!parsed.success) return parsed.response
2727
const { id } = parsed.data.params
28-
const { otherWorkspaceId, direction, dependentOverrides } = parsed.data.body
28+
const { otherWorkspaceId, direction, dependentValues } = parsed.data.body
2929

3030
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
3131

@@ -35,7 +35,7 @@ export const POST = withRouteHandler(
3535
targetWorkspaceId: auth.targetWorkspaceId,
3636
direction,
3737
userId: session.user.id,
38-
dependentOverrides,
38+
dependentValues,
3939
requestId,
4040
})
4141

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
'use client'
2+
3+
import { type Dispatch, type SetStateAction, useMemo, useState } from 'react'
4+
import { ChevronDown } from '@/components/emcn'
5+
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
6+
import { cn } from '@/lib/core/utils/cn'
7+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
8+
import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector'
9+
import {
10+
dependentKey,
11+
effectiveDependentValue,
12+
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/dependent-value'
13+
import type { SelectorKey } from '@/hooks/selectors/types'
14+
15+
interface ReconfigBlock {
16+
targetBlockId: string
17+
blockName: string
18+
fields: ForkDependentReconfig[]
19+
}
20+
21+
/** Group a workflow's dependent fields by their block, sorted by block name. */
22+
function groupByBlock(fields: ForkDependentReconfig[]): ReconfigBlock[] {
23+
const byBlock = new Map<string, ReconfigBlock>()
24+
for (const field of fields) {
25+
let block = byBlock.get(field.targetBlockId)
26+
if (!block) {
27+
block = { targetBlockId: field.targetBlockId, blockName: field.blockName, fields: [] }
28+
byBlock.set(field.targetBlockId, block)
29+
}
30+
block.fields.push(field)
31+
}
32+
return Array.from(byBlock.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
33+
}
34+
35+
interface ResourceReconfigureProps {
36+
/** Every workflow this resource is used in (from the diff's `resourceUsages`). */
37+
workflows: ForkResourceUsage['workflows']
38+
/** This resource's dependent fields across all its workflows (from `dependentReconfigs`). */
39+
dependents: ForkDependentReconfig[]
40+
/** The chosen target id (credential/KB/table) the selectors query against. */
41+
parentTargetValue: string
42+
/** True when the target was changed in-session: start blank (the old value won't resolve). */
43+
parentChanged: boolean
44+
reconfig: Record<string, string>
45+
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
46+
}
47+
48+
/**
49+
* Always-on per-resource reconfigure listing: every workflow the resource is used in, each a
50+
* chevron row that expands to its blocks + dependent selectors so the user can (re)configure
51+
* them at any time - not only right after a target swap. A workflow with nothing configurable
52+
* (a secret/file, or a credential with no dependent selector here) renders greyed and
53+
* non-expandable with a tooltip, so the usage is still visible.
54+
*/
55+
export function ResourceReconfigure({
56+
workflows,
57+
dependents,
58+
parentTargetValue,
59+
parentChanged,
60+
reconfig,
61+
setReconfig,
62+
}: ResourceReconfigureProps) {
63+
// Group each workflow's dependents into blocks once per (workflows, dependents) change, so
64+
// the grouping + per-workflow filter doesn't re-run on every parent re-render (setTargets /
65+
// setReconfig fire often during the editing step).
66+
const workflowBlocks = useMemo(
67+
() =>
68+
workflows.map((workflow) => ({
69+
workflowId: workflow.workflowId,
70+
workflowName: workflow.workflowName,
71+
blocks: groupByBlock(dependents.filter((d) => d.targetWorkflowId === workflow.workflowId)),
72+
})),
73+
[workflows, dependents]
74+
)
75+
76+
if (workflows.length === 0) return null
77+
return (
78+
<div className='mt-4'>
79+
<SettingsSection label='Workflows'>
80+
<div className='flex flex-col gap-1.5'>
81+
{workflowBlocks.map((workflow) => (
82+
<ReconfigWorkflowRow
83+
key={workflow.workflowId}
84+
workflowName={workflow.workflowName}
85+
blocks={workflow.blocks}
86+
parentTargetValue={parentTargetValue}
87+
parentChanged={parentChanged}
88+
reconfig={reconfig}
89+
setReconfig={setReconfig}
90+
/>
91+
))}
92+
</div>
93+
</SettingsSection>
94+
</div>
95+
)
96+
}
97+
98+
interface ReconfigWorkflowRowProps {
99+
workflowName: string
100+
blocks: ReconfigBlock[]
101+
parentTargetValue: string
102+
parentChanged: boolean
103+
reconfig: Record<string, string>
104+
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
105+
}
106+
107+
/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */
108+
function ReconfigWorkflowRow({
109+
workflowName,
110+
blocks,
111+
parentTargetValue,
112+
parentChanged,
113+
reconfig,
114+
setReconfig,
115+
}: ReconfigWorkflowRowProps) {
116+
const [open, setOpen] = useState(false)
117+
const configurable = blocks.length > 0
118+
119+
return (
120+
<div className={cn('flex flex-col gap-1', configurable && open && 'pb-2')}>
121+
{/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed,
122+
non-expandable row uses a native title tooltip to explain why. */}
123+
<button
124+
type='button'
125+
disabled={!configurable}
126+
onClick={() => setOpen((value) => !value)}
127+
title={configurable ? undefined : 'Used here, but nothing to configure for this resource'}
128+
className={cn(
129+
'flex w-full items-center gap-2 text-left text-sm transition-colors',
130+
configurable
131+
? 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
132+
: 'cursor-default text-[var(--text-muted)]'
133+
)}
134+
>
135+
<span className='min-w-0 flex-1 truncate'>{workflowName}</span>
136+
<ChevronDown
137+
className={cn(
138+
'h-[6px] w-[10px] shrink-0 text-[var(--text-icon)] transition-transform',
139+
configurable ? open && 'rotate-180' : 'opacity-40'
140+
)}
141+
/>
142+
</button>
143+
{configurable && open
144+
? blocks.map((block) => (
145+
<BlockReconfig
146+
key={block.targetBlockId}
147+
block={block}
148+
parentTargetValue={parentTargetValue}
149+
parentChanged={parentChanged}
150+
reconfig={reconfig}
151+
setReconfig={setReconfig}
152+
/>
153+
))
154+
: null}
155+
</div>
156+
)
157+
}
158+
159+
interface BlockReconfigProps {
160+
block: ReconfigBlock
161+
parentTargetValue: string
162+
parentChanged: boolean
163+
reconfig: Record<string, string>
164+
setReconfig: Dispatch<SetStateAction<Record<string, string>>>
165+
}
166+
167+
/** One block card: its dependent selectors, chained so a parent feeds its in-block children. */
168+
function BlockReconfig({
169+
block,
170+
parentTargetValue,
171+
parentChanged,
172+
reconfig,
173+
setReconfig,
174+
}: BlockReconfigProps) {
175+
// A field's effective value: the user's re-pick, else the stored value (stable parent) - but
176+
// blank after a parent change, since the old value no longer resolves. Shared with the modal.
177+
const effectiveValue = (field: ForkDependentReconfig) =>
178+
effectiveDependentValue(field, reconfig, parentChanged)
179+
180+
// Chain re-picks: a field that provides a SelectorContext key feeds its effective value to
181+
// its in-block descendants (a spreadsheet drives the sheet selector).
182+
const providedValues: Record<string, string> = {}
183+
const providerByKey = new Map<string, string>()
184+
for (const field of block.fields) {
185+
if (field.providesContextKey) {
186+
providerByKey.set(field.providesContextKey, dependentKey(field))
187+
const value = effectiveValue(field)
188+
if (value) providedValues[field.providesContextKey] = value
189+
}
190+
}
191+
192+
return (
193+
<div className='ml-2 flex flex-col gap-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] p-3'>
194+
<span className='font-medium text-[var(--text-secondary)] text-small'>{block.blockName}</span>
195+
{block.fields.map((field) => {
196+
// Disabled until the parent target is set AND every in-block parent it depends on has
197+
// a value, so a child never queries a stale upstream value.
198+
const ready = field.consumesContextKeys.every(
199+
(key) => !providerByKey.has(key) || providedValues[key] !== undefined
200+
)
201+
return (
202+
<div key={dependentKey(field)} className='flex flex-col gap-1'>
203+
<span className='text-[var(--text-tertiary)] text-caption'>
204+
{field.title}
205+
{field.required ? <span className='text-[var(--text-error)]'> *</span> : null}
206+
</span>
207+
<DependentFieldSelector
208+
selectorKey={field.selectorKey as SelectorKey}
209+
context={{
210+
...field.context,
211+
...providedValues,
212+
[field.parentContextKey]: parentTargetValue,
213+
}}
214+
enabled={parentTargetValue !== '' && ready}
215+
value={effectiveValue(field)}
216+
onChange={(value) =>
217+
setReconfig((prev) => {
218+
const nextState = { ...prev, [dependentKey(field)]: value }
219+
// A changed parent invalidates its children's stale re-picks.
220+
const providedKey = field.providesContextKey
221+
if (providedKey) {
222+
for (const sibling of block.fields) {
223+
if (sibling.consumesContextKeys.includes(providedKey)) {
224+
delete nextState[dependentKey(sibling)]
225+
}
226+
}
227+
}
228+
return nextState
229+
})
230+
}
231+
title={field.title}
232+
/>
233+
</div>
234+
)
235+
})}
236+
</div>
237+
)
238+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
2+
3+
/** Stable key for a per-target dependent re-pick (target workflow + block + subblock). */
4+
export function dependentKey(dependent: ForkDependentReconfig): string {
5+
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
6+
}
7+
8+
/**
9+
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
10+
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old
11+
* stored value was for the previous parent and won't resolve against the new one. Shared by the
12+
* modal (gate + payload) and the per-block selector so the rule can't drift between them.
13+
*/
14+
export function effectiveDependentValue(
15+
field: ForkDependentReconfig,
16+
reconfig: Record<string, string>,
17+
parentChanged: boolean
18+
): string {
19+
const repicked = reconfig[dependentKey(field)]
20+
if (repicked !== undefined) return repicked
21+
return parentChanged ? '' : field.currentValue
22+
}

0 commit comments

Comments
 (0)