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
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { CONTROL_BACK_EDGE_HANDLES, EDGE, SUBFLOW_CONTROL_EDGE_HANDLES } from '@/executor/constants'
|
|
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
|
import type { DAGEdge } from '@/executor/dag/types'
|
|
import type { NormalizedBlockOutput } from '@/executor/types'
|
|
|
|
const logger = createLogger('EdgeManager')
|
|
|
|
export class EdgeManager {
|
|
private deactivatedEdges = new Set<string>()
|
|
private nodesWithActivatedEdge = new Set<string>()
|
|
|
|
constructor(private dag: DAG) {}
|
|
|
|
processOutgoingEdges(
|
|
node: DAGNode,
|
|
output: NormalizedBlockOutput,
|
|
skipBackwardsEdge = false
|
|
): string[] {
|
|
const readyNodes: string[] = []
|
|
const activatedTargets: string[] = []
|
|
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
|
|
|
|
for (const [, edge] of node.outgoingEdges) {
|
|
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
|
|
continue
|
|
}
|
|
|
|
if (!this.shouldActivateEdge(edge, output)) {
|
|
if (!this.isSubflowControlEdge(edge.sourceHandle)) {
|
|
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
|
|
}
|
|
continue
|
|
}
|
|
|
|
activatedTargets.push(edge.target)
|
|
}
|
|
|
|
// Track nodes that have received at least one activated edge
|
|
for (const targetId of activatedTargets) {
|
|
this.nodesWithActivatedEdge.add(targetId)
|
|
}
|
|
|
|
const cascadeTargets = new Set<string>()
|
|
for (const { target, handle } of edgesToDeactivate) {
|
|
this.deactivateEdgeAndDescendants(node.id, target, handle, cascadeTargets)
|
|
}
|
|
|
|
if (activatedTargets.length === 0) {
|
|
for (const { target } of edgesToDeactivate) {
|
|
if (this.isTerminalControlNode(target)) {
|
|
cascadeTargets.add(target)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const targetId of activatedTargets) {
|
|
const targetNode = this.dag.nodes.get(targetId)
|
|
if (!targetNode) {
|
|
logger.warn('Target node not found', { target: targetId })
|
|
continue
|
|
}
|
|
targetNode.incomingEdges.delete(node.id)
|
|
}
|
|
|
|
for (const targetId of activatedTargets) {
|
|
if (this.isTargetReady(targetId)) {
|
|
readyNodes.push(targetId)
|
|
}
|
|
}
|
|
|
|
const isDeadEnd = activatedTargets.length === 0
|
|
const isRoutedDeadEnd = isDeadEnd && !!(output.selectedOption || output.selectedRoute)
|
|
|
|
for (const targetId of cascadeTargets) {
|
|
if (!readyNodes.includes(targetId) && !activatedTargets.includes(targetId)) {
|
|
if (!isDeadEnd || !this.isTargetReady(targetId)) continue
|
|
|
|
if (isRoutedDeadEnd) {
|
|
// A condition/router deliberately selected a dead-end path.
|
|
// Only queue the sentinel if it belongs to the SAME subflow as the
|
|
// current node (the condition is inside the loop/parallel and the
|
|
// loop still needs to continue/exit). Downstream subflow sentinels
|
|
// should NOT fire.
|
|
if (this.isEnclosingSentinel(node, targetId)) {
|
|
readyNodes.push(targetId)
|
|
}
|
|
} else {
|
|
readyNodes.push(targetId)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (output.selectedRoute !== EDGE.LOOP_EXIT && output.selectedRoute !== EDGE.PARALLEL_EXIT) {
|
|
for (const { target } of edgesToDeactivate) {
|
|
if (
|
|
!readyNodes.includes(target) &&
|
|
!activatedTargets.includes(target) &&
|
|
this.nodesWithActivatedEdge.has(target) &&
|
|
this.isTargetReady(target)
|
|
) {
|
|
readyNodes.push(target)
|
|
}
|
|
}
|
|
}
|
|
|
|
return readyNodes
|
|
}
|
|
|
|
isNodeReady(node: DAGNode): boolean {
|
|
return node.incomingEdges.size === 0 || this.countActiveIncomingEdges(node) === 0
|
|
}
|
|
|
|
restoreIncomingEdge(targetNodeId: string, sourceNodeId: string): void {
|
|
const targetNode = this.dag.nodes.get(targetNodeId)
|
|
if (!targetNode) {
|
|
logger.warn('Cannot restore edge - target node not found', { targetNodeId })
|
|
return
|
|
}
|
|
|
|
targetNode.incomingEdges.add(sourceNodeId)
|
|
}
|
|
|
|
clearDeactivatedEdges(): void {
|
|
this.deactivatedEdges.clear()
|
|
this.nodesWithActivatedEdge.clear()
|
|
}
|
|
|
|
getDeactivatedEdges(): string[] {
|
|
return Array.from(this.deactivatedEdges)
|
|
}
|
|
|
|
getNodesWithActivatedEdge(): string[] {
|
|
return Array.from(this.nodesWithActivatedEdge)
|
|
}
|
|
|
|
hasActivatedEdge(nodeId: string): boolean {
|
|
return this.nodesWithActivatedEdge.has(nodeId)
|
|
}
|
|
|
|
restoreDeactivatedEdges(edgeKeys?: string[], activatedNodeIds?: string[]): void {
|
|
this.deactivatedEdges = new Set(
|
|
(edgeKeys ?? []).map((edgeKey) => this.normalizeSerializedEdgeKey(edgeKey))
|
|
)
|
|
this.nodesWithActivatedEdge = new Set(activatedNodeIds ?? [])
|
|
}
|
|
|
|
markNodeWithActivatedEdge(nodeId: string): void {
|
|
this.nodesWithActivatedEdge.add(nodeId)
|
|
}
|
|
|
|
/**
|
|
* Deactivates the `error` edge of a successfully-resumed pause block instead of
|
|
* firing it: the block completed normally, so its error path is pruned (and any
|
|
* now-dead descendants cascaded), mirroring how a normally-succeeding block's
|
|
* error edge is handled in {@link processOutgoingEdges}.
|
|
*
|
|
* `cascadeTargets` is intentionally left undefined here (unlike the
|
|
* {@link processOutgoingEdges} call site, which passes an explicit Set): the
|
|
* resume path has no loop/parallel sentinels to queue — the pause block's
|
|
* `source` edge drives continuation — so cascade-target collection is omitted.
|
|
*/
|
|
deactivateResumedEdge(sourceId: string, targetId: string, sourceHandle?: string): void {
|
|
this.deactivateEdgeAndDescendants(sourceId, targetId, sourceHandle)
|
|
}
|
|
|
|
/**
|
|
* Clear deactivated edges for a set of nodes (used when restoring loop state for next iteration).
|
|
*
|
|
* Only clears edges whose SOURCE is in the provided set. Edges pointing INTO a node in the set
|
|
* whose source lives outside (e.g. an external branch whose path was cascade-deactivated) must
|
|
* remain deactivated — otherwise `countActiveIncomingEdges` would count a source that will never
|
|
* fire again, stalling the loop on its next iteration.
|
|
*
|
|
* Deactivated edge keys encode the source separately so node IDs with shared prefixes
|
|
* cannot clear each other's deactivated edges.
|
|
*/
|
|
clearDeactivatedEdgesForNodes(nodeIds: Set<string>): void {
|
|
const edgesToRemove: string[] = []
|
|
for (const edgeKey of this.deactivatedEdges) {
|
|
const sourceId = this.parseEdgeKey(edgeKey)?.sourceId
|
|
if (!sourceId) continue
|
|
|
|
for (const nodeId of nodeIds) {
|
|
if (sourceId === nodeId) {
|
|
edgesToRemove.push(edgeKey)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for (const edgeKey of edgesToRemove) {
|
|
this.deactivatedEdges.delete(edgeKey)
|
|
}
|
|
for (const nodeId of nodeIds) {
|
|
this.nodesWithActivatedEdge.delete(nodeId)
|
|
}
|
|
}
|
|
|
|
private isTargetReady(targetId: string): boolean {
|
|
const targetNode = this.dag.nodes.get(targetId)
|
|
return targetNode ? this.isNodeReady(targetNode) : false
|
|
}
|
|
|
|
/**
|
|
* Checks if the cascade target sentinel belongs to the same subflow as the source node.
|
|
* A condition inside a loop that hits a dead-end should still allow the enclosing
|
|
* loop's sentinel to fire so the loop can continue or exit.
|
|
*/
|
|
private isEnclosingSentinel(sourceNode: DAGNode, sentinelId: string): boolean {
|
|
const sentinel = this.dag.nodes.get(sentinelId)
|
|
if (!sentinel?.metadata.isSentinel) return false
|
|
|
|
const sourceSubflowType = sourceNode.metadata.subflowType
|
|
const sentinelSubflowType = sentinel.metadata.subflowType
|
|
const sourceSubflowId = sourceNode.metadata.subflowId
|
|
const sentinelSubflowId = sentinel.metadata.subflowId
|
|
|
|
if (
|
|
sourceSubflowType &&
|
|
sentinelSubflowType &&
|
|
sourceSubflowType === sentinelSubflowType &&
|
|
sourceSubflowId &&
|
|
sentinelSubflowId &&
|
|
sourceSubflowId === sentinelSubflowId
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private isSubflowControlEdge(handle?: string): boolean {
|
|
return handle !== undefined && SUBFLOW_CONTROL_EDGE_HANDLES.has(handle)
|
|
}
|
|
|
|
private isBackwardsEdge(sourceHandle?: string): boolean {
|
|
return sourceHandle !== undefined && CONTROL_BACK_EDGE_HANDLES.has(sourceHandle)
|
|
}
|
|
|
|
private isTerminalControlNode(nodeId: string): boolean {
|
|
const node = this.dag.nodes.get(nodeId)
|
|
if (!node || node.outgoingEdges.size === 0) return false
|
|
|
|
for (const [, edge] of node.outgoingEdges) {
|
|
if (!this.isSubflowControlEdge(edge.sourceHandle)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private shouldActivateEdge(edge: DAGEdge, output: NormalizedBlockOutput): boolean {
|
|
const handle = edge.sourceHandle
|
|
|
|
if (output.selectedRoute === EDGE.LOOP_EXIT) {
|
|
return handle === EDGE.LOOP_EXIT
|
|
}
|
|
|
|
if (output.selectedRoute === EDGE.LOOP_CONTINUE) {
|
|
return handle === EDGE.LOOP_CONTINUE || handle === EDGE.LOOP_CONTINUE_ALT
|
|
}
|
|
|
|
if (output.selectedRoute === EDGE.PARALLEL_EXIT) {
|
|
return handle === EDGE.PARALLEL_EXIT
|
|
}
|
|
|
|
if (output.selectedRoute === EDGE.PARALLEL_CONTINUE) {
|
|
return handle === EDGE.PARALLEL_CONTINUE
|
|
}
|
|
|
|
if (this.isSubflowControlEdge(handle)) {
|
|
return false
|
|
}
|
|
|
|
if (!handle) {
|
|
return true
|
|
}
|
|
|
|
if (handle.startsWith(EDGE.CONDITION_PREFIX)) {
|
|
const conditionValue = handle.substring(EDGE.CONDITION_PREFIX.length)
|
|
return output.selectedOption === conditionValue
|
|
}
|
|
|
|
if (handle.startsWith(EDGE.ROUTER_PREFIX)) {
|
|
const routeId = handle.substring(EDGE.ROUTER_PREFIX.length)
|
|
return output.selectedRoute === routeId
|
|
}
|
|
|
|
switch (handle) {
|
|
case EDGE.ERROR:
|
|
return !!output.error
|
|
|
|
case EDGE.SOURCE:
|
|
return !output.error
|
|
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private deactivateEdgeAndDescendants(
|
|
sourceId: string,
|
|
targetId: string,
|
|
sourceHandle?: string,
|
|
cascadeTargets?: Set<string>,
|
|
isCascade = false
|
|
): void {
|
|
const edgeKey = this.createEdgeKey(sourceId, targetId, sourceHandle)
|
|
if (this.deactivatedEdges.has(edgeKey)) {
|
|
return
|
|
}
|
|
|
|
this.deactivatedEdges.add(edgeKey)
|
|
|
|
const targetNode = this.dag.nodes.get(targetId)
|
|
if (!targetNode) return
|
|
|
|
if (isCascade && this.isTerminalControlNode(targetId)) {
|
|
cascadeTargets?.add(targetId)
|
|
}
|
|
|
|
// Don't cascade if node has active incoming edges OR has received an activated edge
|
|
if (
|
|
this.hasActiveIncomingEdges(targetNode, edgeKey) ||
|
|
this.nodesWithActivatedEdge.has(targetId)
|
|
) {
|
|
return
|
|
}
|
|
|
|
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
|
|
if (!this.isBackwardsEdge(outgoingEdge.sourceHandle)) {
|
|
this.deactivateEdgeAndDescendants(
|
|
targetId,
|
|
outgoingEdge.target,
|
|
outgoingEdge.sourceHandle,
|
|
cascadeTargets,
|
|
true
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a node has any active incoming edges besides the one being excluded.
|
|
*/
|
|
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
|
|
for (const incomingSourceId of node.incomingEdges) {
|
|
const incomingNode = this.dag.nodes.get(incomingSourceId)
|
|
if (!incomingNode) continue
|
|
|
|
for (const [, incomingEdge] of incomingNode.outgoingEdges) {
|
|
if (incomingEdge.target === node.id) {
|
|
const incomingEdgeKey = this.createEdgeKey(
|
|
incomingSourceId,
|
|
node.id,
|
|
incomingEdge.sourceHandle
|
|
)
|
|
if (incomingEdgeKey === excludeEdgeKey) continue
|
|
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private countActiveIncomingEdges(node: DAGNode): number {
|
|
let count = 0
|
|
|
|
for (const sourceId of node.incomingEdges) {
|
|
const sourceNode = this.dag.nodes.get(sourceId)
|
|
if (!sourceNode) continue
|
|
|
|
for (const [, edge] of sourceNode.outgoingEdges) {
|
|
if (edge.target === node.id) {
|
|
const edgeKey = this.createEdgeKey(sourceId, edge.target, edge.sourceHandle)
|
|
if (!this.deactivatedEdges.has(edgeKey)) {
|
|
count++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
private createEdgeKey(sourceId: string, targetId: string, sourceHandle?: string): string {
|
|
return JSON.stringify([sourceId, targetId, sourceHandle ?? EDGE.DEFAULT])
|
|
}
|
|
|
|
private parseEdgeKey(
|
|
edgeKey: string
|
|
): { sourceId: string; targetId: string; handle: string } | null {
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(edgeKey)
|
|
} catch {
|
|
return null
|
|
}
|
|
if (
|
|
Array.isArray(parsed) &&
|
|
parsed.length === 3 &&
|
|
typeof parsed[0] === 'string' &&
|
|
typeof parsed[1] === 'string' &&
|
|
typeof parsed[2] === 'string'
|
|
) {
|
|
return { sourceId: parsed[0], targetId: parsed[1], handle: parsed[2] }
|
|
}
|
|
return null
|
|
}
|
|
|
|
private normalizeSerializedEdgeKey(edgeKey: string): string {
|
|
if (this.parseEdgeKey(edgeKey)) {
|
|
return edgeKey
|
|
}
|
|
|
|
for (const [sourceId, sourceNode] of this.dag.nodes) {
|
|
for (const [, edge] of sourceNode.outgoingEdges) {
|
|
const legacyKey = `${sourceId}-${edge.target}-${edge.sourceHandle ?? EDGE.DEFAULT}`
|
|
if (legacyKey === edgeKey) {
|
|
return this.createEdgeKey(sourceId, edge.target, edge.sourceHandle)
|
|
}
|
|
}
|
|
}
|
|
|
|
return edgeKey
|
|
}
|
|
}
|