mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-21 04:48:00 -05:00
Compare commits
6 Commits
feat/tools
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d615c78a30 | ||
|
|
b7f25786ce | ||
|
|
3a9e5f3b78 | ||
|
|
39444fa1a8 | ||
|
|
45ca926e6d | ||
|
|
77ee01747d |
@@ -37,6 +37,7 @@ import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-
|
|||||||
import type { GenerationType } from '@/blocks/types'
|
import type { GenerationType } from '@/blocks/types'
|
||||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||||
|
import { useTextHistory } from '@/hooks/use-text-history'
|
||||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||||
|
|
||||||
const logger = createLogger('Code')
|
const logger = createLogger('Code')
|
||||||
@@ -305,6 +306,20 @@ export function Code({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Text history for undo/redo with debouncing
|
||||||
|
const textHistory = useTextHistory({
|
||||||
|
blockId,
|
||||||
|
subBlockId,
|
||||||
|
value: code,
|
||||||
|
onChange: (newValue) => {
|
||||||
|
setCode(newValue)
|
||||||
|
if (!isPreview && !disabled) {
|
||||||
|
setStoreValue(newValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disabled: isPreview || disabled || readOnly || isAiStreaming,
|
||||||
|
})
|
||||||
|
|
||||||
const getDefaultValueString = () => {
|
const getDefaultValueString = () => {
|
||||||
if (defaultValue === undefined || defaultValue === null) return ''
|
if (defaultValue === undefined || defaultValue === null) return ''
|
||||||
if (typeof defaultValue === 'string') return defaultValue
|
if (typeof defaultValue === 'string') return defaultValue
|
||||||
@@ -348,10 +363,12 @@ export function Code({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleStreamStartRef.current = () => {
|
handleStreamStartRef.current = () => {
|
||||||
setCode('')
|
setCode('')
|
||||||
|
lastInternalValueRef.current = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
handleGeneratedContentRef.current = (generatedCode: string) => {
|
handleGeneratedContentRef.current = (generatedCode: string) => {
|
||||||
setCode(generatedCode)
|
setCode(generatedCode)
|
||||||
|
lastInternalValueRef.current = generatedCode
|
||||||
if (!isPreview && !disabled) {
|
if (!isPreview && !disabled) {
|
||||||
setStoreValue(generatedCode)
|
setStoreValue(generatedCode)
|
||||||
}
|
}
|
||||||
@@ -387,14 +404,21 @@ export function Code({
|
|||||||
}
|
}
|
||||||
}, [readOnly])
|
}, [readOnly])
|
||||||
|
|
||||||
// Effects: Sync code with external value
|
// Ref to track the last value we set internally (to avoid sync loops)
|
||||||
|
const lastInternalValueRef = useRef<string>('')
|
||||||
|
|
||||||
|
// Effects: Sync code with external value (only for truly external changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAiStreaming) return
|
if (isAiStreaming) return
|
||||||
const valueString = value?.toString() ?? ''
|
const valueString = value?.toString() ?? ''
|
||||||
if (valueString !== code) {
|
|
||||||
|
// Only sync if this is a genuine external change, not our own update
|
||||||
|
// This prevents resetting the undo history when we update the store
|
||||||
|
if (valueString !== code && valueString !== lastInternalValueRef.current) {
|
||||||
setCode(valueString)
|
setCode(valueString)
|
||||||
|
lastInternalValueRef.current = valueString
|
||||||
}
|
}
|
||||||
}, [value, code, isAiStreaming])
|
}, [value, isAiStreaming]) // Removed 'code' from dependencies to prevent sync loops
|
||||||
|
|
||||||
// Effects: Track active line number for cursor position
|
// Effects: Track active line number for cursor position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -502,8 +526,9 @@ export function Code({
|
|||||||
const dropPosition = textarea?.selectionStart ?? code.length
|
const dropPosition = textarea?.selectionStart ?? code.length
|
||||||
const newValue = `${code.slice(0, dropPosition)}<${code.slice(dropPosition)}`
|
const newValue = `${code.slice(0, dropPosition)}<${code.slice(dropPosition)}`
|
||||||
|
|
||||||
setCode(newValue)
|
// Use textHistory for proper undo tracking
|
||||||
setStoreValue(newValue)
|
textHistory.handleChange(newValue)
|
||||||
|
lastInternalValueRef.current = newValue
|
||||||
const newCursorPosition = dropPosition + 1
|
const newCursorPosition = dropPosition + 1
|
||||||
setCursorPosition(newCursorPosition)
|
setCursorPosition(newCursorPosition)
|
||||||
|
|
||||||
@@ -531,7 +556,9 @@ export function Code({
|
|||||||
*/
|
*/
|
||||||
const handleTagSelect = (newValue: string) => {
|
const handleTagSelect = (newValue: string) => {
|
||||||
if (!isPreview && !readOnly) {
|
if (!isPreview && !readOnly) {
|
||||||
setCode(newValue)
|
// Use textHistory for proper undo tracking
|
||||||
|
textHistory.handleChange(newValue)
|
||||||
|
lastInternalValueRef.current = newValue
|
||||||
emitTagSelection(newValue)
|
emitTagSelection(newValue)
|
||||||
}
|
}
|
||||||
setShowTags(false)
|
setShowTags(false)
|
||||||
@@ -548,7 +575,9 @@ export function Code({
|
|||||||
*/
|
*/
|
||||||
const handleEnvVarSelect = (newValue: string) => {
|
const handleEnvVarSelect = (newValue: string) => {
|
||||||
if (!isPreview && !readOnly) {
|
if (!isPreview && !readOnly) {
|
||||||
setCode(newValue)
|
// Use textHistory for proper undo tracking
|
||||||
|
textHistory.handleChange(newValue)
|
||||||
|
lastInternalValueRef.current = newValue
|
||||||
emitTagSelection(newValue)
|
emitTagSelection(newValue)
|
||||||
}
|
}
|
||||||
setShowEnvVars(false)
|
setShowEnvVars(false)
|
||||||
@@ -741,8 +770,10 @@ export function Code({
|
|||||||
value={code}
|
value={code}
|
||||||
onValueChange={(newCode) => {
|
onValueChange={(newCode) => {
|
||||||
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
if (!isAiStreaming && !isPreview && !disabled && !readOnly) {
|
||||||
setCode(newCode)
|
// Use textHistory for debounced undo/redo tracking
|
||||||
setStoreValue(newCode)
|
textHistory.handleChange(newCode)
|
||||||
|
// Track this as an internal change to prevent sync loops
|
||||||
|
lastInternalValueRef.current = newCode
|
||||||
|
|
||||||
const textarea = editorRef.current?.querySelector('textarea')
|
const textarea = editorRef.current?.querySelector('textarea')
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@@ -762,6 +793,10 @@ export function Code({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
// Let text history handle undo/redo first
|
||||||
|
if (textHistory.handleKeyDown(e)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setShowTags(false)
|
setShowTags(false)
|
||||||
setShowEnvVars(false)
|
setShowEnvVars(false)
|
||||||
@@ -770,6 +805,10 @@ export function Code({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Commit any pending text history changes on blur
|
||||||
|
textHistory.handleBlur()
|
||||||
|
}}
|
||||||
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
highlight={createHighlightFunction(effectiveLanguage, shouldHighlightReference)}
|
||||||
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
{...getCodeEditorProps({ isStreaming: isAiStreaming, isPreview, disabled })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import 'prismjs/components/prism-json'
|
import 'prismjs/components/prism-json'
|
||||||
import { Wand2 } from 'lucide-react'
|
import { Wand2 } from 'lucide-react'
|
||||||
import Editor from 'react-simple-code-editor'
|
import Editor from 'react-simple-code-editor'
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
createEnvVarPattern,
|
createEnvVarPattern,
|
||||||
createWorkflowVariablePattern,
|
createWorkflowVariablePattern,
|
||||||
} from '@/executor/utils/reference-validation'
|
} from '@/executor/utils/reference-validation'
|
||||||
|
import { useTextHistoryStore } from '@/stores/text-history'
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -33,6 +34,11 @@ interface CodeEditorProps {
|
|||||||
showWandButton?: boolean
|
showWandButton?: boolean
|
||||||
onWandClick?: () => void
|
onWandClick?: () => void
|
||||||
wandButtonDisabled?: boolean
|
wandButtonDisabled?: boolean
|
||||||
|
/**
|
||||||
|
* Unique identifier for text history. When provided, enables undo/redo functionality.
|
||||||
|
* Format: "blockId:fieldName" e.g. "block-123:schema" or "block-123:code"
|
||||||
|
*/
|
||||||
|
historyId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeEditor({
|
export function CodeEditor({
|
||||||
@@ -50,16 +56,125 @@ export function CodeEditor({
|
|||||||
showWandButton = false,
|
showWandButton = false,
|
||||||
onWandClick,
|
onWandClick,
|
||||||
wandButtonDisabled = false,
|
wandButtonDisabled = false,
|
||||||
|
historyId,
|
||||||
}: CodeEditorProps) {
|
}: CodeEditorProps) {
|
||||||
const [code, setCode] = useState(value)
|
const [code, setCode] = useState(value)
|
||||||
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
|
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
|
||||||
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null)
|
const editorRef = useRef<HTMLDivElement>(null)
|
||||||
|
const lastInternalValueRef = useRef<string>(value)
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
|
// Text history store for undo/redo
|
||||||
|
const textHistoryStore = useTextHistoryStore()
|
||||||
|
|
||||||
|
// Parse historyId into blockId and subBlockId for the store
|
||||||
|
const [historyBlockId, historySubBlockId] = historyId?.split(':') ?? ['', '']
|
||||||
|
const hasHistory = Boolean(historyId && historyBlockId && historySubBlockId)
|
||||||
|
|
||||||
|
// Initialize history on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCode(value)
|
if (hasHistory && !initializedRef.current) {
|
||||||
|
textHistoryStore.initHistory(historyBlockId, historySubBlockId, value)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
}, [hasHistory, historyBlockId, historySubBlockId, value, textHistoryStore])
|
||||||
|
|
||||||
|
// Sync external value changes (but avoid resetting undo history for internal changes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== code && value !== lastInternalValueRef.current) {
|
||||||
|
setCode(value)
|
||||||
|
lastInternalValueRef.current = value
|
||||||
|
}
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
// Handle value change with history tracking
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(newCode: string) => {
|
||||||
|
setCode(newCode)
|
||||||
|
lastInternalValueRef.current = newCode
|
||||||
|
onChange(newCode)
|
||||||
|
|
||||||
|
// Record to history if enabled
|
||||||
|
if (hasHistory) {
|
||||||
|
textHistoryStore.recordChange(historyBlockId, historySubBlockId, newCode)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, hasHistory, historyBlockId, historySubBlockId, textHistoryStore]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle undo
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
if (!hasHistory) return false
|
||||||
|
|
||||||
|
const previousValue = textHistoryStore.undo(historyBlockId, historySubBlockId)
|
||||||
|
if (previousValue !== null) {
|
||||||
|
setCode(previousValue)
|
||||||
|
lastInternalValueRef.current = previousValue
|
||||||
|
onChange(previousValue)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore, onChange])
|
||||||
|
|
||||||
|
// Handle redo
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
if (!hasHistory) return false
|
||||||
|
|
||||||
|
const nextValue = textHistoryStore.redo(historyBlockId, historySubBlockId)
|
||||||
|
if (nextValue !== null) {
|
||||||
|
setCode(nextValue)
|
||||||
|
lastInternalValueRef.current = nextValue
|
||||||
|
onChange(nextValue)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore, onChange])
|
||||||
|
|
||||||
|
// Handle keyboard events for undo/redo
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
const isMod = e.metaKey || e.ctrlKey
|
||||||
|
|
||||||
|
// Undo: Cmd+Z / Ctrl+Z
|
||||||
|
if (isMod && e.key === 'z' && !e.shiftKey && hasHistory) {
|
||||||
|
if (handleUndo()) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo: Cmd+Shift+Z / Ctrl+Shift+Z / Ctrl+Y
|
||||||
|
if (hasHistory) {
|
||||||
|
if (
|
||||||
|
(isMod && e.key === 'z' && e.shiftKey) ||
|
||||||
|
(isMod && e.key === 'Z') ||
|
||||||
|
(e.ctrlKey && e.key === 'y')
|
||||||
|
) {
|
||||||
|
if (handleRedo()) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call parent's onKeyDown if provided
|
||||||
|
onKeyDown?.(e)
|
||||||
|
},
|
||||||
|
[disabled, hasHistory, handleUndo, handleRedo, onKeyDown]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle blur - commit pending history
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
if (hasHistory) {
|
||||||
|
textHistoryStore.commitPending(historyBlockId, historySubBlockId)
|
||||||
|
}
|
||||||
|
}, [hasHistory, historyBlockId, historySubBlockId, textHistoryStore])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editorRef.current) return
|
if (!editorRef.current) return
|
||||||
|
|
||||||
@@ -211,11 +326,9 @@ export function CodeEditor({
|
|||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
value={code}
|
value={code}
|
||||||
onValueChange={(newCode) => {
|
onValueChange={handleValueChange}
|
||||||
setCode(newCode)
|
onKeyDown={handleKeyDown}
|
||||||
onChange(newCode)
|
onBlur={handleBlur}
|
||||||
}}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
highlight={(code) => customHighlight(code)}
|
highlight={(code) => customHighlight(code)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...getCodeEditorProps({ disabled })}
|
{...getCodeEditorProps({ disabled })}
|
||||||
|
|||||||
@@ -936,6 +936,7 @@ try {
|
|||||||
gutterClassName='bg-[var(--bg)]'
|
gutterClassName='bg-[var(--bg)]'
|
||||||
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
disabled={schemaGeneration.isLoading || schemaGeneration.isStreaming}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
historyId={`${blockId}:tool-schema`}
|
||||||
/>
|
/>
|
||||||
</ModalTabsContent>
|
</ModalTabsContent>
|
||||||
|
|
||||||
@@ -1018,6 +1019,7 @@ try {
|
|||||||
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
disabled={codeGeneration.isLoading || codeGeneration.isStreaming}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
schemaParameters={schemaParameters}
|
schemaParameters={schemaParameters}
|
||||||
|
historyId={`${blockId}:tool-code`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showEnvVars && (
|
{showEnvVars && (
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export const DEFAULTS = {
|
|||||||
BLOCK_TITLE: 'Untitled Block',
|
BLOCK_TITLE: 'Untitled Block',
|
||||||
WORKFLOW_NAME: 'Workflow',
|
WORKFLOW_NAME: 'Workflow',
|
||||||
MAX_LOOP_ITERATIONS: 1000,
|
MAX_LOOP_ITERATIONS: 1000,
|
||||||
|
MAX_FOREACH_ITEMS: 1000,
|
||||||
|
MAX_PARALLEL_BRANCHES: 20,
|
||||||
MAX_WORKFLOW_DEPTH: 10,
|
MAX_WORKFLOW_DEPTH: 10,
|
||||||
EXECUTION_TIME: 0,
|
EXECUTION_TIME: 0,
|
||||||
TOKENS: {
|
TOKENS: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LoopConstructor } from '@/executor/dag/construction/loops'
|
|||||||
import { NodeConstructor } from '@/executor/dag/construction/nodes'
|
import { NodeConstructor } from '@/executor/dag/construction/nodes'
|
||||||
import { PathConstructor } from '@/executor/dag/construction/paths'
|
import { PathConstructor } from '@/executor/dag/construction/paths'
|
||||||
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
|
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
|
||||||
|
import { buildSentinelStartId, extractBaseBlockId } from '@/executor/utils/subflow-utils'
|
||||||
import type {
|
import type {
|
||||||
SerializedBlock,
|
SerializedBlock,
|
||||||
SerializedLoop,
|
SerializedLoop,
|
||||||
@@ -79,6 +80,9 @@ export class DAGBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate loop and parallel structure
|
||||||
|
this.validateSubflowStructure(dag)
|
||||||
|
|
||||||
logger.info('DAG built', {
|
logger.info('DAG built', {
|
||||||
totalNodes: dag.nodes.size,
|
totalNodes: dag.nodes.size,
|
||||||
loopCount: dag.loopConfigs.size,
|
loopCount: dag.loopConfigs.size,
|
||||||
@@ -105,4 +109,43 @@ export class DAGBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that loops and parallels have proper internal structure.
|
||||||
|
* Throws an error if a loop/parallel has no blocks inside or no connections from start.
|
||||||
|
*/
|
||||||
|
private validateSubflowStructure(dag: DAG): void {
|
||||||
|
for (const [id, config] of dag.loopConfigs) {
|
||||||
|
this.validateSubflow(dag, id, config.nodes, 'Loop')
|
||||||
|
}
|
||||||
|
for (const [id, config] of dag.parallelConfigs) {
|
||||||
|
this.validateSubflow(dag, id, config.nodes, 'Parallel')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateSubflow(
|
||||||
|
dag: DAG,
|
||||||
|
id: string,
|
||||||
|
nodes: string[] | undefined,
|
||||||
|
type: 'Loop' | 'Parallel'
|
||||||
|
): void {
|
||||||
|
if (!nodes || nodes.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${type} has no blocks inside. Add at least one block to the ${type.toLowerCase()}.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentinelStartNode = dag.nodes.get(buildSentinelStartId(id))
|
||||||
|
if (!sentinelStartNode) return
|
||||||
|
|
||||||
|
const hasConnections = Array.from(sentinelStartNode.outgoingEdges.values()).some((edge) =>
|
||||||
|
nodes.includes(extractBaseBlockId(edge.target))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasConnections) {
|
||||||
|
throw new Error(
|
||||||
|
`${type} start is not connected to any blocks. Connect a block to the ${type.toLowerCase()} start.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ export class DAGExecutor {
|
|||||||
|
|
||||||
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
|
const resolver = new VariableResolver(this.workflow, this.workflowVariables, state)
|
||||||
const loopOrchestrator = new LoopOrchestrator(dag, state, resolver)
|
const loopOrchestrator = new LoopOrchestrator(dag, state, resolver)
|
||||||
|
loopOrchestrator.setContextExtensions(this.contextExtensions)
|
||||||
const parallelOrchestrator = new ParallelOrchestrator(dag, state)
|
const parallelOrchestrator = new ParallelOrchestrator(dag, state)
|
||||||
parallelOrchestrator.setResolver(resolver)
|
parallelOrchestrator.setResolver(resolver)
|
||||||
|
parallelOrchestrator.setContextExtensions(this.contextExtensions)
|
||||||
const allHandlers = createBlockHandlers()
|
const allHandlers = createBlockHandlers()
|
||||||
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
|
const blockExecutor = new BlockExecutor(allHandlers, resolver, this.contextExtensions, state)
|
||||||
const edgeManager = new EdgeManager(dag)
|
const edgeManager = new EdgeManager(dag)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface LoopScope {
|
|||||||
condition?: string
|
condition?: string
|
||||||
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
|
loopType?: 'for' | 'forEach' | 'while' | 'doWhile'
|
||||||
skipFirstConditionCheck?: boolean
|
skipFirstConditionCheck?: boolean
|
||||||
|
/** Error message if loop validation failed (e.g., exceeded max iterations) */
|
||||||
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParallelScope {
|
export interface ParallelScope {
|
||||||
@@ -23,6 +25,8 @@ export interface ParallelScope {
|
|||||||
completedCount: number
|
completedCount: number
|
||||||
totalExpectedNodes: number
|
totalExpectedNodes: number
|
||||||
items?: any[]
|
items?: any[]
|
||||||
|
/** Error message if parallel validation failed (e.g., exceeded max branches) */
|
||||||
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExecutionState implements BlockStateController {
|
export class ExecutionState implements BlockStateController {
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { buildLoopIndexCondition, DEFAULTS, EDGE } from '@/executor/constants'
|
|||||||
import type { DAG } from '@/executor/dag/builder'
|
import type { DAG } from '@/executor/dag/builder'
|
||||||
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
import type { EdgeManager } from '@/executor/execution/edge-manager'
|
||||||
import type { LoopScope } from '@/executor/execution/state'
|
import type { LoopScope } from '@/executor/execution/state'
|
||||||
import type { BlockStateController } from '@/executor/execution/types'
|
import type { BlockStateController, ContextExtensions } from '@/executor/execution/types'
|
||||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||||
import type { LoopConfigWithNodes } from '@/executor/types/loop'
|
import type { LoopConfigWithNodes } from '@/executor/types/loop'
|
||||||
import { replaceValidReferences } from '@/executor/utils/reference-validation'
|
import { replaceValidReferences } from '@/executor/utils/reference-validation'
|
||||||
import {
|
import {
|
||||||
|
addSubflowErrorLog,
|
||||||
buildSentinelEndId,
|
buildSentinelEndId,
|
||||||
buildSentinelStartId,
|
buildSentinelStartId,
|
||||||
extractBaseBlockId,
|
extractBaseBlockId,
|
||||||
|
resolveArrayInput,
|
||||||
|
validateMaxCount,
|
||||||
} from '@/executor/utils/subflow-utils'
|
} from '@/executor/utils/subflow-utils'
|
||||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||||
import type { SerializedLoop } from '@/serializer/types'
|
import type { SerializedLoop } from '@/serializer/types'
|
||||||
@@ -32,6 +35,7 @@ export interface LoopContinuationResult {
|
|||||||
|
|
||||||
export class LoopOrchestrator {
|
export class LoopOrchestrator {
|
||||||
private edgeManager: EdgeManager | null = null
|
private edgeManager: EdgeManager | null = null
|
||||||
|
private contextExtensions: ContextExtensions | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dag: DAG,
|
private dag: DAG,
|
||||||
@@ -39,6 +43,10 @@ export class LoopOrchestrator {
|
|||||||
private resolver: VariableResolver
|
private resolver: VariableResolver
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
setContextExtensions(contextExtensions: ContextExtensions): void {
|
||||||
|
this.contextExtensions = contextExtensions
|
||||||
|
}
|
||||||
|
|
||||||
setEdgeManager(edgeManager: EdgeManager): void {
|
setEdgeManager(edgeManager: EdgeManager): void {
|
||||||
this.edgeManager = edgeManager
|
this.edgeManager = edgeManager
|
||||||
}
|
}
|
||||||
@@ -48,7 +56,6 @@ export class LoopOrchestrator {
|
|||||||
if (!loopConfig) {
|
if (!loopConfig) {
|
||||||
throw new Error(`Loop config not found: ${loopId}`)
|
throw new Error(`Loop config not found: ${loopId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scope: LoopScope = {
|
const scope: LoopScope = {
|
||||||
iteration: 0,
|
iteration: 0,
|
||||||
currentIterationOutputs: new Map(),
|
currentIterationOutputs: new Map(),
|
||||||
@@ -58,15 +65,70 @@ export class LoopOrchestrator {
|
|||||||
const loopType = loopConfig.loopType
|
const loopType = loopConfig.loopType
|
||||||
|
|
||||||
switch (loopType) {
|
switch (loopType) {
|
||||||
case 'for':
|
case 'for': {
|
||||||
scope.loopType = 'for'
|
scope.loopType = 'for'
|
||||||
scope.maxIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
const requestedIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
||||||
|
|
||||||
|
const iterationError = validateMaxCount(
|
||||||
|
requestedIterations,
|
||||||
|
DEFAULTS.MAX_LOOP_ITERATIONS,
|
||||||
|
'For loop iterations'
|
||||||
|
)
|
||||||
|
if (iterationError) {
|
||||||
|
logger.error(iterationError, { loopId, requestedIterations })
|
||||||
|
this.addLoopErrorLog(ctx, loopId, loopType, iterationError, {
|
||||||
|
iterations: requestedIterations,
|
||||||
|
})
|
||||||
|
scope.maxIterations = 0
|
||||||
|
scope.validationError = iterationError
|
||||||
|
scope.condition = buildLoopIndexCondition(0)
|
||||||
|
ctx.loopExecutions?.set(loopId, scope)
|
||||||
|
throw new Error(iterationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.maxIterations = requestedIterations
|
||||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'forEach': {
|
case 'forEach': {
|
||||||
scope.loopType = 'forEach'
|
scope.loopType = 'forEach'
|
||||||
const items = this.resolveForEachItems(ctx, loopConfig.forEachItems)
|
let items: any[]
|
||||||
|
try {
|
||||||
|
items = this.resolveForEachItems(ctx, loopConfig.forEachItems)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = `ForEach loop resolution failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
logger.error(errorMessage, { loopId, forEachItems: loopConfig.forEachItems })
|
||||||
|
this.addLoopErrorLog(ctx, loopId, loopType, errorMessage, {
|
||||||
|
forEachItems: loopConfig.forEachItems,
|
||||||
|
})
|
||||||
|
scope.items = []
|
||||||
|
scope.maxIterations = 0
|
||||||
|
scope.validationError = errorMessage
|
||||||
|
scope.condition = buildLoopIndexCondition(0)
|
||||||
|
ctx.loopExecutions?.set(loopId, scope)
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeError = validateMaxCount(
|
||||||
|
items.length,
|
||||||
|
DEFAULTS.MAX_FOREACH_ITEMS,
|
||||||
|
'ForEach loop collection size'
|
||||||
|
)
|
||||||
|
if (sizeError) {
|
||||||
|
logger.error(sizeError, { loopId, collectionSize: items.length })
|
||||||
|
this.addLoopErrorLog(ctx, loopId, loopType, sizeError, {
|
||||||
|
forEachItems: loopConfig.forEachItems,
|
||||||
|
collectionSize: items.length,
|
||||||
|
})
|
||||||
|
scope.items = []
|
||||||
|
scope.maxIterations = 0
|
||||||
|
scope.validationError = sizeError
|
||||||
|
scope.condition = buildLoopIndexCondition(0)
|
||||||
|
ctx.loopExecutions?.set(loopId, scope)
|
||||||
|
throw new Error(sizeError)
|
||||||
|
}
|
||||||
|
|
||||||
scope.items = items
|
scope.items = items
|
||||||
scope.maxIterations = items.length
|
scope.maxIterations = items.length
|
||||||
scope.item = items[0]
|
scope.item = items[0]
|
||||||
@@ -79,15 +141,35 @@ export class LoopOrchestrator {
|
|||||||
scope.condition = loopConfig.whileCondition
|
scope.condition = loopConfig.whileCondition
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'doWhile':
|
case 'doWhile': {
|
||||||
scope.loopType = 'doWhile'
|
scope.loopType = 'doWhile'
|
||||||
if (loopConfig.doWhileCondition) {
|
if (loopConfig.doWhileCondition) {
|
||||||
scope.condition = loopConfig.doWhileCondition
|
scope.condition = loopConfig.doWhileCondition
|
||||||
} else {
|
} else {
|
||||||
scope.maxIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
const requestedIterations = loopConfig.iterations || DEFAULTS.MAX_LOOP_ITERATIONS
|
||||||
|
|
||||||
|
const iterationError = validateMaxCount(
|
||||||
|
requestedIterations,
|
||||||
|
DEFAULTS.MAX_LOOP_ITERATIONS,
|
||||||
|
'Do-While loop iterations'
|
||||||
|
)
|
||||||
|
if (iterationError) {
|
||||||
|
logger.error(iterationError, { loopId, requestedIterations })
|
||||||
|
this.addLoopErrorLog(ctx, loopId, loopType, iterationError, {
|
||||||
|
iterations: requestedIterations,
|
||||||
|
})
|
||||||
|
scope.maxIterations = 0
|
||||||
|
scope.validationError = iterationError
|
||||||
|
scope.condition = buildLoopIndexCondition(0)
|
||||||
|
ctx.loopExecutions?.set(loopId, scope)
|
||||||
|
throw new Error(iterationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.maxIterations = requestedIterations
|
||||||
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
scope.condition = buildLoopIndexCondition(scope.maxIterations)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown loop type: ${loopType}`)
|
throw new Error(`Unknown loop type: ${loopType}`)
|
||||||
@@ -100,6 +182,23 @@ export class LoopOrchestrator {
|
|||||||
return scope
|
return scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addLoopErrorLog(
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
loopId: string,
|
||||||
|
loopType: string,
|
||||||
|
errorMessage: string,
|
||||||
|
inputData?: any
|
||||||
|
): void {
|
||||||
|
addSubflowErrorLog(
|
||||||
|
ctx,
|
||||||
|
loopId,
|
||||||
|
'loop',
|
||||||
|
errorMessage,
|
||||||
|
{ loopType, ...inputData },
|
||||||
|
this.contextExtensions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
storeLoopNodeOutput(
|
storeLoopNodeOutput(
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
loopId: string,
|
loopId: string,
|
||||||
@@ -412,54 +511,6 @@ export class LoopOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveForEachItems(ctx: ExecutionContext, items: any): any[] {
|
private resolveForEachItems(ctx: ExecutionContext, items: any): any[] {
|
||||||
if (Array.isArray(items)) {
|
return resolveArrayInput(ctx, items, this.resolver)
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof items === 'object' && items !== null) {
|
|
||||||
return Object.entries(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof items === 'string') {
|
|
||||||
if (items.startsWith('<') && items.endsWith('>')) {
|
|
||||||
const resolved = this.resolver.resolveSingleReference(ctx, '', items)
|
|
||||||
if (Array.isArray(resolved)) {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const normalized = items.replace(/'/g, '"')
|
|
||||||
const parsed = JSON.parse(normalized)
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse forEach items', { items, error })
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolved = this.resolver.resolveInputs(ctx, 'loop_foreach_items', { items }).items
|
|
||||||
|
|
||||||
if (Array.isArray(resolved)) {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('ForEach items did not resolve to array', {
|
|
||||||
items,
|
|
||||||
resolved,
|
|
||||||
})
|
|
||||||
|
|
||||||
return []
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error resolving forEach items, returning empty array:', {
|
|
||||||
error: error.message,
|
|
||||||
})
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { DEFAULTS } from '@/executor/constants'
|
||||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||||
import type { ParallelScope } from '@/executor/execution/state'
|
import type { ParallelScope } from '@/executor/execution/state'
|
||||||
import type { BlockStateWriter } from '@/executor/execution/types'
|
import type { BlockStateWriter, ContextExtensions } from '@/executor/execution/types'
|
||||||
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
import type { ExecutionContext, NormalizedBlockOutput } from '@/executor/types'
|
||||||
import type { ParallelConfigWithNodes } from '@/executor/types/parallel'
|
import type { ParallelConfigWithNodes } from '@/executor/types/parallel'
|
||||||
import {
|
import {
|
||||||
|
addSubflowErrorLog,
|
||||||
buildBranchNodeId,
|
buildBranchNodeId,
|
||||||
calculateBranchCount,
|
calculateBranchCount,
|
||||||
extractBaseBlockId,
|
extractBaseBlockId,
|
||||||
extractBranchIndex,
|
extractBranchIndex,
|
||||||
parseDistributionItems,
|
parseDistributionItems,
|
||||||
|
resolveArrayInput,
|
||||||
|
validateMaxCount,
|
||||||
} from '@/executor/utils/subflow-utils'
|
} from '@/executor/utils/subflow-utils'
|
||||||
import type { VariableResolver } from '@/executor/variables/resolver'
|
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||||
import type { SerializedParallel } from '@/serializer/types'
|
import type { SerializedParallel } from '@/serializer/types'
|
||||||
@@ -32,6 +36,7 @@ export interface ParallelAggregationResult {
|
|||||||
|
|
||||||
export class ParallelOrchestrator {
|
export class ParallelOrchestrator {
|
||||||
private resolver: VariableResolver | null = null
|
private resolver: VariableResolver | null = null
|
||||||
|
private contextExtensions: ContextExtensions | null = null
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dag: DAG,
|
private dag: DAG,
|
||||||
@@ -42,6 +47,10 @@ export class ParallelOrchestrator {
|
|||||||
this.resolver = resolver
|
this.resolver = resolver
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setContextExtensions(contextExtensions: ContextExtensions): void {
|
||||||
|
this.contextExtensions = contextExtensions
|
||||||
|
}
|
||||||
|
|
||||||
initializeParallelScope(
|
initializeParallelScope(
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
parallelId: string,
|
parallelId: string,
|
||||||
@@ -49,11 +58,42 @@ export class ParallelOrchestrator {
|
|||||||
terminalNodesCount = 1
|
terminalNodesCount = 1
|
||||||
): ParallelScope {
|
): ParallelScope {
|
||||||
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
const parallelConfig = this.dag.parallelConfigs.get(parallelId)
|
||||||
const items = parallelConfig ? this.resolveDistributionItems(ctx, parallelConfig) : undefined
|
|
||||||
|
|
||||||
// If we have more items than pre-built branches, expand the DAG
|
let items: any[] | undefined
|
||||||
|
if (parallelConfig) {
|
||||||
|
try {
|
||||||
|
items = this.resolveDistributionItems(ctx, parallelConfig)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = `Parallel distribution resolution failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
logger.error(errorMessage, {
|
||||||
|
parallelId,
|
||||||
|
distribution: parallelConfig.distribution,
|
||||||
|
})
|
||||||
|
this.addParallelErrorLog(ctx, parallelId, errorMessage, {
|
||||||
|
distribution: parallelConfig.distribution,
|
||||||
|
})
|
||||||
|
this.setErrorScope(ctx, parallelId, errorMessage)
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const actualBranchCount = items && items.length > totalBranches ? items.length : totalBranches
|
const actualBranchCount = items && items.length > totalBranches ? items.length : totalBranches
|
||||||
|
|
||||||
|
const branchError = validateMaxCount(
|
||||||
|
actualBranchCount,
|
||||||
|
DEFAULTS.MAX_PARALLEL_BRANCHES,
|
||||||
|
'Parallel branch count'
|
||||||
|
)
|
||||||
|
if (branchError) {
|
||||||
|
logger.error(branchError, { parallelId, actualBranchCount })
|
||||||
|
this.addParallelErrorLog(ctx, parallelId, branchError, {
|
||||||
|
distribution: parallelConfig?.distribution,
|
||||||
|
branchCount: actualBranchCount,
|
||||||
|
})
|
||||||
|
this.setErrorScope(ctx, parallelId, branchError)
|
||||||
|
throw new Error(branchError)
|
||||||
|
}
|
||||||
|
|
||||||
const scope: ParallelScope = {
|
const scope: ParallelScope = {
|
||||||
parallelId,
|
parallelId,
|
||||||
totalBranches: actualBranchCount,
|
totalBranches: actualBranchCount,
|
||||||
@@ -108,6 +148,38 @@ export class ParallelOrchestrator {
|
|||||||
return scope
|
return scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addParallelErrorLog(
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
parallelId: string,
|
||||||
|
errorMessage: string,
|
||||||
|
inputData?: any
|
||||||
|
): void {
|
||||||
|
addSubflowErrorLog(
|
||||||
|
ctx,
|
||||||
|
parallelId,
|
||||||
|
'parallel',
|
||||||
|
errorMessage,
|
||||||
|
inputData || {},
|
||||||
|
this.contextExtensions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setErrorScope(ctx: ExecutionContext, parallelId: string, errorMessage: string): void {
|
||||||
|
const scope: ParallelScope = {
|
||||||
|
parallelId,
|
||||||
|
totalBranches: 0,
|
||||||
|
branchOutputs: new Map(),
|
||||||
|
completedCount: 0,
|
||||||
|
totalExpectedNodes: 0,
|
||||||
|
items: [],
|
||||||
|
validationError: errorMessage,
|
||||||
|
}
|
||||||
|
if (!ctx.parallelExecutions) {
|
||||||
|
ctx.parallelExecutions = new Map()
|
||||||
|
}
|
||||||
|
ctx.parallelExecutions.set(parallelId, scope)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dynamically expand the DAG to include additional branch nodes when
|
* Dynamically expand the DAG to include additional branch nodes when
|
||||||
* the resolved item count exceeds the pre-built branch count.
|
* the resolved item count exceeds the pre-built branch count.
|
||||||
@@ -291,63 +363,11 @@ export class ParallelOrchestrator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve distribution items at runtime, handling references like <previousBlock.items>
|
|
||||||
* This mirrors how LoopOrchestrator.resolveForEachItems works.
|
|
||||||
*/
|
|
||||||
private resolveDistributionItems(ctx: ExecutionContext, config: SerializedParallel): any[] {
|
private resolveDistributionItems(ctx: ExecutionContext, config: SerializedParallel): any[] {
|
||||||
const rawItems = config.distribution
|
if (config.distribution === undefined || config.distribution === null) {
|
||||||
|
|
||||||
if (rawItems === undefined || rawItems === null) {
|
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
return resolveArrayInput(ctx, config.distribution, this.resolver)
|
||||||
// Already an array - return as-is
|
|
||||||
if (Array.isArray(rawItems)) {
|
|
||||||
return rawItems
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object - convert to entries array (consistent with loop forEach behavior)
|
|
||||||
if (typeof rawItems === 'object') {
|
|
||||||
return Object.entries(rawItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
// String handling
|
|
||||||
if (typeof rawItems === 'string') {
|
|
||||||
// Resolve references at runtime using the variable resolver
|
|
||||||
if (rawItems.startsWith('<') && rawItems.endsWith('>') && this.resolver) {
|
|
||||||
const resolved = this.resolver.resolveSingleReference(ctx, '', rawItems)
|
|
||||||
if (Array.isArray(resolved)) {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
if (typeof resolved === 'object' && resolved !== null) {
|
|
||||||
return Object.entries(resolved)
|
|
||||||
}
|
|
||||||
logger.warn('Distribution reference did not resolve to array or object', {
|
|
||||||
rawItems,
|
|
||||||
resolved,
|
|
||||||
})
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse as JSON
|
|
||||||
try {
|
|
||||||
const normalized = rawItems.replace(/'/g, '"')
|
|
||||||
const parsed = JSON.parse(normalized)
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
|
||||||
return Object.entries(parsed)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to parse distribution items', { rawItems, error })
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleParallelBranchCompletion(
|
handleParallelBranchCompletion(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { LOOP, PARALLEL, PARSING, REFERENCE } from '@/executor/constants'
|
import { LOOP, PARALLEL, PARSING, REFERENCE } from '@/executor/constants'
|
||||||
|
import type { ContextExtensions } from '@/executor/execution/types'
|
||||||
|
import type { BlockLog, ExecutionContext } from '@/executor/types'
|
||||||
|
import type { VariableResolver } from '@/executor/variables/resolver'
|
||||||
import type { SerializedParallel } from '@/serializer/types'
|
import type { SerializedParallel } from '@/serializer/types'
|
||||||
|
|
||||||
const logger = createLogger('SubflowUtils')
|
const logger = createLogger('SubflowUtils')
|
||||||
@@ -132,3 +135,131 @@ export function normalizeNodeId(nodeId: string): string {
|
|||||||
}
|
}
|
||||||
return nodeId
|
return nodeId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a count doesn't exceed a maximum limit.
|
||||||
|
* Returns an error message if validation fails, undefined otherwise.
|
||||||
|
*/
|
||||||
|
export function validateMaxCount(count: number, max: number, itemType: string): string | undefined {
|
||||||
|
if (count > max) {
|
||||||
|
return `${itemType} (${count}) exceeds maximum allowed (${max}). Execution blocked.`
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves array input at runtime. Handles arrays, objects, references, and JSON strings.
|
||||||
|
* Used by both loop forEach and parallel distribution resolution.
|
||||||
|
* Throws an error if resolution fails.
|
||||||
|
*/
|
||||||
|
export function resolveArrayInput(
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
items: any,
|
||||||
|
resolver: VariableResolver | null
|
||||||
|
): any[] {
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof items === 'object' && items !== null) {
|
||||||
|
return Object.entries(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof items === 'string') {
|
||||||
|
if (items.startsWith(REFERENCE.START) && items.endsWith(REFERENCE.END) && resolver) {
|
||||||
|
try {
|
||||||
|
const resolved = resolver.resolveSingleReference(ctx, '', items)
|
||||||
|
if (Array.isArray(resolved)) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
if (typeof resolved === 'object' && resolved !== null) {
|
||||||
|
return Object.entries(resolved)
|
||||||
|
}
|
||||||
|
throw new Error(`Reference "${items}" did not resolve to an array or object`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Reference "')) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve reference "${items}": ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = items.replace(/'/g, '"')
|
||||||
|
const parsed = JSON.parse(normalized)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
return Object.entries(parsed)
|
||||||
|
}
|
||||||
|
throw new Error(`Parsed value is not an array or object`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Parsed value')) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to parse items as JSON: "${items}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolver) {
|
||||||
|
try {
|
||||||
|
const resolved = resolver.resolveInputs(ctx, 'subflow_items', { items }).items
|
||||||
|
if (Array.isArray(resolved)) {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
throw new Error(`Resolved items is not an array`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Resolved items')) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve items: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and logs an error for a subflow (loop or parallel).
|
||||||
|
*/
|
||||||
|
export function addSubflowErrorLog(
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
blockId: string,
|
||||||
|
blockType: 'loop' | 'parallel',
|
||||||
|
errorMessage: string,
|
||||||
|
inputData: Record<string, any>,
|
||||||
|
contextExtensions: ContextExtensions | null
|
||||||
|
): void {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
const block = ctx.workflow?.blocks?.find((b) => b.id === blockId)
|
||||||
|
const blockName = block?.metadata?.name || (blockType === 'loop' ? 'Loop' : 'Parallel')
|
||||||
|
|
||||||
|
const blockLog: BlockLog = {
|
||||||
|
blockId,
|
||||||
|
blockName,
|
||||||
|
blockType,
|
||||||
|
startedAt: now,
|
||||||
|
endedAt: now,
|
||||||
|
durationMs: 0,
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
input: inputData,
|
||||||
|
output: { error: errorMessage },
|
||||||
|
...(blockType === 'loop' ? { loopId: blockId } : { parallelId: blockId }),
|
||||||
|
}
|
||||||
|
ctx.blockLogs.push(blockLog)
|
||||||
|
|
||||||
|
if (contextExtensions?.onBlockComplete) {
|
||||||
|
contextExtensions.onBlockComplete(blockId, blockName, blockType, {
|
||||||
|
input: inputData,
|
||||||
|
output: { error: errorMessage },
|
||||||
|
executionTime: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
196
apps/sim/hooks/use-text-history.ts
Normal file
196
apps/sim/hooks/use-text-history.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useTextHistoryStore } from '@/stores/text-history'
|
||||||
|
|
||||||
|
interface UseTextHistoryOptions {
|
||||||
|
/** Block ID for the text field */
|
||||||
|
blockId: string
|
||||||
|
/** Sub-block ID for the text field */
|
||||||
|
subBlockId: string
|
||||||
|
/** Current value of the text field */
|
||||||
|
value: string
|
||||||
|
/** Callback to update the value */
|
||||||
|
onChange: (value: string) => void
|
||||||
|
/** Whether the field is disabled/readonly */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTextHistoryResult {
|
||||||
|
/**
|
||||||
|
* Handle text change - records to history with debouncing
|
||||||
|
*/
|
||||||
|
handleChange: (newValue: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events for undo/redo
|
||||||
|
* Returns true if the event was handled
|
||||||
|
*/
|
||||||
|
handleKeyDown: (e: React.KeyboardEvent) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle blur - commits any pending changes
|
||||||
|
*/
|
||||||
|
handleBlur: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the last change
|
||||||
|
*/
|
||||||
|
undo: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redo the last undone change
|
||||||
|
*/
|
||||||
|
redo: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether undo is available
|
||||||
|
*/
|
||||||
|
canUndo: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether redo is available
|
||||||
|
*/
|
||||||
|
canRedo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing text undo/redo history for a specific text field.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - Provides debounced history recording (coalesces rapid changes)
|
||||||
|
* - Handles Cmd+Z/Ctrl+Z for undo, Cmd+Shift+Z/Ctrl+Y for redo
|
||||||
|
* - Commits pending changes on blur to preserve history
|
||||||
|
* - Each blockId:subBlockId pair has its own independent history
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { handleChange, handleKeyDown, handleBlur } = useTextHistory({
|
||||||
|
* blockId,
|
||||||
|
* subBlockId,
|
||||||
|
* value: code,
|
||||||
|
* onChange: (newCode) => {
|
||||||
|
* setCode(newCode)
|
||||||
|
* setStoreValue(newCode)
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* <textarea
|
||||||
|
* value={code}
|
||||||
|
* onChange={(e) => handleChange(e.target.value)}
|
||||||
|
* onKeyDown={handleKeyDown}
|
||||||
|
* onBlur={handleBlur}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useTextHistory({
|
||||||
|
blockId,
|
||||||
|
subBlockId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: UseTextHistoryOptions): UseTextHistoryResult {
|
||||||
|
const store = useTextHistoryStore()
|
||||||
|
const initializedRef = useRef(false)
|
||||||
|
const lastExternalValueRef = useRef(value)
|
||||||
|
|
||||||
|
// Initialize history on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initializedRef.current && blockId && subBlockId) {
|
||||||
|
store.initHistory(blockId, subBlockId, value)
|
||||||
|
initializedRef.current = true
|
||||||
|
}
|
||||||
|
}, [blockId, subBlockId, value, store])
|
||||||
|
|
||||||
|
// Handle external value changes (e.g., from AI generation or store sync)
|
||||||
|
useEffect(() => {
|
||||||
|
if (value !== lastExternalValueRef.current) {
|
||||||
|
// This is an external change, commit any pending and record the new value
|
||||||
|
store.commitPending(blockId, subBlockId)
|
||||||
|
store.recordChange(blockId, subBlockId, value)
|
||||||
|
store.commitPending(blockId, subBlockId)
|
||||||
|
lastExternalValueRef.current = value
|
||||||
|
}
|
||||||
|
}, [value, blockId, subBlockId, store])
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
// Update the external value immediately
|
||||||
|
onChange(newValue)
|
||||||
|
lastExternalValueRef.current = newValue
|
||||||
|
|
||||||
|
// Record to history with debouncing
|
||||||
|
store.recordChange(blockId, subBlockId, newValue)
|
||||||
|
},
|
||||||
|
[blockId, subBlockId, onChange, disabled, store]
|
||||||
|
)
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
const previousValue = store.undo(blockId, subBlockId)
|
||||||
|
if (previousValue !== null) {
|
||||||
|
onChange(previousValue)
|
||||||
|
lastExternalValueRef.current = previousValue
|
||||||
|
}
|
||||||
|
}, [blockId, subBlockId, onChange, disabled, store])
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (disabled) return
|
||||||
|
|
||||||
|
const nextValue = store.redo(blockId, subBlockId)
|
||||||
|
if (nextValue !== null) {
|
||||||
|
onChange(nextValue)
|
||||||
|
lastExternalValueRef.current = nextValue
|
||||||
|
}
|
||||||
|
}, [blockId, subBlockId, onChange, disabled, store])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent): boolean => {
|
||||||
|
if (disabled) return false
|
||||||
|
|
||||||
|
const isMod = e.metaKey || e.ctrlKey
|
||||||
|
|
||||||
|
// Undo: Cmd+Z / Ctrl+Z
|
||||||
|
if (isMod && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
undo()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo: Cmd+Shift+Z / Ctrl+Shift+Z / Ctrl+Y
|
||||||
|
if (
|
||||||
|
(isMod && e.key === 'z' && e.shiftKey) ||
|
||||||
|
(isMod && e.key === 'Z') ||
|
||||||
|
(e.ctrlKey && e.key === 'y')
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
redo()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
[disabled, undo, redo]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
// Commit any pending changes when the field loses focus
|
||||||
|
store.commitPending(blockId, subBlockId)
|
||||||
|
}, [blockId, subBlockId, store])
|
||||||
|
|
||||||
|
const canUndo = store.canUndo(blockId, subBlockId)
|
||||||
|
const canRedo = store.canRedo(blockId, subBlockId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBlur,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -471,8 +471,10 @@ function groupIterationBlocks(spans: TraceSpan[]): TraceSpan[] {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Include loop/parallel spans that have errors (e.g., validation errors that blocked execution)
|
||||||
|
// These won't have iteration children, so they should appear directly in results
|
||||||
const nonIterationContainerSpans = normalSpans.filter(
|
const nonIterationContainerSpans = normalSpans.filter(
|
||||||
(span) => span.type !== 'parallel' && span.type !== 'loop'
|
(span) => (span.type !== 'parallel' && span.type !== 'loop') || span.status === 'error'
|
||||||
)
|
)
|
||||||
|
|
||||||
if (iterationSpans.length > 0) {
|
if (iterationSpans.length > 0) {
|
||||||
|
|||||||
1
apps/sim/stores/text-history/index.ts
Normal file
1
apps/sim/stores/text-history/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useTextHistoryStore } from './store'
|
||||||
339
apps/sim/stores/text-history/store.ts
Normal file
339
apps/sim/stores/text-history/store.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('TextHistoryStore')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default debounce delay in milliseconds.
|
||||||
|
* Changes within this window are coalesced into a single history entry.
|
||||||
|
*/
|
||||||
|
const DEBOUNCE_DELAY_MS = 500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of history entries per text field.
|
||||||
|
*/
|
||||||
|
const MAX_HISTORY_SIZE = 10
|
||||||
|
|
||||||
|
interface TextHistoryEntry {
|
||||||
|
/** The undo/redo stack of text values */
|
||||||
|
stack: string[]
|
||||||
|
/** Current position in the stack (0 = oldest) */
|
||||||
|
index: number
|
||||||
|
/** Pending value that hasn't been committed to history yet */
|
||||||
|
pending: string | null
|
||||||
|
/** Timer ID for debounced commit */
|
||||||
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
||||||
|
/** Timestamp of last change (for coalescing logic) */
|
||||||
|
lastChangeAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextHistoryState {
|
||||||
|
/** Map of "blockId:subBlockId" to history entry */
|
||||||
|
histories: Record<string, TextHistoryEntry>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a text change with debouncing.
|
||||||
|
* Multiple rapid changes are coalesced into a single history entry.
|
||||||
|
*/
|
||||||
|
recordChange: (blockId: string, subBlockId: string, value: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately commits any pending changes to history.
|
||||||
|
* Call this on blur or before navigation.
|
||||||
|
*/
|
||||||
|
commitPending: (blockId: string, subBlockId: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo the last text change for a specific field.
|
||||||
|
* @returns The previous value, or null if at the beginning of history
|
||||||
|
*/
|
||||||
|
undo: (blockId: string, subBlockId: string) => string | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redo the last undone text change for a specific field.
|
||||||
|
* @returns The next value, or null if at the end of history
|
||||||
|
*/
|
||||||
|
redo: (blockId: string, subBlockId: string) => string | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if undo is available for a field.
|
||||||
|
*/
|
||||||
|
canUndo: (blockId: string, subBlockId: string) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if redo is available for a field.
|
||||||
|
*/
|
||||||
|
canRedo: (blockId: string, subBlockId: string) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize history for a field with an initial value.
|
||||||
|
* Called when a text field first mounts.
|
||||||
|
*/
|
||||||
|
initHistory: (blockId: string, subBlockId: string, initialValue: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear history for a specific field.
|
||||||
|
*/
|
||||||
|
clearHistory: (blockId: string, subBlockId: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all history for a block (when block is deleted).
|
||||||
|
*/
|
||||||
|
clearBlockHistory: (blockId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKey(blockId: string, subBlockId: string): string {
|
||||||
|
return `${blockId}:${subBlockId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyEntry(initialValue: string): TextHistoryEntry {
|
||||||
|
return {
|
||||||
|
stack: [initialValue],
|
||||||
|
index: 0,
|
||||||
|
pending: null,
|
||||||
|
debounceTimer: null,
|
||||||
|
lastChangeAt: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTextHistoryStore = create<TextHistoryState>((set, get) => ({
|
||||||
|
histories: {},
|
||||||
|
|
||||||
|
initHistory: (blockId: string, subBlockId: string, initialValue: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const state = get()
|
||||||
|
|
||||||
|
// Only initialize if not already present
|
||||||
|
if (!state.histories[key]) {
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...state.histories,
|
||||||
|
[key]: createEmptyEntry(initialValue),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.debug('Initialized text history', { blockId, subBlockId })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
recordChange: (blockId: string, subBlockId: string, value: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const state = get()
|
||||||
|
let entry = state.histories[key]
|
||||||
|
|
||||||
|
// Initialize if needed
|
||||||
|
if (!entry) {
|
||||||
|
entry = createEmptyEntry('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing debounce timer
|
||||||
|
if (entry.debounceTimer) {
|
||||||
|
clearTimeout(entry.debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up new debounce timer
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
get().commitPending(blockId, subBlockId)
|
||||||
|
}, DEBOUNCE_DELAY_MS)
|
||||||
|
|
||||||
|
// Update entry with pending value
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...get().histories,
|
||||||
|
[key]: {
|
||||||
|
...entry,
|
||||||
|
pending: value,
|
||||||
|
debounceTimer: timer,
|
||||||
|
lastChangeAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
commitPending: (blockId: string, subBlockId: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const state = get()
|
||||||
|
const entry = state.histories[key]
|
||||||
|
|
||||||
|
if (!entry || entry.pending === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the timer
|
||||||
|
if (entry.debounceTimer) {
|
||||||
|
clearTimeout(entry.debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = entry.stack[entry.index]
|
||||||
|
|
||||||
|
// Don't commit if value hasn't changed
|
||||||
|
if (entry.pending === currentValue) {
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...state.histories,
|
||||||
|
[key]: {
|
||||||
|
...entry,
|
||||||
|
pending: null,
|
||||||
|
debounceTimer: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate any redo history (we're branching)
|
||||||
|
const newStack = entry.stack.slice(0, entry.index + 1)
|
||||||
|
|
||||||
|
// Add the new value
|
||||||
|
newStack.push(entry.pending)
|
||||||
|
|
||||||
|
// Enforce max size (remove oldest entries)
|
||||||
|
while (newStack.length > MAX_HISTORY_SIZE) {
|
||||||
|
newStack.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIndex = newStack.length - 1
|
||||||
|
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...state.histories,
|
||||||
|
[key]: {
|
||||||
|
stack: newStack,
|
||||||
|
index: newIndex,
|
||||||
|
pending: null,
|
||||||
|
debounceTimer: null,
|
||||||
|
lastChangeAt: entry.lastChangeAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug('Committed text change to history', {
|
||||||
|
blockId,
|
||||||
|
subBlockId,
|
||||||
|
stackSize: newStack.length,
|
||||||
|
index: newIndex,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: (blockId: string, subBlockId: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const state = get()
|
||||||
|
const entry = state.histories[key]
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit any pending changes first
|
||||||
|
if (entry.pending !== null) {
|
||||||
|
get().commitPending(blockId, subBlockId)
|
||||||
|
// Re-fetch after commit
|
||||||
|
const updatedEntry = get().histories[key]
|
||||||
|
if (!updatedEntry || updatedEntry.index <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const newIndex = updatedEntry.index - 1
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...get().histories,
|
||||||
|
[key]: {
|
||||||
|
...updatedEntry,
|
||||||
|
index: newIndex,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.debug('Text undo', { blockId, subBlockId, newIndex })
|
||||||
|
return updatedEntry.stack[newIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.index <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIndex = entry.index - 1
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...state.histories,
|
||||||
|
[key]: {
|
||||||
|
...entry,
|
||||||
|
index: newIndex,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug('Text undo', { blockId, subBlockId, newIndex })
|
||||||
|
return entry.stack[newIndex]
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: (blockId: string, subBlockId: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const state = get()
|
||||||
|
const entry = state.histories[key]
|
||||||
|
|
||||||
|
if (!entry || entry.index >= entry.stack.length - 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIndex = entry.index + 1
|
||||||
|
set({
|
||||||
|
histories: {
|
||||||
|
...state.histories,
|
||||||
|
[key]: {
|
||||||
|
...entry,
|
||||||
|
index: newIndex,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.debug('Text redo', { blockId, subBlockId, newIndex })
|
||||||
|
return entry.stack[newIndex]
|
||||||
|
},
|
||||||
|
|
||||||
|
canUndo: (blockId: string, subBlockId: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const entry = get().histories[key]
|
||||||
|
if (!entry) return false
|
||||||
|
// Can undo if we have pending changes or index > 0
|
||||||
|
return entry.pending !== null || entry.index > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
canRedo: (blockId: string, subBlockId: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const entry = get().histories[key]
|
||||||
|
if (!entry) return false
|
||||||
|
return entry.index < entry.stack.length - 1
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHistory: (blockId: string, subBlockId: string) => {
|
||||||
|
const key = getKey(blockId, subBlockId)
|
||||||
|
const state = get()
|
||||||
|
const entry = state.histories[key]
|
||||||
|
|
||||||
|
if (entry?.debounceTimer) {
|
||||||
|
clearTimeout(entry.debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { [key]: _, ...rest } = state.histories
|
||||||
|
set({ histories: rest })
|
||||||
|
|
||||||
|
logger.debug('Cleared text history', { blockId, subBlockId })
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBlockHistory: (blockId: string) => {
|
||||||
|
const state = get()
|
||||||
|
const prefix = `${blockId}:`
|
||||||
|
const newHistories: Record<string, TextHistoryEntry> = {}
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(state.histories)) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
if (entry.debounceTimer) {
|
||||||
|
clearTimeout(entry.debounceTimer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newHistories[key] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ histories: newHistories })
|
||||||
|
logger.debug('Cleared all text history for block', { blockId })
|
||||||
|
},
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user