Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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())
}