/** * Workflow execution logic */ import { readFileSync } from 'node:fs'; import { WorkflowEngine } from '../workflow/engine.js'; import type { WorkflowConfig, Language } from '../models/types.js'; import type { IterationLimitRequest } from '../workflow/types.js'; import { loadAgentSessions, updateAgentSession, loadWorktreeSessions, updateWorktreeSession, } from '../config/paths.js'; import { loadGlobalConfig } from '../config/globalConfig.js'; import { header, info, warn, error, success, status, StreamDisplay, } from '../utils/ui.js'; import { generateSessionId, createSessionLog, finalizeSessionLog, updateLatestPointer, initNdjsonLog, appendNdjsonLine, type NdjsonStepStart, type NdjsonStepComplete, type NdjsonWorkflowComplete, type NdjsonWorkflowAbort, } from '../utils/session.js'; import { createLogger } from '../utils/debug.js'; import { notifySuccess, notifyError } from '../utils/notification.js'; import { selectOption, promptInput } from '../prompt/index.js'; const log = createLogger('workflow'); /** * Format elapsed time in human-readable format */ function formatElapsedTime(startTime: string, endTime: string): string { const start = new Date(startTime).getTime(); const end = new Date(endTime).getTime(); const elapsedMs = end - start; const elapsedSec = elapsedMs / 1000; if (elapsedSec < 60) { return `${elapsedSec.toFixed(1)}s`; } const minutes = Math.floor(elapsedSec / 60); const seconds = Math.floor(elapsedSec % 60); return `${minutes}m ${seconds}s`; } /** Result of workflow execution */ export interface WorkflowExecutionResult { success: boolean; reason?: string; } /** Options for workflow execution */ export interface WorkflowExecutionOptions { /** Header prefix for display */ headerPrefix?: string; /** Project root directory (where .takt/ lives). Defaults to cwd. */ projectCwd?: string; /** Language for instruction metadata */ language?: Language; } /** * Execute a workflow and handle all events */ export async function executeWorkflow( workflowConfig: WorkflowConfig, task: string, cwd: string, options: WorkflowExecutionOptions = {} ): Promise { const { headerPrefix = 'Running Workflow:', } = options; // projectCwd is where .takt/ lives (project root, not the clone) const projectCwd = options.projectCwd ?? cwd; // Always continue from previous sessions (use /clear to reset) log.debug('Continuing session (use /clear to reset)'); header(`${headerPrefix} ${workflowConfig.name}`); const workflowSessionId = generateSessionId(); let sessionLog = createSessionLog(task, projectCwd, workflowConfig.name); // Initialize NDJSON log file + pointer at workflow start const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd); updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true }); // Track current display for streaming const displayRef: { current: StreamDisplay | null } = { current: null }; // Create stream handler that delegates to UI display const streamHandler = ( event: Parameters>[0] ): void => { if (!displayRef.current) return; if (event.type === 'result') return; displayRef.current.createHandler()(event); }; // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; const currentProvider = loadGlobalConfig().provider ?? 'claude'; const savedSessions = isWorktree ? loadWorktreeSessions(projectCwd, cwd, currentProvider) : loadAgentSessions(projectCwd, currentProvider); // Session update handler - persist session IDs when they change // Clone sessions are stored separately per clone path const sessionUpdateHandler = isWorktree ? (agentName: string, agentSessionId: string): void => { updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider); } : (agentName: string, agentSessionId: string): void => { updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider); }; const iterationLimitHandler = async ( request: IterationLimitRequest ): Promise => { if (displayRef.current) { displayRef.current.flush(); displayRef.current = null; } console.log(); warn( `最大イテレーションに到達しました (${request.currentIteration}/${request.maxIterations})` ); info(`現在のステップ: ${request.currentStep}`); const action = await selectOption('続行しますか?', [ { label: '続行する(追加イテレーション数を入力)', value: 'continue', description: '入力した回数だけ上限を増やします', }, { label: '終了する', value: 'stop' }, ]); if (action !== 'continue') { return null; } while (true) { const input = await promptInput('追加するイテレーション数を入力してください(1以上)'); if (!input) { return null; } const additionalIterations = Number.parseInt(input, 10); if (Number.isInteger(additionalIterations) && additionalIterations > 0) { workflowConfig.maxIterations += additionalIterations; return additionalIterations; } warn('1以上の整数を入力してください。'); } }; const engine = new WorkflowEngine(workflowConfig, cwd, task, { onStream: streamHandler, initialSessions: savedSessions, onSessionUpdate: sessionUpdateHandler, onIterationLimit: iterationLimitHandler, projectCwd, language: options.language, }); let abortReason: string | undefined; engine.on('step:start', (step, iteration, instruction) => { log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration }); info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`); // Log prompt content for debugging if (instruction) { log.debug('Step instruction', instruction); } displayRef.current = new StreamDisplay(step.agentDisplayName); // Write step_start record to NDJSON log const record: NdjsonStepStart = { type: 'step_start', step: step.name, agent: step.agentDisplayName, iteration, timestamp: new Date().toISOString(), ...(instruction ? { instruction } : {}), }; appendNdjsonLine(ndjsonLogPath, record); }); engine.on('step:complete', (step, response, instruction) => { log.debug('Step completed', { step: step.name, status: response.status, matchedRuleIndex: response.matchedRuleIndex, matchedRuleMethod: response.matchedRuleMethod, contentLength: response.content.length, sessionId: response.sessionId, error: response.error, }); if (displayRef.current) { displayRef.current.flush(); displayRef.current = null; } console.log(); if (response.matchedRuleIndex != null && step.rules) { const rule = step.rules[response.matchedRuleIndex]; if (rule) { const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : ''; status('Status', `${rule.condition}${methodLabel}`); } else { status('Status', response.status); } } else { status('Status', response.status); } if (response.error) { error(`Error: ${response.error}`); } if (response.sessionId) { status('Session', response.sessionId); } // Write step_complete record to NDJSON log const record: NdjsonStepComplete = { type: 'step_complete', step: step.name, agent: response.agent, status: response.status, content: response.content, instruction, ...(response.matchedRuleIndex != null ? { matchedRuleIndex: response.matchedRuleIndex } : {}), ...(response.matchedRuleMethod ? { matchedRuleMethod: response.matchedRuleMethod } : {}), ...(response.error ? { error: response.error } : {}), timestamp: response.timestamp.toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); // Update in-memory log for pointer metadata (immutable) sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 }; updateLatestPointer(sessionLog, workflowSessionId, projectCwd); }); engine.on('step:report', (_step, filePath, fileName) => { const content = readFileSync(filePath, 'utf-8'); console.log(`\n📄 Report: ${fileName}\n`); console.log(content); }); engine.on('workflow:complete', (state) => { log.info('Workflow completed successfully', { iterations: state.iteration }); sessionLog = finalizeSessionLog(sessionLog, 'completed'); // Write workflow_complete record to NDJSON log const record: NdjsonWorkflowComplete = { type: 'workflow_complete', iterations: state.iteration, endTime: new Date().toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); updateLatestPointer(sessionLog, workflowSessionId, projectCwd); const elapsed = sessionLog.endTime ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) : ''; const elapsedDisplay = elapsed ? `, ${elapsed}` : ''; success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`); info(`Session log: ${ndjsonLogPath}`); notifySuccess('TAKT', `ワークフロー完了 (${state.iteration} iterations)`); }); engine.on('workflow:abort', (state, reason) => { log.error('Workflow aborted', { reason, iterations: state.iteration }); if (displayRef.current) { displayRef.current.flush(); displayRef.current = null; } abortReason = reason; sessionLog = finalizeSessionLog(sessionLog, 'aborted'); // Write workflow_abort record to NDJSON log const record: NdjsonWorkflowAbort = { type: 'workflow_abort', iterations: state.iteration, reason, endTime: new Date().toISOString(), }; appendNdjsonLine(ndjsonLogPath, record); updateLatestPointer(sessionLog, workflowSessionId, projectCwd); const elapsed = sessionLog.endTime ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) : ''; const elapsedDisplay = elapsed ? ` (${elapsed})` : ''; error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); info(`Session log: ${ndjsonLogPath}`); notifyError('TAKT', `中断: ${reason}`); }); const finalState = await engine.run(); return { success: finalState.status === 'completed', reason: abortReason, }; }