Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useAppStore } from '@shared/stores/appStore'
import { useExtensionsStore } from '@shared/stores/extensionsStore'
import { useNavStore } from '@shared/stores/navStore'
import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore'
import { useWaitButton } from '@areas/workflows/useWaitButton'
import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflows/mockExtensions'
import { validateWorkflowPreflight } from '@areas/workflows/preflight'
import type { WorkflowExtension } from '@areas/workflows/mockExtensions'
Expand Down Expand Up @@ -335,10 +336,7 @@ function TextParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowN
}

function WaitParamRow({ nodeId }: { nodeId: string }) {
const status = useWorkflowRunStore((s) => s.runState.status)
const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId)
const continueRun = useWorkflowRunStore((s) => s.continueRun)
const isPaused = status === 'paused' && activeNodeId === nodeId
const { waitState, canContinue, isRunning, label, buttonClass, onContinue } = useWaitButton(nodeId)

return (
<div className="flex flex-col gap-1.5">
Expand All @@ -348,15 +346,29 @@ function WaitParamRow({ nodeId }: { nodeId: string }) {
</svg>
<span className="text-[11px] font-medium text-zinc-300">Wait</span>
</div>
{isPaused ? (
{waitState ? (
<button
onClick={continueRun}
className="w-full flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-md bg-amber-500/15 border border-amber-500/30 text-amber-400 hover:bg-amber-500/25 transition-colors text-[11px] font-medium animate-pulse"
onClick={onContinue}
disabled={!canContinue}
className={`w-full flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-md border transition-colors text-[11px] font-medium ${buttonClass} ${
canContinue ? (waitState === 'pending' ? 'animate-pulse' : '') : 'opacity-40 cursor-not-allowed'
}`}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Continue
{isRunning ? (
<>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Running…
</>
) : (
<>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
{label}
</>
)}
</button>
) : (
<p className="text-[10px] text-zinc-600 italic px-0.5">
Expand Down
101 changes: 101 additions & 0 deletions src/areas/workflows/nodeBehaviors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { WFNode, WFEdge } from '@shared/types/electron.d'

// ─── Node behaviors registry ──────────────────────────────────────────────────
//
// Add a new entry here when you introduce a node type that needs to participate
// in the runner's control-flow logic. The predicates and helpers below derive
// everything they need from this table — no hardcoded type checks anywhere else.
//
// • passthrough — data flows through this node unchanged. Resolvers (inputs,
// preflight typing) walk past it to find the real source.
// • branchStarter — splits the run into a user-driven sub-DAG. The runner
// pauses on these nodes and exposes a Continue/Retry button.
// • sceneOutput — terminal sink that gets pushed to the 3D viewer. Used by
// the immediate mesh-push logic during execution.

export interface NodeBehavior {
passthrough?: boolean
branchStarter?: boolean
sceneOutput?: boolean
}

const BEHAVIORS: Record<string, NodeBehavior> = {
waitNode: { passthrough: true, branchStarter: true },
outputNode: { sceneOutput: true },
}

export const isPassthrough = (type: string | undefined): boolean => !!type && !!BEHAVIORS[type]?.passthrough
export const isBranchStarter = (type: string | undefined): boolean => !!type && !!BEHAVIORS[type]?.branchStarter
export const isSceneOutput = (type: string | undefined): boolean => !!type && !!BEHAVIORS[type]?.sceneOutput

// ─── Helpers ──────────────────────────────────────────────────────────────────

/**
* Walks `sourceId` backwards through passthrough nodes (following each one's
* incoming edge) until it hits a non-passthrough node. Returns its id, or
* undefined if a passthrough has no incoming edge.
*/
export function resolveDataSource(
sourceId: string,
edges: WFEdge[],
nodeMap: Map<string, WFNode>,
): string | undefined {
let cur = sourceId
const seen = new Set<string>()
while (isPassthrough(nodeMap.get(cur)?.type) && !seen.has(cur)) {
seen.add(cur)
const parent = edges.find((e) => e.target === cur)
if (!parent) return undefined
cur = parent.source
}
return cur
}

/**
* Walks backwards from `nodeId` and returns the set of nearest upstream
* branch-starter (Wait) nodes — the first Wait found on each incoming path,
* without traversing past it. Empty = no upstream Wait. Size > 1 = the node
* merges two distinct branches.
*/
export function nearestUpstreamWaits(
nodeId: string,
edges: WFEdge[],
nodeMap: Map<string, WFNode>,
): Set<string> {
const result = new Set<string>()
const seen = new Set<string>()
const stack = edges.filter((e) => e.target === nodeId).map((e) => e.source)
while (stack.length > 0) {
const id = stack.pop()!
if (seen.has(id)) continue
seen.add(id)
if (isBranchStarter(nodeMap.get(id)?.type)) { result.add(id); continue }
for (const e of edges) if (e.target === id) stack.push(e.source)
}
return result
}

/**
* True if any forward path from `sourceId` reaches a sceneOutput node, walking
* through passthrough nodes along the way.
*/
export function reachesSceneOutput(
sourceId: string,
edges: WFEdge[],
nodeMap: Map<string, WFNode>,
): boolean {
const stack = [sourceId]
const seen = new Set<string>()
while (stack.length > 0) {
const id = stack.pop()!
if (seen.has(id)) continue
seen.add(id)
for (const e of edges) {
if (e.source !== id) continue
const tType = nodeMap.get(e.target)?.type
if (isSceneOutput(tType)) return true
if (isPassthrough(tType)) stack.push(e.target)
}
}
return false
}
49 changes: 30 additions & 19 deletions src/areas/workflows/nodes/WaitNode.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import { Handle, Position } from '@xyflow/react'
import type { WFNodeData } from '@shared/types/electron.d'
import { useWorkflowRunStore } from '../workflowRunStore'
import { useWaitButton } from '../useWaitButton'
import BaseNode from './BaseNode'

const HANDLE_STYLE = { background: '#71717a', width: 14, height: 14, border: '2.5px solid #18181b' }

export default function WaitNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) {
const status = useWorkflowRunStore((s) => s.runState.status)
const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId)
const continueRun = useWorkflowRunStore((s) => s.continueRun)
const isPaused = status === 'paused' && activeNodeId === id
const { waitState, canContinue, isRunning, label, buttonClass, statusText, onContinue } = useWaitButton(id)

const subheader = waitState ? (
<button
onClick={onContinue}
disabled={!canContinue}
className={`nodrag w-full flex items-center justify-center gap-1.5 px-2.5 py-2 border-y transition-colors text-[10px] font-medium ${buttonClass} ${
canContinue ? (waitState === 'pending' ? 'animate-pulse' : '') : 'opacity-40 cursor-not-allowed'
}`}
>
{isRunning ? (
<>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Running…
</>
) : (
<>
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
{label}
</>
)}
</button>
) : undefined

return (
<BaseNode
Expand All @@ -24,17 +47,7 @@ export default function WaitNode({ id, data, selected }: { id: string; data: WFN
<polyline points="12 6 12 12 16 14"/>
</svg>
}
subheader={isPaused ? (
<button
onClick={continueRun}
className="nodrag w-full flex items-center justify-center gap-1.5 px-2.5 py-2 bg-amber-500/15 border-y border-amber-500/30 text-amber-400 hover:bg-amber-500/25 transition-colors text-[10px] font-medium animate-pulse"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Continue
</button>
) : undefined}
subheader={subheader}
handles={
<>
<Handle type="target" position={Position.Left} style={HANDLE_STYLE} />
Expand All @@ -43,9 +56,7 @@ export default function WaitNode({ id, data, selected }: { id: string; data: WFN
}
>
<div className="px-3 pb-3 pt-2.5">
<p className="text-[10px] text-zinc-500 italic">
{isPaused ? 'Workflow paused — click Continue to resume.' : 'Pauses the workflow until you click Continue.'}
</p>
<p className="text-[10px] text-zinc-500 italic">{statusText}</p>
</div>
</BaseNode>
)
Expand Down
22 changes: 21 additions & 1 deletion src/areas/workflows/preflight.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Workflow, WFNode } from '@shared/types/electron.d'
import { getWorkflowExtension, type WorkflowExtension } from './mockExtensions'
import { isPassthrough, resolveDataSource, nearestUpstreamWaits } from './nodeBehaviors'

type DataType = 'image' | 'text' | 'mesh'

Expand Down Expand Up @@ -55,11 +56,17 @@ export function validateWorkflowPreflight(
): WorkflowPreflightIssue[] {
const issues: WorkflowPreflightIssue[] = []
const nodeMap = new Map(workflow.nodes.map((node) => [node.id, node]))
const outputTypes = new Map<string, DataType | undefined>()

const outputTypes = new Map<string, DataType | undefined>()
for (const node of workflow.nodes) {
outputTypes.set(node.id, getNodeOutputType(node, allExtensions))
}
// Passthrough nodes inherit their resolved upstream source's type.
for (const node of workflow.nodes) {
if (!isPassthrough(node.type)) continue
const realSourceId = resolveDataSource(node.id, workflow.edges, nodeMap)
if (realSourceId && realSourceId !== node.id) outputTypes.set(node.id, outputTypes.get(realSourceId))
}

for (const node of workflow.nodes) {
if (node.type === 'meshNode' && node.data.params?.source === 'current' && !options?.currentMeshUrl) {
Expand All @@ -70,6 +77,19 @@ export function validateWorkflowPreflight(
})
}

// A node fed by two different Wait branches can't be scheduled into a single
// branch — it would run before either branch produces its mesh.
if (
(node.type === 'extensionNode' || node.type === 'outputNode') &&
nearestUpstreamWaits(node.id, workflow.edges, nodeMap).size > 1
) {
pushIssue(issues, {
key: `${node.id}:wait-merge`,
nodeId: node.id,
message: `${nodeLabel(node, allExtensions)} merges two Wait branches, which isn't supported. Route it through a single Wait.`,
})
}

if (node.type !== 'extensionNode') continue

const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions)
Expand Down
47 changes: 47 additions & 0 deletions src/areas/workflows/useWaitButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useWorkflowRunStore } from './workflowRunStore'
import type { WaitState } from './workflowRunStore'

export interface WaitButtonModel {
waitState: WaitState | undefined
canContinue: boolean
isRunning: boolean
label: 'Retry' | 'Continue'
buttonClass: string
statusText: string
onContinue: () => void
}

// Shared derivation for the Wait Continue/Retry control, used by both the
// canvas node (WaitNode) and the params panel (WaitParamRow). Keeps the two
// renderings in sync — they differ only in markup/sizing, not in logic.
export function useWaitButton(nodeId: string): WaitButtonModel {
const waitState = useWorkflowRunStore((s) => s.waitStates[nodeId])
const runningBranchId = useWorkflowRunStore((s) => s.runningBranchId)
const status = useWorkflowRunStore((s) => s.runState.status)
const continueRun = useWorkflowRunStore((s) => s.continueRun)

const otherBranchRunning = runningBranchId !== null && runningBranchId !== nodeId
// Pre-phase: shared nodes (e.g. Generate Mesh) still running before any branch hands off.
const inPrePhase = status === 'running' && runningBranchId === null
const isRunning = waitState === 'running'
const canContinue = (waitState === 'pending' || waitState === 'done' || waitState === 'error') && !otherBranchRunning && !inPrePhase
const label: 'Retry' | 'Continue' = waitState === 'done' || waitState === 'error' ? 'Retry' : 'Continue'

const buttonClass = waitState === 'error'
? 'bg-red-500/15 border-red-500/30 text-red-400 hover:bg-red-500/25'
: waitState === 'done'
? 'bg-emerald-500/15 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/25'
: 'bg-amber-500/15 border-amber-500/30 text-amber-400 hover:bg-amber-500/25'

const statusText =
waitState === 'blocked' ? 'Waiting for the previous Wait to finish…' :
waitState === 'running' ? 'Branch in progress…' :
waitState === 'done' ? 'Branch finished — Retry to re-run.' :
waitState === 'error' ? 'Branch failed — Retry to re-run.' :
waitState === 'pending' && inPrePhase ? 'Waiting for upstream nodes…' :
waitState === 'pending' && otherBranchRunning ? 'Another branch is running…' :
waitState === 'pending' ? 'Workflow paused — click Continue to run this branch.' :
'Pauses the workflow until you click Continue.'

return { waitState, canContinue, isRunning, label, buttonClass, statusText, onContinue: () => continueRun(nodeId) }
}
Loading