d25d482dc2
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
384 lines
18 KiB
TypeScript
384 lines
18 KiB
TypeScript
import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork'
|
|
import { coerceObjectArray, isRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
|
import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace/dependencies'
|
|
import {
|
|
buildSelectorContextFromBlock,
|
|
SELECTOR_CONTEXT_FIELDS,
|
|
} from '@/lib/workflows/subblocks/context'
|
|
import {
|
|
buildCanonicalIndex,
|
|
buildSubBlockValues,
|
|
type CanonicalModeOverrides,
|
|
evaluateSubBlockCondition,
|
|
isNonEmptyValue,
|
|
scopeCanonicalModesForTool,
|
|
} from '@/lib/workflows/subblocks/visibility'
|
|
import { getBlock } from '@/blocks/registry'
|
|
import type { SubBlockConfig } from '@/blocks/types'
|
|
import { getDependsOnFields } from '@/blocks/utils'
|
|
import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
|
|
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
|
|
import {
|
|
createCanonicalModeGates,
|
|
isSubBlockRequired,
|
|
scanWorkflowReferences,
|
|
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
|
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
|
|
|
const isSelectorContextKey = (
|
|
key: string
|
|
): key is Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0] =>
|
|
SELECTOR_CONTEXT_FIELDS.has(key as Parameters<typeof SELECTOR_CONTEXT_FIELDS.has>[0])
|
|
|
|
interface ReconfigItem {
|
|
sourceWorkflowId: string
|
|
targetWorkflowId: string
|
|
mode: 'create' | 'replace'
|
|
}
|
|
|
|
/**
|
|
* Parent anchor types a dependent selector can hang off, with the SelectorContext key
|
|
* the new parent value is supplied under. A parent is a remappable resource (rewritten
|
|
* source->target on sync) whose target swap clears its dependents. MCP servers are
|
|
* intentionally excluded: their tool dependent has no `selectorKey` and a separate
|
|
* (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing.
|
|
*/
|
|
const PARENT_ANCHORS: ReadonlyArray<{
|
|
subBlockType: string
|
|
parentKind: ForkDependentReconfig['parentKind']
|
|
parentContextKey: string
|
|
}> = [
|
|
{ subBlockType: 'oauth-input', parentKind: 'credential', parentContextKey: 'oauthCredential' },
|
|
{
|
|
subBlockType: 'knowledge-base-selector',
|
|
parentKind: 'knowledge-base',
|
|
parentContextKey: 'knowledgeBaseId',
|
|
},
|
|
{ subBlockType: 'table-selector', parentKind: 'table', parentContextKey: 'tableId' },
|
|
]
|
|
|
|
interface EmitAnchoredParams {
|
|
/** The block (top-level) or tool config whose subblocks are scanned for anchors. */
|
|
config: NonNullable<ReturnType<typeof getBlock>>
|
|
/** Flat id -> value map for that config (top-level subblock values, or a tool's params). */
|
|
values: Record<string, unknown>
|
|
/** Block/tool type + its subblock shape, for building the source selector context. */
|
|
contextBlockType: string
|
|
contextSubBlocks: Record<string, { value?: unknown }>
|
|
blockName: string
|
|
targetWorkflowId: string
|
|
/** Canonical-mode overrides for resolving the active parent member (undefined -> value heuristic). */
|
|
canonicalModes?: CanonicalModeOverrides
|
|
/** Memoized so the deterministic target block id is derived at most once per block. */
|
|
resolveTargetBlockId: () => string
|
|
/** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */
|
|
makeSubBlockKey: (dependentId: string) => string
|
|
makeTitle: (dependent: SubBlockConfig) => string
|
|
/** Nested `tool-input` tool display name; omitted for top-level block subblocks. */
|
|
toolName?: string
|
|
/**
|
|
* Emit `providesContextKey`/`consumesContextKeys` so the modal can chain in-block
|
|
* re-picks. Top-level chains; nested tool params don't (a tool's chain would need
|
|
* per-tool context scoping - out of scope - and the common nested case is a single
|
|
* credential-anchored field).
|
|
*/
|
|
chaining: boolean
|
|
out: ForkDependentReconfig[]
|
|
}
|
|
|
|
/**
|
|
* Emit one config's credential/KB/table-anchored selector dependents that the source had
|
|
* configured. Shared by the top-level subblock scan and the nested `tool-input` tool scan.
|
|
* Walks the FULL transitive dependent chain (parent -> child -> grandchild) per anchor and
|
|
* dedups fields reachable via multiple anchors/paths.
|
|
*/
|
|
function emitAnchoredDependents(params: EmitAnchoredParams): void {
|
|
const {
|
|
config,
|
|
values,
|
|
contextBlockType,
|
|
contextSubBlocks,
|
|
blockName,
|
|
targetWorkflowId,
|
|
canonicalModes,
|
|
resolveTargetBlockId,
|
|
makeSubBlockKey,
|
|
makeTitle,
|
|
toolName,
|
|
chaining,
|
|
out,
|
|
} = params
|
|
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks)
|
|
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
|
|
const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes)
|
|
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))
|
|
// A field could hang off two anchors (or be reachable via two paths); emit it once.
|
|
const seen = new Set<string>()
|
|
|
|
for (const anchor of PARENT_ANCHORS) {
|
|
for (const anchorCfg of config.subBlocks) {
|
|
if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue
|
|
// An anchor whose canonical pair is in ADVANCED (manual) mode is skipped entirely: the
|
|
// active value is the user-owned manual member's, which is verbatim by policy - a sync
|
|
// never remaps it, so its dependents are never cleared and there is nothing to re-pick.
|
|
if (gates.isAdvancedActiveGroup(anchorCfg.id)) continue
|
|
// Basic mode: the anchor selector's own value. Nested tools can store the pick under the
|
|
// pair's `canonicalParamId` instead (the tool-input UI writes the canonical key), so fall
|
|
// back to it - but only when that key is not itself a subblock id (when it is, the key
|
|
// is the manual member's own field and reading it would leak a manual value).
|
|
let rawValue = values[anchorCfg.id]
|
|
if (
|
|
!isNonEmptyValue(rawValue) &&
|
|
anchorCfg.canonicalParamId &&
|
|
!configById.has(anchorCfg.canonicalParamId)
|
|
) {
|
|
rawValue = values[anchorCfg.canonicalParamId]
|
|
}
|
|
const parentSourceId = typeof rawValue === 'string' ? rawValue : ''
|
|
if (!parentSourceId) continue
|
|
// Multi-value parents (comma-joined) can't match a single mapping entry; skip
|
|
// (the field falls back to needs-config) rather than mis-bind to one of several.
|
|
if (parentSourceId.includes(',')) continue
|
|
|
|
// Context the dependents need (spreadsheetId, ...) minus the parent key the modal supplies.
|
|
const context: Record<string, string> = {}
|
|
for (const [key, value] of Object.entries(fullContext)) {
|
|
if (key === anchor.parentContextKey) continue
|
|
if (typeof value === 'string' && value) context[key] = value
|
|
}
|
|
|
|
for (const clear of getWorkflowSearchDependentClears(config.subBlocks, anchorCfg.id)) {
|
|
const dependent = configById.get(clear.subBlockId)
|
|
if (!dependent?.id || !dependent.selectorKey) continue
|
|
// Skip fields gated off by their `condition` - a selector under a now-inactive
|
|
// operation (e.g. a move-only label while the block reads) isn't in play. We do
|
|
// NOT require a source value: an active selector the source left empty is still
|
|
// offered, so the user can set a label/sheet during the swap even when the source
|
|
// (or a prior sync) cleared it - the whole point of the in-place re-pick.
|
|
if (dependent.condition && !evaluateSubBlockCondition(dependent.condition, values)) continue
|
|
// Skip a DORMANT canonical member: when the dependent's own pair is in advanced
|
|
// (manual) mode, the selector is not the live field - the manual member is, and it
|
|
// is verbatim by policy (never cleared by a remap), so there's nothing to re-pick.
|
|
if (gates.isDormantMember(dependent.id)) continue
|
|
// The SelectorContext key this field supplies to its own descendants, so the
|
|
// modal can chain re-picks (re-picked spreadsheet feeds the sheet selector).
|
|
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[dependent.id] ?? dependent.id
|
|
// Dedup by canonical key so a basic/advanced pair (or two paths to the same field)
|
|
// is offered exactly once.
|
|
if (seen.has(canonicalKey)) continue
|
|
seen.add(canonicalKey)
|
|
const providesContextKey =
|
|
chaining && isSelectorContextKey(canonicalKey) ? canonicalKey : undefined
|
|
// The SelectorContext keys this field needs from in-block siblings (e.g. a sheet
|
|
// needs the spreadsheet), excluding the anchor key the modal already supplies, so
|
|
// the modal can keep a child disabled until its re-picked parent is chosen.
|
|
const consumesContextKeys = chaining
|
|
? [
|
|
...new Set(
|
|
getDependsOnFields(dependent.dependsOn)
|
|
.map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent)
|
|
.filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key))
|
|
),
|
|
]
|
|
: []
|
|
// Carry the selector's static `mimeType` filter (Drive/Sheets pickers) so the
|
|
// modal selector loads the same filtered list the editor would, not all files.
|
|
const dependentContext =
|
|
typeof dependent.mimeType === 'string' && dependent.mimeType
|
|
? { ...context, mimeType: dependent.mimeType }
|
|
: context
|
|
// Nested tools can store the pick under the pair's `canonicalParamId` (the tool-input
|
|
// UI writes the canonical key); fall back to it when the key isn't a subblock's own id.
|
|
const rawDependentValue =
|
|
values[dependent.id] ??
|
|
(dependent.canonicalParamId && !configById.has(dependent.canonicalParamId)
|
|
? values[dependent.canonicalParamId]
|
|
: undefined)
|
|
const rawSourceValue = typeof rawDependentValue === 'string' ? rawDependentValue : ''
|
|
out.push({
|
|
parentKind: anchor.parentKind,
|
|
parentSourceId,
|
|
parentContextKey: anchor.parentContextKey,
|
|
targetWorkflowId,
|
|
targetBlockId: resolveTargetBlockId(),
|
|
blockName,
|
|
subBlockKey: makeSubBlockKey(dependent.id),
|
|
selectorKey: dependent.selectorKey,
|
|
title: makeTitle(dependent),
|
|
...(toolName ? { toolName } : {}),
|
|
// Source value, so the always-on listing pre-fills a stable parent's selector.
|
|
// The diff route overlays the stored/target-draft value onto `currentValue`;
|
|
// `sourceValue` stays the raw source reference (the copy-resolved parent's seed).
|
|
currentValue: rawSourceValue,
|
|
required: isSubBlockRequired(dependent.required, values),
|
|
providesContextKey,
|
|
consumesContextKeys,
|
|
context: dependentContext,
|
|
sourceValue: rawSourceValue,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan the source's deployed workflows for configured selector fields that `dependsOn`
|
|
* a remappable parent (a credential, knowledge base, or table) - the fields a sync clears
|
|
* whenever that parent's target changes. Covers top-level block subblocks AND selectors
|
|
* nested inside `tool-input` tools (Agent/tool blocks), so a Gmail tool's label inside an
|
|
* Agent block is offered for re-pick too. Each entry carries the deterministic target
|
|
* block id, the parent it hangs off (so the modal can bind it to the newly-chosen target),
|
|
* and the source-derived selector context. Every selector active for the source's current
|
|
* operation is emitted - including ones the source left empty - so the user can set a
|
|
* value in place during the swap even when the source (or a prior sync) had none; only
|
|
* selectors gated off by their `condition` (a different operation's variant) are skipped.
|
|
* Scans one target `mode` per call: `replace` for targets that exist (re-pick against the
|
|
* swapped parent), `create` for never-synced workflows (pre-configure what the first sync
|
|
* writes - the diff route emits both).
|
|
*
|
|
* `resolveTargetBlockId` MUST be the same resolver `copyWorkflowStateIntoTarget` uses for
|
|
* this promote (see {@link buildForkBlockIdResolver}); otherwise the modal would key a
|
|
* re-pick by a derived id while the sync writes the block under its persisted counterpart,
|
|
* and the override would silently miss.
|
|
*/
|
|
export function collectForkDependentReconfigs(
|
|
items: ReconfigItem[],
|
|
sourceStates: Map<string, WorkflowState>,
|
|
resolveTargetBlockId: ForkBlockIdResolver,
|
|
/**
|
|
* Which target mode to scan. Defaults to `replace` (the reconfigure UI, where the user re-picks
|
|
* a dependent against a swapped parent). The pre-sync cleared-ref list passes `create` to surface
|
|
* dependents a new target inherits that a remapped parent will clear (it can't be re-picked yet).
|
|
*/
|
|
mode: 'create' | 'replace' = 'replace'
|
|
): ForkDependentReconfig[] {
|
|
const out: ForkDependentReconfig[] = []
|
|
for (const item of items) {
|
|
if (item.mode !== mode) continue
|
|
const state = sourceStates.get(item.sourceWorkflowId)
|
|
if (!state) continue
|
|
for (const [sourceBlockId, block] of Object.entries(state.blocks)) {
|
|
const config = getBlock(block.type)
|
|
if (!config) continue
|
|
const subBlocks = (block.subBlocks ?? {}) as Record<string, { value?: unknown }>
|
|
const sourceValues = buildSubBlockValues(subBlocks)
|
|
let cachedTargetBlockId: string | null = null
|
|
const resolveBlockId = () =>
|
|
(cachedTargetBlockId ??= resolveTargetBlockId(item.targetWorkflowId, sourceBlockId))
|
|
|
|
// Top-level credential/KB/table-anchored selectors. Block-level canonicalModes pick the
|
|
// active parent member; nested tools below pass their tool-scoped overrides (via
|
|
// scopeCanonicalModesForTool), falling back to the value heuristic only when none is set.
|
|
emitAnchoredDependents({
|
|
config,
|
|
values: sourceValues,
|
|
contextBlockType: block.type,
|
|
contextSubBlocks: subBlocks,
|
|
blockName: block.name,
|
|
targetWorkflowId: item.targetWorkflowId,
|
|
canonicalModes: block.data?.canonicalModes,
|
|
resolveTargetBlockId: resolveBlockId,
|
|
makeSubBlockKey: (id) => id,
|
|
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
|
|
chaining: true,
|
|
out,
|
|
})
|
|
|
|
// Nested `tool-input` tools: each selected tool's own credential-anchored selectors,
|
|
// keyed `toolInput[index].paramId` (matching the needs-config key). Field `title` stays
|
|
// plain; `toolName` carries the tool so the UI can show block → tool → field tiers.
|
|
for (const cfg of config.subBlocks) {
|
|
if (cfg.type !== 'tool-input' || !cfg.id) continue
|
|
const { array: tools } = coerceObjectArray(subBlocks[cfg.id]?.value)
|
|
if (!tools) continue
|
|
for (let index = 0; index < tools.length; index++) {
|
|
const tool = tools[index]
|
|
if (!isRecord(tool) || typeof tool.type !== 'string') continue
|
|
const toolConfig = getBlock(tool.type)
|
|
if (!toolConfig) continue
|
|
const toolParams = isRecord(tool.params) ? tool.params : {}
|
|
// A tool's `operation` is stored at the tool level, not in params, but subblock
|
|
// conditions reference it (e.g. a Gmail label only under `read_gmail`). Merge it
|
|
// in so condition-gating matches the editor's `{ operation, ...params }`.
|
|
const toolValues =
|
|
typeof tool.operation === 'string'
|
|
? { operation: tool.operation, ...toolParams }
|
|
: toolParams
|
|
const toolContextSubBlocks: Record<string, { value?: unknown }> = {}
|
|
for (const [key, value] of Object.entries(toolValues)) {
|
|
toolContextSubBlocks[key] = { value }
|
|
}
|
|
const toolLabel =
|
|
typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name
|
|
const toolInputKey = cfg.id
|
|
const toolIndex = index
|
|
emitAnchoredDependents({
|
|
config: toolConfig,
|
|
values: toolValues,
|
|
contextBlockType: tool.type,
|
|
contextSubBlocks: toolContextSubBlocks,
|
|
blockName: block.name,
|
|
targetWorkflowId: item.targetWorkflowId,
|
|
canonicalModes: scopeCanonicalModesForTool(
|
|
block.data?.canonicalModes,
|
|
toolIndex,
|
|
tool.type
|
|
),
|
|
resolveTargetBlockId: resolveBlockId,
|
|
makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`,
|
|
makeTitle: (dependent) => dependent.title ?? dependent.id ?? '',
|
|
toolName: toolLabel,
|
|
chaining: false,
|
|
out,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
interface ResourceUsageItem {
|
|
sourceWorkflowId: string
|
|
targetWorkflowId: string
|
|
mode: 'create' | 'replace'
|
|
/** Source workflow name, shown as the (renamed-aware) target name in the listing. */
|
|
sourceMeta: { name: string }
|
|
}
|
|
|
|
/**
|
|
* Every workflow each mapped resource (any kind) is used in - the spine of the always-on
|
|
* reconfigure listing under a mapping entry. Scans each source workflow's references
|
|
* (deduped per workflow, so a resource used by several blocks is one workflow usage) and
|
|
* groups them by `(kind, sourceId)`. Unlike {@link collectForkDependentReconfigs} this is
|
|
* NOT anchor-limited: it includes resources with no configurable dependent (env vars, files,
|
|
* a Gmail block with no active label) so the modal can still list - greyed - the workflows
|
|
* they appear in. Covers EVERY deployed source workflow - replace targets and creates
|
|
* (never-synced workflows) alike - so the listing accounts for the full next sync.
|
|
*/
|
|
export function collectForkResourceUsages(
|
|
items: ResourceUsageItem[],
|
|
sourceStates: Map<string, WorkflowState>
|
|
): ForkResourceUsage[] {
|
|
const byResource = new Map<string, ForkResourceUsage>()
|
|
for (const item of items) {
|
|
const state = sourceStates.get(item.sourceWorkflowId)
|
|
if (!state) continue
|
|
// scanWorkflowReferences already dedups by `${kind}:${sourceId}` across the workflow,
|
|
// so each resource appears once per workflow here.
|
|
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
|
|
const key = `${reference.kind}\u0000${reference.sourceId}`
|
|
let usage = byResource.get(key)
|
|
if (!usage) {
|
|
usage = { parentKind: reference.kind, parentSourceId: reference.sourceId, workflows: [] }
|
|
byResource.set(key, usage)
|
|
}
|
|
usage.workflows.push({
|
|
workflowId: item.targetWorkflowId,
|
|
workflowName: item.sourceMeta.name,
|
|
})
|
|
}
|
|
}
|
|
return Array.from(byResource.values())
|
|
}
|