export type PlayStepLifecycleNode = { nodeId: string; type: string; }; export type PlayStepLifecycleProgress = { startedAt?: number | null; completedAt?: number | null; }; export type PlayStepLifecycleEvent = { nodeId: string; type: string; transition: 'started' | 'completed' | 'failed'; at: number; error?: string; }; function isAutoStartedSetupNode(type: string): boolean { return type === 'csv' || type === 'code' || type === 'run_javascript'; } export class PlayStepLifecycleTracker { private failedNodeIds = new Set(); constructor( private readonly nodes: readonly PlayStepLifecycleNode[], private readonly getProgress: () => Record< string, PlayStepLifecycleProgress >, private readonly emit: (event: PlayStepLifecycleEvent) => void, private readonly now: () => number = Date.now, ) {} markPreDatasetStepsStarted(at = this.now()): void { for (const node of this.nodes) { if (!isAutoStartedSetupNode(node.type)) break; if (!this.getProgress()[node.nodeId]?.startedAt) { this.emit({ nodeId: node.nodeId, type: node.type, transition: 'started', at, }); } } } onToolCalled(toolId: string, at = this.now()): void { const normalizedToolId = toolId.trim(); if (!normalizedToolId) { return; } this.completeStartedNonMapNodes(at); const node = this.nodes.find((candidate) => { if (candidate.type !== 'tool') { return false; } if (this.getProgress()[candidate.nodeId]?.startedAt) { return false; } return candidate.nodeId.endsWith(`:${normalizedToolId}`); }); if (!node) { return; } this.emit({ nodeId: node.nodeId, type: node.type, transition: 'started', at, }); } onToolFailed(toolId: string, at = this.now()): void { const normalizedToolId = toolId.trim(); if (!normalizedToolId) { return; } const node = [...this.nodes].reverse().find((candidate) => { if (candidate.type !== 'tool') { return false; } if (!candidate.nodeId.endsWith(`:${normalizedToolId}`)) { return false; } const existing = this.getProgress()[candidate.nodeId]; return Boolean(existing?.startedAt) && !existing?.completedAt; }); if (!node) { return; } this.failedNodeIds.add(node.nodeId); this.emit({ nodeId: node.nodeId, type: node.type, transition: 'failed', at, }); } onMapStarted(mapNodeId: string, at = this.now()): void { const mapIndex = this.nodes.findIndex((node) => node.nodeId === mapNodeId); if (mapIndex < 0) return; const mapNode = this.nodes[mapIndex]!; for (let index = 0; index < mapIndex; index += 1) { const node = this.nodes[index]!; if ( node.type !== 'dataset' && this.getProgress()[node.nodeId]?.startedAt && !this.getProgress()[node.nodeId]?.completedAt && !this.failedNodeIds.has(node.nodeId) ) { this.emit({ nodeId: node.nodeId, type: node.type, transition: 'completed', at, }); } } if (!this.getProgress()[mapNodeId]?.startedAt) { this.emit({ nodeId: mapNode.nodeId, type: mapNode.type, transition: 'started', at, }); } } onMapCompleted(mapNodeId: string, at = this.now()): void { const mapIndex = this.nodes.findIndex((node) => node.nodeId === mapNodeId); if (mapIndex < 0) return; const mapNode = this.nodes[mapIndex]!; if (!this.getProgress()[mapNodeId]?.completedAt) { this.emit({ nodeId: mapNode.nodeId, type: mapNode.type, transition: 'completed', at, }); } for (let index = mapIndex + 1; index < this.nodes.length; index += 1) { const node = this.nodes[index]!; if (node.type === 'dataset') break; if ( isAutoStartedSetupNode(node.type) && !this.getProgress()[node.nodeId]?.startedAt ) { this.emit({ nodeId: node.nodeId, type: node.type, transition: 'started', at, }); } } } onMapFailed(mapNodeId: string, at = this.now()): void { const mapIndex = this.nodes.findIndex((node) => node.nodeId === mapNodeId); if (mapIndex < 0) return; const mapNode = this.nodes[mapIndex]!; if (!this.getProgress()[mapNodeId]?.completedAt) { this.failedNodeIds.add(mapNode.nodeId); this.emit({ nodeId: mapNode.nodeId, type: mapNode.type, transition: 'failed', at, }); } } markAllTerminal(at = this.now()): void { for (const node of this.nodes) { const existing = this.getProgress()[node.nodeId]; if ( existing?.startedAt && !existing.completedAt && !this.failedNodeIds.has(node.nodeId) ) { this.emit({ nodeId: node.nodeId, type: node.type, transition: 'completed', at, }); } } } markStartedFailed(at = this.now(), error?: string): void { for (let index = this.nodes.length - 1; index >= 0; index -= 1) { const node = this.nodes[index]!; const existing = this.getProgress()[node.nodeId]; if (existing?.startedAt && !existing.completedAt) { this.failedNodeIds.add(node.nodeId); this.emit({ nodeId: node.nodeId, type: node.type, transition: 'failed', at, ...(error?.trim() ? { error: error.trim() } : {}), }); return; } } } private completeStartedNonMapNodes(at: number): void { for (const node of this.nodes) { if (node.type === 'dataset') { continue; } const existing = this.getProgress()[node.nodeId]; if ( existing?.startedAt && !existing.completedAt && !this.failedNodeIds.has(node.nodeId) ) { this.emit({ nodeId: node.nodeId, type: node.type, transition: 'completed', at, }); } } } }