@sudocode-ai/local-server 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/execution/executors/agent-executor-wrapper.d.ts.map +1 -1
- package/dist/execution/executors/agent-executor-wrapper.js +57 -2
- package/dist/execution/executors/agent-executor-wrapper.js.map +1 -1
- package/dist/execution/process/builders/claude.d.ts.map +1 -1
- package/dist/execution/process/builders/claude.js +32 -1
- package/dist/execution/process/builders/claude.js.map +1 -1
- package/dist/execution/worktree/config.js +1 -1
- package/dist/execution/worktree/config.js.map +1 -1
- package/dist/execution/worktree/git-cli.d.ts +48 -0
- package/dist/execution/worktree/git-cli.d.ts.map +1 -1
- package/dist/execution/worktree/git-cli.js +81 -0
- package/dist/execution/worktree/git-cli.js.map +1 -1
- package/dist/execution/worktree/types.d.ts.map +1 -1
- package/dist/execution/worktree/types.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/public/assets/index-Nz4IjDwB.css +1 -0
- package/dist/public/assets/index-Z8yftXvD.js +824 -0
- package/dist/public/assets/index-Z8yftXvD.js.map +1 -0
- package/dist/public/assets/{react-vendor-DiL5hC7l.js → react-vendor-5f1Wq1qs.js} +5 -5
- package/dist/public/assets/{react-vendor-DiL5hC7l.js.map → react-vendor-5f1Wq1qs.js.map} +1 -1
- package/dist/public/assets/{ui-vendor-B4WMPEfa.js → ui-vendor-BDDPoYki.js} +2 -2
- package/dist/public/assets/{ui-vendor-B4WMPEfa.js.map → ui-vendor-BDDPoYki.js.map} +1 -1
- package/dist/public/index.html +4 -4
- package/dist/routes/executions.d.ts.map +1 -1
- package/dist/routes/executions.js +3 -1
- package/dist/routes/executions.js.map +1 -1
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +13 -0
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/specs.d.ts.map +1 -1
- package/dist/routes/specs.js +14 -0
- package/dist/routes/specs.js.map +1 -1
- package/dist/routes/workflows.d.ts +8 -0
- package/dist/routes/workflows.d.ts.map +1 -0
- package/dist/routes/workflows.js +1729 -0
- package/dist/routes/workflows.js.map +1 -0
- package/dist/services/execution-event-callbacks.d.ts +73 -0
- package/dist/services/execution-event-callbacks.d.ts.map +1 -0
- package/dist/services/execution-event-callbacks.js +82 -0
- package/dist/services/execution-event-callbacks.js.map +1 -0
- package/dist/services/execution-lifecycle.d.ts +38 -2
- package/dist/services/execution-lifecycle.d.ts.map +1 -1
- package/dist/services/execution-lifecycle.js +94 -23
- package/dist/services/execution-lifecycle.js.map +1 -1
- package/dist/services/execution-service.d.ts +31 -3
- package/dist/services/execution-service.d.ts.map +1 -1
- package/dist/services/execution-service.js +161 -34
- package/dist/services/execution-service.js.map +1 -1
- package/dist/services/executions.d.ts +1 -0
- package/dist/services/executions.d.ts.map +1 -1
- package/dist/services/executions.js +4 -0
- package/dist/services/executions.js.map +1 -1
- package/dist/services/project-context.d.ts +25 -0
- package/dist/services/project-context.d.ts.map +1 -1
- package/dist/services/project-context.js +53 -3
- package/dist/services/project-context.js.map +1 -1
- package/dist/services/project-manager.d.ts +7 -0
- package/dist/services/project-manager.d.ts.map +1 -1
- package/dist/services/project-manager.js +108 -13
- package/dist/services/project-manager.js.map +1 -1
- package/dist/services/websocket.d.ts +10 -2
- package/dist/services/websocket.d.ts.map +1 -1
- package/dist/services/websocket.js +18 -0
- package/dist/services/websocket.js.map +1 -1
- package/dist/services/workflow-broadcast-service.d.ts +43 -0
- package/dist/services/workflow-broadcast-service.d.ts.map +1 -0
- package/dist/services/workflow-broadcast-service.js +145 -0
- package/dist/services/workflow-broadcast-service.js.map +1 -0
- package/dist/services/worktree-sync-service.d.ts +76 -4
- package/dist/services/worktree-sync-service.d.ts.map +1 -1
- package/dist/services/worktree-sync-service.js +264 -23
- package/dist/services/worktree-sync-service.js.map +1 -1
- package/dist/workflow/base-workflow-engine.d.ts +186 -0
- package/dist/workflow/base-workflow-engine.d.ts.map +1 -0
- package/dist/workflow/base-workflow-engine.js +549 -0
- package/dist/workflow/base-workflow-engine.js.map +1 -0
- package/dist/workflow/dependency-analyzer.d.ts +78 -0
- package/dist/workflow/dependency-analyzer.d.ts.map +1 -0
- package/dist/workflow/dependency-analyzer.js +264 -0
- package/dist/workflow/dependency-analyzer.js.map +1 -0
- package/dist/workflow/engines/orchestrator-engine.d.ts +237 -0
- package/dist/workflow/engines/orchestrator-engine.d.ts.map +1 -0
- package/dist/workflow/engines/orchestrator-engine.js +749 -0
- package/dist/workflow/engines/orchestrator-engine.js.map +1 -0
- package/dist/workflow/engines/sequential-engine.d.ts +276 -0
- package/dist/workflow/engines/sequential-engine.d.ts.map +1 -0
- package/dist/workflow/engines/sequential-engine.js +1110 -0
- package/dist/workflow/engines/sequential-engine.js.map +1 -0
- package/dist/workflow/index.d.ts +15 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +22 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/mcp/api-client.d.ts +103 -0
- package/dist/workflow/mcp/api-client.d.ts.map +1 -0
- package/dist/workflow/mcp/api-client.js +193 -0
- package/dist/workflow/mcp/api-client.js.map +1 -0
- package/dist/workflow/mcp/index.d.ts +16 -0
- package/dist/workflow/mcp/index.d.ts.map +1 -0
- package/dist/workflow/mcp/index.js +114 -0
- package/dist/workflow/mcp/index.js.map +1 -0
- package/dist/workflow/mcp/server.d.ts +85 -0
- package/dist/workflow/mcp/server.d.ts.map +1 -0
- package/dist/workflow/mcp/server.js +520 -0
- package/dist/workflow/mcp/server.js.map +1 -0
- package/dist/workflow/mcp/tools/escalation.d.ts +36 -0
- package/dist/workflow/mcp/tools/escalation.d.ts.map +1 -0
- package/dist/workflow/mcp/tools/escalation.js +47 -0
- package/dist/workflow/mcp/tools/escalation.js.map +1 -0
- package/dist/workflow/mcp/tools/execution.d.ts +59 -0
- package/dist/workflow/mcp/tools/execution.d.ts.map +1 -0
- package/dist/workflow/mcp/tools/execution.js +67 -0
- package/dist/workflow/mcp/tools/execution.js.map +1 -0
- package/dist/workflow/mcp/tools/inspection.d.ts +82 -0
- package/dist/workflow/mcp/tools/inspection.d.ts.map +1 -0
- package/dist/workflow/mcp/tools/inspection.js +57 -0
- package/dist/workflow/mcp/tools/inspection.js.map +1 -0
- package/dist/workflow/mcp/tools/workflow.d.ts +59 -0
- package/dist/workflow/mcp/tools/workflow.d.ts.map +1 -0
- package/dist/workflow/mcp/tools/workflow.js +40 -0
- package/dist/workflow/mcp/tools/workflow.js.map +1 -0
- package/dist/workflow/mcp/types.d.ts +345 -0
- package/dist/workflow/mcp/types.d.ts.map +1 -0
- package/dist/workflow/mcp/types.js +7 -0
- package/dist/workflow/mcp/types.js.map +1 -0
- package/dist/workflow/services/prompt-builder.d.ts +36 -0
- package/dist/workflow/services/prompt-builder.d.ts.map +1 -0
- package/dist/workflow/services/prompt-builder.js +329 -0
- package/dist/workflow/services/prompt-builder.js.map +1 -0
- package/dist/workflow/services/wakeup-service.d.ts +262 -0
- package/dist/workflow/services/wakeup-service.d.ts.map +1 -0
- package/dist/workflow/services/wakeup-service.js +809 -0
- package/dist/workflow/services/wakeup-service.js.map +1 -0
- package/dist/workflow/workflow-engine.d.ts +221 -0
- package/dist/workflow/workflow-engine.d.ts.map +1 -0
- package/dist/workflow/workflow-engine.js +94 -0
- package/dist/workflow/workflow-engine.js.map +1 -0
- package/dist/workflow/workflow-event-emitter.d.ts +278 -0
- package/dist/workflow/workflow-event-emitter.d.ts.map +1 -0
- package/dist/workflow/workflow-event-emitter.js +259 -0
- package/dist/workflow/workflow-event-emitter.js.map +1 -0
- package/package.json +8 -6
- package/dist/public/assets/index-DV9Tbujb.css +0 -1
- package/dist/public/assets/index-DcDX9-Ad.js +0 -740
- package/dist/public/assets/index-DcDX9-Ad.js.map +0 -1
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequential Workflow Engine
|
|
3
|
+
*
|
|
4
|
+
* Executes workflow steps in topological order with worktree reuse.
|
|
5
|
+
* Supports both sequential and parallel execution modes.
|
|
6
|
+
*/
|
|
7
|
+
import { exec } from "child_process";
|
|
8
|
+
import { promisify } from "util";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import { getIssue, updateIssue, } from "@sudocode-ai/cli/dist/operations/issues.js";
|
|
11
|
+
import { readJSONLSync, writeJSONL, } from "@sudocode-ai/cli/dist/jsonl.js";
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
import { BaseWorkflowEngine } from "../base-workflow-engine.js";
|
|
14
|
+
import { WorkflowCycleError, WorkflowStateError, WorkflowStepNotFoundError, } from "../workflow-engine.js";
|
|
15
|
+
import { getExecution } from "../../services/executions.js";
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Sequential Workflow Engine
|
|
18
|
+
// =============================================================================
|
|
19
|
+
/**
|
|
20
|
+
* Sequential Workflow Engine implementation.
|
|
21
|
+
*
|
|
22
|
+
* Executes workflow steps in topological order (respecting dependencies).
|
|
23
|
+
* Features:
|
|
24
|
+
* - Single shared worktree for all steps
|
|
25
|
+
* - Auto-commit after each step (configurable)
|
|
26
|
+
* - Configurable failure handling (stop, pause, skip_dependents, continue)
|
|
27
|
+
* - Support for parallel execution of independent steps
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const engine = new SequentialWorkflowEngine(db, executionService, repoPath);
|
|
32
|
+
*
|
|
33
|
+
* // Create workflow from a spec
|
|
34
|
+
* const workflow = await engine.createWorkflow({
|
|
35
|
+
* type: "spec",
|
|
36
|
+
* specId: "s-auth"
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // Start execution
|
|
40
|
+
* await engine.startWorkflow(workflow.id);
|
|
41
|
+
*
|
|
42
|
+
* // Subscribe to events
|
|
43
|
+
* engine.onWorkflowEvent((event) => {
|
|
44
|
+
* console.log(event.type, event);
|
|
45
|
+
* });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export class SequentialWorkflowEngine extends BaseWorkflowEngine {
|
|
49
|
+
executionService;
|
|
50
|
+
lifecycleService;
|
|
51
|
+
repoPath;
|
|
52
|
+
activeWorkflows = new Map();
|
|
53
|
+
constructor(db, executionService, lifecycleService, repoPath, eventEmitter) {
|
|
54
|
+
super(db, eventEmitter);
|
|
55
|
+
this.executionService = executionService;
|
|
56
|
+
this.lifecycleService = lifecycleService;
|
|
57
|
+
this.repoPath = repoPath;
|
|
58
|
+
}
|
|
59
|
+
// ===========================================================================
|
|
60
|
+
// Workflow Creation
|
|
61
|
+
// ===========================================================================
|
|
62
|
+
/**
|
|
63
|
+
* Create a new workflow from a source definition.
|
|
64
|
+
*
|
|
65
|
+
* @param source - How to determine workflow scope (spec, issues, root_issue, or goal)
|
|
66
|
+
* @param config - Optional configuration overrides (includes baseBranch, title)
|
|
67
|
+
* @returns The created workflow
|
|
68
|
+
* @throws WorkflowCycleError if dependency cycles are detected
|
|
69
|
+
*/
|
|
70
|
+
async createWorkflow(source, config) {
|
|
71
|
+
// 1. Resolve source to issue IDs
|
|
72
|
+
const issueIds = await this.resolveSource(source);
|
|
73
|
+
// 2. Handle goal-based workflows (no initial issues)
|
|
74
|
+
if (source.type === "goal" && issueIds.length === 0) {
|
|
75
|
+
// Goal workflows start with no steps - orchestrator creates them
|
|
76
|
+
const workflow = this.buildWorkflow({
|
|
77
|
+
source,
|
|
78
|
+
steps: [],
|
|
79
|
+
config: config || {},
|
|
80
|
+
repoPath: this.repoPath,
|
|
81
|
+
});
|
|
82
|
+
this.saveWorkflow(workflow);
|
|
83
|
+
return workflow;
|
|
84
|
+
}
|
|
85
|
+
// 3. Build dependency graph
|
|
86
|
+
const graph = this.analyzeDependencies(issueIds);
|
|
87
|
+
// 4. Check for cycles
|
|
88
|
+
if (graph.cycles && graph.cycles.length > 0) {
|
|
89
|
+
throw new WorkflowCycleError(graph.cycles);
|
|
90
|
+
}
|
|
91
|
+
// 5. Create steps from graph
|
|
92
|
+
const steps = this.createStepsFromGraph(graph);
|
|
93
|
+
// 6. Build workflow object
|
|
94
|
+
const workflow = this.buildWorkflow({
|
|
95
|
+
source,
|
|
96
|
+
steps,
|
|
97
|
+
config: config || {},
|
|
98
|
+
repoPath: this.repoPath,
|
|
99
|
+
});
|
|
100
|
+
// 7. Save to database
|
|
101
|
+
this.saveWorkflow(workflow);
|
|
102
|
+
return workflow;
|
|
103
|
+
}
|
|
104
|
+
// ===========================================================================
|
|
105
|
+
// Workflow Recovery
|
|
106
|
+
// ===========================================================================
|
|
107
|
+
/**
|
|
108
|
+
* Recover workflows that were running when the server crashed.
|
|
109
|
+
*
|
|
110
|
+
* This method:
|
|
111
|
+
* 1. Finds all sequential workflows in 'running' or 'paused' status
|
|
112
|
+
* 2. Reconstructs the activeWorkflows Map from database state
|
|
113
|
+
* 3. Handles any steps that were 'running' when the server crashed
|
|
114
|
+
* 4. Resumes execution loops for 'running' workflows
|
|
115
|
+
*
|
|
116
|
+
* Should be called during server startup after engine initialization.
|
|
117
|
+
*/
|
|
118
|
+
async recoverWorkflows() {
|
|
119
|
+
console.log("[SequentialWorkflowEngine] Starting workflow recovery...");
|
|
120
|
+
// Find all sequential workflows that need recovery
|
|
121
|
+
const rows = this.db
|
|
122
|
+
.prepare(`
|
|
123
|
+
SELECT * FROM workflows
|
|
124
|
+
WHERE status IN ('running', 'paused')
|
|
125
|
+
AND json_extract(config, '$.engineType') = 'sequential'
|
|
126
|
+
`)
|
|
127
|
+
.all();
|
|
128
|
+
console.log(`[SequentialWorkflowEngine] Found ${rows.length} workflows to recover`);
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
try {
|
|
131
|
+
await this.recoverSingleWorkflow(row);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error(`[SequentialWorkflowEngine] Failed to recover workflow ${row.id}:`, error);
|
|
135
|
+
// Continue with other workflows even if one fails
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
console.log("[SequentialWorkflowEngine] Workflow recovery complete");
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Recover a single workflow from its database row.
|
|
142
|
+
*/
|
|
143
|
+
async recoverSingleWorkflow(row) {
|
|
144
|
+
const workflowId = row.id;
|
|
145
|
+
const isPaused = row.status === "paused";
|
|
146
|
+
console.log(`[SequentialWorkflowEngine] Recovering workflow ${workflowId} (status: ${row.status})`);
|
|
147
|
+
// Reconstruct activeWorkflows entry
|
|
148
|
+
this.activeWorkflows.set(workflowId, {
|
|
149
|
+
workflowId,
|
|
150
|
+
isPaused,
|
|
151
|
+
isCancelled: false,
|
|
152
|
+
});
|
|
153
|
+
// Parse steps to find any that were running
|
|
154
|
+
const steps = JSON.parse(row.steps);
|
|
155
|
+
const runningStep = steps.find((s) => s.status === "running");
|
|
156
|
+
if (runningStep) {
|
|
157
|
+
console.log(`[SequentialWorkflowEngine] Found running step ${runningStep.id} (execution: ${runningStep.executionId})`);
|
|
158
|
+
await this.handleCrashedStep(workflowId, runningStep, row);
|
|
159
|
+
}
|
|
160
|
+
// Resume execution loop if workflow was running (not paused)
|
|
161
|
+
if (!isPaused) {
|
|
162
|
+
console.log(`[SequentialWorkflowEngine] Resuming execution loop for workflow ${workflowId}`);
|
|
163
|
+
this.runExecutionLoop(workflowId).catch((error) => {
|
|
164
|
+
console.error(`[SequentialWorkflowEngine] Recovered workflow ${workflowId} execution loop failed:`, error);
|
|
165
|
+
this.failWorkflow(workflowId, error.message).catch(console.error);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
console.log(`[SequentialWorkflowEngine] Workflow ${workflowId} is paused, not resuming execution loop`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Handle a step that was running when the server crashed.
|
|
174
|
+
*/
|
|
175
|
+
async handleCrashedStep(workflowId, step, workflowRow) {
|
|
176
|
+
if (!step.executionId) {
|
|
177
|
+
// Step was marked running but no execution was created yet
|
|
178
|
+
// Reset to pending so it can be retried
|
|
179
|
+
console.log(`[SequentialWorkflowEngine] Step ${step.id} has no execution, resetting to pending`);
|
|
180
|
+
this.updateStep(workflowId, step.id, {
|
|
181
|
+
status: "pending",
|
|
182
|
+
error: undefined,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Check the execution status
|
|
187
|
+
const execution = getExecution(this.db, step.executionId);
|
|
188
|
+
if (!execution) {
|
|
189
|
+
// Execution record doesn't exist - this shouldn't happen but handle it
|
|
190
|
+
console.warn(`[SequentialWorkflowEngine] Execution ${step.executionId} not found, marking step as failed`);
|
|
191
|
+
this.updateStep(workflowId, step.id, {
|
|
192
|
+
status: "failed",
|
|
193
|
+
error: "Execution record not found after server restart",
|
|
194
|
+
});
|
|
195
|
+
await this.handleRecoveredStepFailure(workflowId, step, workflowRow);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Handle based on execution status
|
|
199
|
+
switch (execution.status) {
|
|
200
|
+
case "completed":
|
|
201
|
+
// Execution completed but we didn't record step success
|
|
202
|
+
console.log(`[SequentialWorkflowEngine] Execution ${step.executionId} completed, handling success`);
|
|
203
|
+
await this.handleRecoveredStepSuccess(workflowId, step, execution, workflowRow);
|
|
204
|
+
break;
|
|
205
|
+
case "failed":
|
|
206
|
+
case "cancelled":
|
|
207
|
+
case "stopped":
|
|
208
|
+
// Execution failed/cancelled but we didn't record step failure
|
|
209
|
+
console.log(`[SequentialWorkflowEngine] Execution ${step.executionId} ${execution.status}, handling failure`);
|
|
210
|
+
this.updateStep(workflowId, step.id, {
|
|
211
|
+
status: "failed",
|
|
212
|
+
error: execution.error_message || `Execution ${execution.status}`,
|
|
213
|
+
});
|
|
214
|
+
await this.handleRecoveredStepFailure(workflowId, step, workflowRow);
|
|
215
|
+
break;
|
|
216
|
+
case "running":
|
|
217
|
+
case "pending":
|
|
218
|
+
case "preparing":
|
|
219
|
+
case "paused":
|
|
220
|
+
// Execution was still in progress - it's now orphaned
|
|
221
|
+
// Mark as failed since the process is gone
|
|
222
|
+
console.log(`[SequentialWorkflowEngine] Execution ${step.executionId} was ${execution.status}, marking as failed`);
|
|
223
|
+
this.updateStep(workflowId, step.id, {
|
|
224
|
+
status: "failed",
|
|
225
|
+
error: "Server crashed during execution",
|
|
226
|
+
});
|
|
227
|
+
await this.handleRecoveredStepFailure(workflowId, step, workflowRow);
|
|
228
|
+
break;
|
|
229
|
+
default:
|
|
230
|
+
console.warn(`[SequentialWorkflowEngine] Unknown execution status: ${execution.status}`);
|
|
231
|
+
this.updateStep(workflowId, step.id, {
|
|
232
|
+
status: "failed",
|
|
233
|
+
error: `Unknown execution status: ${execution.status}`,
|
|
234
|
+
});
|
|
235
|
+
await this.handleRecoveredStepFailure(workflowId, step, workflowRow);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Handle step success during recovery.
|
|
240
|
+
*/
|
|
241
|
+
async handleRecoveredStepSuccess(workflowId, step, execution, workflowRow) {
|
|
242
|
+
// Update step status
|
|
243
|
+
this.updateStep(workflowId, step.id, {
|
|
244
|
+
status: "completed",
|
|
245
|
+
commitSha: execution.after_commit ?? undefined,
|
|
246
|
+
});
|
|
247
|
+
// Update workflow progress
|
|
248
|
+
this.updateWorkflow(workflowId, {
|
|
249
|
+
currentStepIndex: workflowRow.current_step_index + 1,
|
|
250
|
+
});
|
|
251
|
+
// Close the issue in the worktree JSONL if available
|
|
252
|
+
await this.closeIssue(step.issueId, workflowRow.worktree_path ?? undefined);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Handle step failure during recovery.
|
|
256
|
+
* Applies the workflow's onFailure strategy.
|
|
257
|
+
*/
|
|
258
|
+
async handleRecoveredStepFailure(workflowId, step, workflowRow) {
|
|
259
|
+
const config = JSON.parse(workflowRow.config);
|
|
260
|
+
const steps = JSON.parse(workflowRow.steps);
|
|
261
|
+
// Build a minimal workflow object for helper methods
|
|
262
|
+
const workflow = {
|
|
263
|
+
id: workflowId,
|
|
264
|
+
title: workflowRow.title,
|
|
265
|
+
source: JSON.parse(workflowRow.source),
|
|
266
|
+
status: workflowRow.status,
|
|
267
|
+
steps,
|
|
268
|
+
worktreePath: workflowRow.worktree_path ?? undefined,
|
|
269
|
+
branchName: workflowRow.branch_name ?? undefined,
|
|
270
|
+
baseBranch: workflowRow.base_branch,
|
|
271
|
+
currentStepIndex: workflowRow.current_step_index,
|
|
272
|
+
config,
|
|
273
|
+
createdAt: "",
|
|
274
|
+
updatedAt: "",
|
|
275
|
+
};
|
|
276
|
+
// Apply failure strategy
|
|
277
|
+
switch (config.onFailure) {
|
|
278
|
+
case "stop":
|
|
279
|
+
await this.failWorkflow(workflowId, `Step ${step.id} failed during recovery`);
|
|
280
|
+
break;
|
|
281
|
+
case "pause":
|
|
282
|
+
this.updateWorkflow(workflowId, { status: "paused" });
|
|
283
|
+
const state = this.activeWorkflows.get(workflowId);
|
|
284
|
+
if (state) {
|
|
285
|
+
state.isPaused = true;
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
case "skip_dependents":
|
|
289
|
+
await this.skipDependentSteps(workflow, step, `Dependency ${step.issueId} failed during recovery`);
|
|
290
|
+
break;
|
|
291
|
+
case "continue":
|
|
292
|
+
await this.blockDependentSteps(workflow, step);
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ===========================================================================
|
|
297
|
+
// Workflow Lifecycle
|
|
298
|
+
// ===========================================================================
|
|
299
|
+
/**
|
|
300
|
+
* Start executing a pending workflow.
|
|
301
|
+
*
|
|
302
|
+
* Creates a worktree and begins the execution loop.
|
|
303
|
+
*
|
|
304
|
+
* @param workflowId - The workflow to start
|
|
305
|
+
* @throws WorkflowNotFoundError if workflow doesn't exist
|
|
306
|
+
* @throws WorkflowStateError if workflow is not in pending state
|
|
307
|
+
*/
|
|
308
|
+
async startWorkflow(workflowId) {
|
|
309
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
310
|
+
// Validate state
|
|
311
|
+
if (workflow.status !== "pending") {
|
|
312
|
+
throw new WorkflowStateError(workflowId, workflow.status, "start");
|
|
313
|
+
}
|
|
314
|
+
// Create workflow-level worktree if not already present
|
|
315
|
+
if (!workflow.worktreePath) {
|
|
316
|
+
const { worktreePath, branchName } = await this.createWorkflowWorktreeHelper(workflow, this.repoPath, this.lifecycleService);
|
|
317
|
+
// Update local workflow reference with worktree info
|
|
318
|
+
workflow.worktreePath = worktreePath;
|
|
319
|
+
workflow.branchName = branchName;
|
|
320
|
+
}
|
|
321
|
+
// Initialize workflow state
|
|
322
|
+
this.activeWorkflows.set(workflowId, {
|
|
323
|
+
workflowId,
|
|
324
|
+
isPaused: false,
|
|
325
|
+
isCancelled: false,
|
|
326
|
+
});
|
|
327
|
+
// Update status to running
|
|
328
|
+
const updated = this.updateWorkflow(workflowId, {
|
|
329
|
+
status: "running",
|
|
330
|
+
startedAt: new Date().toISOString(),
|
|
331
|
+
});
|
|
332
|
+
// Emit workflow started event
|
|
333
|
+
this.eventEmitter.emit({
|
|
334
|
+
type: "workflow_started",
|
|
335
|
+
workflowId,
|
|
336
|
+
workflow: updated,
|
|
337
|
+
timestamp: Date.now(),
|
|
338
|
+
});
|
|
339
|
+
// Start execution loop (non-blocking)
|
|
340
|
+
this.runExecutionLoop(workflowId).catch((error) => {
|
|
341
|
+
console.error(`Workflow ${workflowId} execution loop failed:`, error);
|
|
342
|
+
this.failWorkflow(workflowId, error.message).catch(console.error);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Pause a running workflow, cancelling the current execution.
|
|
347
|
+
*
|
|
348
|
+
* The current step is reset to "pending" so it can be re-executed on resume.
|
|
349
|
+
*
|
|
350
|
+
* @param workflowId - The workflow to pause
|
|
351
|
+
* @throws WorkflowNotFoundError if workflow doesn't exist
|
|
352
|
+
* @throws WorkflowStateError if workflow is not running
|
|
353
|
+
*/
|
|
354
|
+
async pauseWorkflow(workflowId) {
|
|
355
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
356
|
+
if (workflow.status !== "running") {
|
|
357
|
+
throw new WorkflowStateError(workflowId, workflow.status, "pause");
|
|
358
|
+
}
|
|
359
|
+
// Set pause flag for execution loop
|
|
360
|
+
const state = this.activeWorkflows.get(workflowId);
|
|
361
|
+
if (state) {
|
|
362
|
+
state.isPaused = true;
|
|
363
|
+
// Cancel current execution if running and reset the step status
|
|
364
|
+
// Keep the executionId so we can resume the session later
|
|
365
|
+
if (state.currentExecutionId) {
|
|
366
|
+
try {
|
|
367
|
+
await this.executionService.cancelExecution(state.currentExecutionId);
|
|
368
|
+
// Find the running step and reset status to pending (keep executionId for resume)
|
|
369
|
+
const runningStep = workflow.steps.find((s) => s.status === "running");
|
|
370
|
+
if (runningStep) {
|
|
371
|
+
this.updateStep(workflowId, runningStep.id, {
|
|
372
|
+
status: "pending",
|
|
373
|
+
// Keep executionId so we can resume the session
|
|
374
|
+
error: undefined,
|
|
375
|
+
});
|
|
376
|
+
console.log(`[SequentialWorkflowEngine] Reset step ${runningStep.id} to pending after pause (keeping executionId for resume)`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
console.warn(`[SequentialWorkflowEngine] Failed to cancel execution ${state.currentExecutionId}:`, error);
|
|
381
|
+
}
|
|
382
|
+
state.currentExecutionId = undefined;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Update status
|
|
386
|
+
this.updateWorkflow(workflowId, { status: "paused" });
|
|
387
|
+
// Emit event
|
|
388
|
+
this.eventEmitter.emit({
|
|
389
|
+
type: "workflow_paused",
|
|
390
|
+
workflowId,
|
|
391
|
+
timestamp: Date.now(),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Resume a paused workflow.
|
|
396
|
+
*
|
|
397
|
+
* @param workflowId - The workflow to resume
|
|
398
|
+
* @param _message - Optional message (not used in sequential engine, no orchestrator)
|
|
399
|
+
* @throws WorkflowNotFoundError if workflow doesn't exist
|
|
400
|
+
* @throws WorkflowStateError if workflow is not paused
|
|
401
|
+
*/
|
|
402
|
+
async resumeWorkflow(workflowId, _message) {
|
|
403
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
404
|
+
if (workflow.status !== "paused") {
|
|
405
|
+
throw new WorkflowStateError(workflowId, workflow.status, "resume");
|
|
406
|
+
}
|
|
407
|
+
// Clear pause flag
|
|
408
|
+
let state = this.activeWorkflows.get(workflowId);
|
|
409
|
+
if (!state) {
|
|
410
|
+
state = {
|
|
411
|
+
workflowId,
|
|
412
|
+
isPaused: false,
|
|
413
|
+
isCancelled: false,
|
|
414
|
+
};
|
|
415
|
+
this.activeWorkflows.set(workflowId, state);
|
|
416
|
+
}
|
|
417
|
+
state.isPaused = false;
|
|
418
|
+
// Update status
|
|
419
|
+
this.updateWorkflow(workflowId, { status: "running" });
|
|
420
|
+
// Emit event
|
|
421
|
+
this.eventEmitter.emit({
|
|
422
|
+
type: "workflow_resumed",
|
|
423
|
+
workflowId,
|
|
424
|
+
timestamp: Date.now(),
|
|
425
|
+
});
|
|
426
|
+
// Restart execution loop
|
|
427
|
+
this.runExecutionLoop(workflowId).catch((error) => {
|
|
428
|
+
console.error(`Workflow ${workflowId} execution loop failed:`, error);
|
|
429
|
+
this.failWorkflow(workflowId, error.message).catch(console.error);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Cancel a workflow, stopping any running executions.
|
|
434
|
+
*
|
|
435
|
+
* @param workflowId - The workflow to cancel
|
|
436
|
+
* @throws WorkflowNotFoundError if workflow doesn't exist
|
|
437
|
+
* @throws WorkflowStateError if workflow is already completed/failed/cancelled
|
|
438
|
+
*/
|
|
439
|
+
async cancelWorkflow(workflowId) {
|
|
440
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
441
|
+
// Check if already in terminal state
|
|
442
|
+
if (["completed", "failed", "cancelled"].includes(workflow.status)) {
|
|
443
|
+
throw new WorkflowStateError(workflowId, workflow.status, "cancel");
|
|
444
|
+
}
|
|
445
|
+
// Set cancel flag
|
|
446
|
+
const state = this.activeWorkflows.get(workflowId);
|
|
447
|
+
if (state) {
|
|
448
|
+
state.isCancelled = true;
|
|
449
|
+
// Cancel current execution if running
|
|
450
|
+
if (state.currentExecutionId) {
|
|
451
|
+
try {
|
|
452
|
+
await this.executionService.cancelExecution(state.currentExecutionId);
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
console.warn(`Failed to cancel execution ${state.currentExecutionId}:`, error);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Update status
|
|
460
|
+
this.updateWorkflow(workflowId, {
|
|
461
|
+
status: "cancelled",
|
|
462
|
+
completedAt: new Date().toISOString(),
|
|
463
|
+
});
|
|
464
|
+
// Emit event
|
|
465
|
+
this.eventEmitter.emit({
|
|
466
|
+
type: "workflow_cancelled",
|
|
467
|
+
workflowId,
|
|
468
|
+
timestamp: Date.now(),
|
|
469
|
+
});
|
|
470
|
+
// Cleanup
|
|
471
|
+
this.activeWorkflows.delete(workflowId);
|
|
472
|
+
}
|
|
473
|
+
// ===========================================================================
|
|
474
|
+
// Step Control
|
|
475
|
+
// ===========================================================================
|
|
476
|
+
/**
|
|
477
|
+
* Retry a failed step.
|
|
478
|
+
*
|
|
479
|
+
* Resets the step status to pending and unblocks dependent steps.
|
|
480
|
+
* If the workflow was paused due to the failure, it will be resumed.
|
|
481
|
+
*
|
|
482
|
+
* @param workflowId - The workflow containing the step
|
|
483
|
+
* @param stepId - The step to retry
|
|
484
|
+
* @throws WorkflowNotFoundError if workflow doesn't exist
|
|
485
|
+
* @throws WorkflowStepNotFoundError if step doesn't exist
|
|
486
|
+
* @throws WorkflowStateError if step is not in failed state
|
|
487
|
+
*/
|
|
488
|
+
async retryStep(workflowId, stepId) {
|
|
489
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
490
|
+
const step = workflow.steps.find((s) => s.id === stepId);
|
|
491
|
+
if (!step) {
|
|
492
|
+
throw new WorkflowStepNotFoundError(workflowId, stepId);
|
|
493
|
+
}
|
|
494
|
+
if (step.status !== "failed") {
|
|
495
|
+
throw new WorkflowStateError(workflowId, `step ${stepId} is ${step.status}`, "retry");
|
|
496
|
+
}
|
|
497
|
+
// Reset step status
|
|
498
|
+
this.updateStep(workflowId, stepId, {
|
|
499
|
+
status: "pending",
|
|
500
|
+
error: undefined,
|
|
501
|
+
executionId: undefined,
|
|
502
|
+
});
|
|
503
|
+
// Unblock dependent steps
|
|
504
|
+
await this.unblockDependentSteps(workflow, step);
|
|
505
|
+
// Resume workflow if paused
|
|
506
|
+
if (workflow.status === "paused") {
|
|
507
|
+
await this.resumeWorkflow(workflowId);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Skip a step and handle its dependents.
|
|
512
|
+
*
|
|
513
|
+
* @param workflowId - The workflow containing the step
|
|
514
|
+
* @param stepId - The step to skip
|
|
515
|
+
* @param reason - Optional reason for skipping
|
|
516
|
+
* @throws WorkflowNotFoundError if workflow doesn't exist
|
|
517
|
+
* @throws WorkflowStepNotFoundError if step doesn't exist
|
|
518
|
+
*/
|
|
519
|
+
async skipStep(workflowId, stepId, reason) {
|
|
520
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
521
|
+
const step = workflow.steps.find((s) => s.id === stepId);
|
|
522
|
+
if (!step) {
|
|
523
|
+
throw new WorkflowStepNotFoundError(workflowId, stepId);
|
|
524
|
+
}
|
|
525
|
+
const skipReason = reason || "Manually skipped";
|
|
526
|
+
// Mark as skipped
|
|
527
|
+
this.updateStep(workflowId, stepId, {
|
|
528
|
+
status: "skipped",
|
|
529
|
+
error: skipReason,
|
|
530
|
+
});
|
|
531
|
+
// Emit event
|
|
532
|
+
this.eventEmitter.emit({
|
|
533
|
+
type: "step_skipped",
|
|
534
|
+
workflowId,
|
|
535
|
+
step: { ...step, status: "skipped", error: skipReason },
|
|
536
|
+
reason: skipReason,
|
|
537
|
+
timestamp: Date.now(),
|
|
538
|
+
});
|
|
539
|
+
// Handle dependents based on config
|
|
540
|
+
if (workflow.config.onFailure === "skip_dependents") {
|
|
541
|
+
await this.skipDependentSteps(workflow, step, "Dependency skipped");
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
await this.blockDependentSteps(workflow, step);
|
|
545
|
+
}
|
|
546
|
+
// Resume workflow if paused
|
|
547
|
+
if (workflow.status === "paused") {
|
|
548
|
+
await this.resumeWorkflow(workflowId);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// ===========================================================================
|
|
552
|
+
// Execution Loop
|
|
553
|
+
// ===========================================================================
|
|
554
|
+
/**
|
|
555
|
+
* Main execution loop for the workflow.
|
|
556
|
+
* Runs steps in topological order, respecting dependencies.
|
|
557
|
+
*
|
|
558
|
+
* @param workflowId - The workflow to execute
|
|
559
|
+
*/
|
|
560
|
+
async runExecutionLoop(workflowId) {
|
|
561
|
+
while (true) {
|
|
562
|
+
// Check workflow state
|
|
563
|
+
const state = this.activeWorkflows.get(workflowId);
|
|
564
|
+
if (!state || state.isPaused || state.isCancelled) {
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
// Get current workflow state
|
|
568
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
569
|
+
// Check if workflow is complete
|
|
570
|
+
if (this.isWorkflowComplete(workflow)) {
|
|
571
|
+
await this.completeWorkflow(workflowId);
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
// Get ready steps (dependencies satisfied)
|
|
575
|
+
const readySteps = await this.getReadySteps(workflowId);
|
|
576
|
+
if (readySteps.length === 0) {
|
|
577
|
+
// No ready steps but workflow not complete - likely all remaining steps are blocked
|
|
578
|
+
// This can happen if a step fails and dependents are blocked
|
|
579
|
+
const hasBlockedOrFailed = workflow.steps.some((s) => s.status === "blocked" || s.status === "failed");
|
|
580
|
+
if (hasBlockedOrFailed) {
|
|
581
|
+
// Workflow is stuck, wait for user intervention (retry/skip)
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
// Should not happen - safeguard against infinite loop
|
|
585
|
+
console.warn(`Workflow ${workflowId}: No ready steps but workflow not complete`);
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
// Execute steps based on parallelism config
|
|
589
|
+
if (workflow.config.parallelism === "sequential") {
|
|
590
|
+
// Execute one step at a time
|
|
591
|
+
await this.executeStep(workflow, readySteps[0]);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
// Execute multiple ready steps in parallel mode
|
|
595
|
+
// Note: For Phase 4, this uses shared worktree with sequential commits
|
|
596
|
+
// True parallel execution with separate branches is a future enhancement
|
|
597
|
+
await this.executeParallel(workflow, readySteps);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Execute multiple steps in parallel mode.
|
|
603
|
+
*
|
|
604
|
+
* For Phase 4, this uses a shared worktree with sequential commits.
|
|
605
|
+
* Steps are executed one after another but all ready steps in the batch
|
|
606
|
+
* are processed before re-checking dependencies.
|
|
607
|
+
*
|
|
608
|
+
* Future enhancement: True parallel execution with separate branches
|
|
609
|
+
* and merge handling.
|
|
610
|
+
*
|
|
611
|
+
* @param workflow - The workflow containing the steps
|
|
612
|
+
* @param steps - The ready steps to execute
|
|
613
|
+
*/
|
|
614
|
+
async executeParallel(workflow, steps) {
|
|
615
|
+
// Respect maxConcurrency limit
|
|
616
|
+
const maxConcurrency = workflow.config.maxConcurrency ?? steps.length;
|
|
617
|
+
const toExecute = steps.slice(0, maxConcurrency);
|
|
618
|
+
// Track failed steps for batch failure handling
|
|
619
|
+
const failedSteps = [];
|
|
620
|
+
// Execute steps sequentially within the batch
|
|
621
|
+
// (shared worktree requires sequential commits)
|
|
622
|
+
for (const step of toExecute) {
|
|
623
|
+
// Check for pause/cancel between steps
|
|
624
|
+
const state = this.activeWorkflows.get(workflow.id);
|
|
625
|
+
if (!state || state.isPaused || state.isCancelled) {
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
await this.executeStep(workflow, step);
|
|
630
|
+
// Refresh workflow state to check if step succeeded
|
|
631
|
+
const updatedWorkflow = await this.getWorkflowOrThrow(workflow.id);
|
|
632
|
+
const updatedStep = updatedWorkflow.steps.find((s) => s.id === step.id);
|
|
633
|
+
if (updatedStep?.status === "failed") {
|
|
634
|
+
failedSteps.push(updatedStep);
|
|
635
|
+
// For "stop" strategy, break immediately
|
|
636
|
+
if (workflow.config.onFailure === "stop") {
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
// For "pause" strategy, the workflow is already paused by handleStepFailure
|
|
640
|
+
if (updatedWorkflow.status === "paused") {
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
// Unexpected error during step execution
|
|
647
|
+
console.error(`Error executing step ${step.id}:`, error);
|
|
648
|
+
failedSteps.push(step);
|
|
649
|
+
if (workflow.config.onFailure === "stop") {
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Execute a single workflow step.
|
|
657
|
+
*
|
|
658
|
+
* If the step has a previous execution (from a pause), attempts to resume
|
|
659
|
+
* the session. Otherwise creates a new execution.
|
|
660
|
+
*
|
|
661
|
+
* @param workflow - The workflow containing the step
|
|
662
|
+
* @param step - The step to execute
|
|
663
|
+
*/
|
|
664
|
+
async executeStep(workflow, step) {
|
|
665
|
+
const state = this.activeWorkflows.get(workflow.id);
|
|
666
|
+
// 1. Get issue details for prompt
|
|
667
|
+
const issue = getIssue(this.db, step.issueId);
|
|
668
|
+
if (!issue) {
|
|
669
|
+
throw new Error(`Issue ${step.issueId} not found`);
|
|
670
|
+
}
|
|
671
|
+
// 2. Check if we should resume a previous execution
|
|
672
|
+
let sessionIdToResume;
|
|
673
|
+
let parentExecutionId;
|
|
674
|
+
if (step.executionId) {
|
|
675
|
+
const previousExecution = getExecution(this.db, step.executionId);
|
|
676
|
+
if (previousExecution?.session_id) {
|
|
677
|
+
sessionIdToResume = previousExecution.session_id;
|
|
678
|
+
parentExecutionId = step.executionId; // Link to previous execution
|
|
679
|
+
console.log(`[SequentialWorkflowEngine] Resuming step ${step.id} with session ${sessionIdToResume} (parent: ${parentExecutionId})`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// 3. Build execution config - always use workflow's worktree
|
|
683
|
+
// Workflow-spawned executions run autonomously without terminal interaction
|
|
684
|
+
const config = {
|
|
685
|
+
mode: "worktree",
|
|
686
|
+
baseBranch: workflow.baseBranch,
|
|
687
|
+
createBaseBranch: workflow.config.createBaseBranch,
|
|
688
|
+
reuseWorktreePath: workflow.worktreePath,
|
|
689
|
+
// Workflow step executions run autonomously - must skip permission prompts
|
|
690
|
+
dangerouslySkipPermissions: true,
|
|
691
|
+
// Use workflow's orchestrator model for consistency if available
|
|
692
|
+
model: workflow.config.orchestratorModel,
|
|
693
|
+
// Resume previous session if available
|
|
694
|
+
resume: sessionIdToResume,
|
|
695
|
+
// Link to parent execution for chain tracking
|
|
696
|
+
parentExecutionId,
|
|
697
|
+
};
|
|
698
|
+
// 4. Build prompt - use resume message if resuming, otherwise full prompt
|
|
699
|
+
const prompt = sessionIdToResume
|
|
700
|
+
? "Workflow resumed. Continue where you left off."
|
|
701
|
+
: this.buildPrompt(issue, workflow);
|
|
702
|
+
// 5. Update step status to running
|
|
703
|
+
this.updateStep(workflow.id, step.id, {
|
|
704
|
+
status: "running",
|
|
705
|
+
});
|
|
706
|
+
// 6. Emit step started event
|
|
707
|
+
this.eventEmitter.emit({
|
|
708
|
+
type: "step_started",
|
|
709
|
+
workflowId: workflow.id,
|
|
710
|
+
step: { ...step, status: "running" },
|
|
711
|
+
timestamp: Date.now(),
|
|
712
|
+
});
|
|
713
|
+
// 7. Create execution (will resume session if config.resume is set)
|
|
714
|
+
const execution = await this.executionService.createExecution(step.issueId, config, prompt, workflow.config.defaultAgentType);
|
|
715
|
+
// Track current execution
|
|
716
|
+
if (state) {
|
|
717
|
+
state.currentExecutionId = execution.id;
|
|
718
|
+
}
|
|
719
|
+
// Update step with new execution ID
|
|
720
|
+
this.updateStep(workflow.id, step.id, {
|
|
721
|
+
executionId: execution.id,
|
|
722
|
+
});
|
|
723
|
+
// 8. Wait for execution to complete
|
|
724
|
+
const completedExecution = await this.waitForExecution(execution.id);
|
|
725
|
+
// Clear current execution tracking
|
|
726
|
+
if (state) {
|
|
727
|
+
state.currentExecutionId = undefined;
|
|
728
|
+
}
|
|
729
|
+
// 9. Check if workflow was paused/cancelled during execution
|
|
730
|
+
// If so, don't treat the cancelled execution as a failure
|
|
731
|
+
if (state?.isPaused || state?.isCancelled) {
|
|
732
|
+
console.log(`[SequentialWorkflowEngine] Step ${step.id} execution ended due to workflow ${state.isPaused ? "pause" : "cancel"}, not treating as failure`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// 10. Handle result
|
|
736
|
+
if (completedExecution.status === "completed") {
|
|
737
|
+
await this.handleStepSuccess(workflow, step, completedExecution);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
await this.handleStepFailure(workflow, step, completedExecution);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Wait for an execution to complete by polling.
|
|
745
|
+
*
|
|
746
|
+
* @param executionId - The execution to wait for
|
|
747
|
+
* @returns The completed execution
|
|
748
|
+
*/
|
|
749
|
+
async waitForExecution(executionId) {
|
|
750
|
+
const pollIntervalMs = 1000; // 1 second
|
|
751
|
+
const maxWaitMs = 60 * 60 * 1000; // 1 hour max
|
|
752
|
+
const startTime = Date.now();
|
|
753
|
+
while (true) {
|
|
754
|
+
const execution = getExecution(this.db, executionId);
|
|
755
|
+
if (!execution) {
|
|
756
|
+
throw new Error(`Execution ${executionId} not found`);
|
|
757
|
+
}
|
|
758
|
+
// Check if execution is in a terminal state
|
|
759
|
+
if (execution.status === "completed" ||
|
|
760
|
+
execution.status === "failed" ||
|
|
761
|
+
execution.status === "stopped" ||
|
|
762
|
+
execution.status === "cancelled") {
|
|
763
|
+
return execution;
|
|
764
|
+
}
|
|
765
|
+
// Check timeout
|
|
766
|
+
if (Date.now() - startTime > maxWaitMs) {
|
|
767
|
+
throw new Error(`Execution ${executionId} timed out after ${maxWaitMs}ms`);
|
|
768
|
+
}
|
|
769
|
+
// Wait before next poll
|
|
770
|
+
await this.sleep(pollIntervalMs);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Sleep for a specified duration.
|
|
775
|
+
*/
|
|
776
|
+
sleep(ms) {
|
|
777
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Build the prompt for executing an issue.
|
|
781
|
+
*
|
|
782
|
+
* @param issue - The issue to execute
|
|
783
|
+
* @param workflow - The workflow context
|
|
784
|
+
* @returns The prompt string
|
|
785
|
+
*/
|
|
786
|
+
buildPrompt(issue, workflow) {
|
|
787
|
+
// Build a basic prompt from issue details
|
|
788
|
+
const parts = [];
|
|
789
|
+
parts.push(`# Task: ${issue.title}`);
|
|
790
|
+
parts.push("");
|
|
791
|
+
if (issue.content) {
|
|
792
|
+
parts.push("## Description");
|
|
793
|
+
parts.push(issue.content);
|
|
794
|
+
parts.push("");
|
|
795
|
+
}
|
|
796
|
+
// Add workflow context
|
|
797
|
+
parts.push("## Workflow Context");
|
|
798
|
+
parts.push(`This is step ${workflow.currentStepIndex + 1} of ${workflow.steps.length} in workflow "${workflow.title}".`);
|
|
799
|
+
return parts.join("\n");
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Handle successful step completion.
|
|
803
|
+
*
|
|
804
|
+
* @param workflow - The workflow containing the step
|
|
805
|
+
* @param step - The completed step
|
|
806
|
+
* @param execution - The execution result
|
|
807
|
+
*/
|
|
808
|
+
async handleStepSuccess(workflow, step, execution) {
|
|
809
|
+
// Convert null to undefined for type compatibility
|
|
810
|
+
let commitSha = execution.after_commit ?? undefined;
|
|
811
|
+
// Close the issue BEFORE auto-commit so the status change is included
|
|
812
|
+
// Use worktree path to keep changes isolated until explicit sync
|
|
813
|
+
await this.closeIssue(step.issueId, workflow.worktreePath);
|
|
814
|
+
// Auto-commit if configured and we have a worktree
|
|
815
|
+
// This now includes the issue status change from closeIssue above
|
|
816
|
+
if (workflow.config.autoCommitAfterStep && workflow.worktreePath) {
|
|
817
|
+
const newCommitSha = await this.commitStepChanges(workflow, step);
|
|
818
|
+
if (newCommitSha) {
|
|
819
|
+
commitSha = newCommitSha;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// Update step status
|
|
823
|
+
this.updateStep(workflow.id, step.id, {
|
|
824
|
+
status: "completed",
|
|
825
|
+
commitSha,
|
|
826
|
+
});
|
|
827
|
+
// Emit event
|
|
828
|
+
this.eventEmitter.emit({
|
|
829
|
+
type: "step_completed",
|
|
830
|
+
workflowId: workflow.id,
|
|
831
|
+
step: { ...step, status: "completed", commitSha },
|
|
832
|
+
executionId: execution.id,
|
|
833
|
+
timestamp: Date.now(),
|
|
834
|
+
});
|
|
835
|
+
// Update workflow progress
|
|
836
|
+
this.updateWorkflow(workflow.id, {
|
|
837
|
+
currentStepIndex: workflow.currentStepIndex + 1,
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Commit step changes to git.
|
|
842
|
+
*
|
|
843
|
+
* @param workflow - The workflow containing the step
|
|
844
|
+
* @param step - The completed step
|
|
845
|
+
* @returns The commit SHA if successful, null otherwise
|
|
846
|
+
*/
|
|
847
|
+
async commitStepChanges(workflow, step) {
|
|
848
|
+
if (!workflow.worktreePath) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
const issue = getIssue(this.db, step.issueId);
|
|
852
|
+
const message = this.buildCommitMessage(workflow, step, issue);
|
|
853
|
+
try {
|
|
854
|
+
// Check if there are changes to commit
|
|
855
|
+
const { stdout: status } = await execAsync("git status --porcelain", {
|
|
856
|
+
cwd: workflow.worktreePath,
|
|
857
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
858
|
+
});
|
|
859
|
+
if (!status.trim()) {
|
|
860
|
+
// No changes to commit
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
// Stage all changes and commit
|
|
864
|
+
// Escape double quotes in message for shell safety
|
|
865
|
+
const escapedMessage = message.replace(/"/g, '\\"');
|
|
866
|
+
await execAsync(`git add -A && git commit -m "${escapedMessage}"`, {
|
|
867
|
+
cwd: workflow.worktreePath,
|
|
868
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
869
|
+
});
|
|
870
|
+
// Get new commit SHA
|
|
871
|
+
const { stdout: sha } = await execAsync("git rev-parse HEAD", {
|
|
872
|
+
cwd: workflow.worktreePath,
|
|
873
|
+
});
|
|
874
|
+
return sha.trim();
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
console.error("Failed to commit step changes:", error);
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Build commit message for a step.
|
|
883
|
+
*
|
|
884
|
+
* @param workflow - The workflow
|
|
885
|
+
* @param step - The completed step
|
|
886
|
+
* @param issue - The issue (may be null)
|
|
887
|
+
* @returns The commit message
|
|
888
|
+
*/
|
|
889
|
+
buildCommitMessage(workflow, step, issue) {
|
|
890
|
+
const issueTitle = issue?.title || "Unknown issue";
|
|
891
|
+
const stepNum = step.index + 1;
|
|
892
|
+
const totalSteps = workflow.steps.length;
|
|
893
|
+
return `[Workflow ${stepNum}/${totalSteps}] ${step.issueId}: ${issueTitle}
|
|
894
|
+
|
|
895
|
+
Workflow: ${workflow.title}
|
|
896
|
+
Step: ${stepNum} of ${totalSteps}`;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Update an issue directly in the worktree's JSONL file.
|
|
900
|
+
* This keeps changes isolated to the worktree until explicitly synced.
|
|
901
|
+
*
|
|
902
|
+
* @param worktreePath - Path to the worktree
|
|
903
|
+
* @param issueId - The issue ID to update
|
|
904
|
+
* @param updates - Partial issue updates to apply
|
|
905
|
+
*/
|
|
906
|
+
async updateIssueInWorktree(worktreePath, issueId, updates) {
|
|
907
|
+
const issuesPath = path.join(worktreePath, ".sudocode", "issues.jsonl");
|
|
908
|
+
// Read current issues from worktree JSONL
|
|
909
|
+
const issues = readJSONLSync(issuesPath);
|
|
910
|
+
// Find and update the issue
|
|
911
|
+
const issueIndex = issues.findIndex((issue) => issue.id === issueId);
|
|
912
|
+
if (issueIndex === -1) {
|
|
913
|
+
console.warn(`[SequentialWorkflowEngine] Issue ${issueId} not found in worktree JSONL`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
// Apply updates
|
|
917
|
+
const updatedIssue = {
|
|
918
|
+
...issues[issueIndex],
|
|
919
|
+
...updates,
|
|
920
|
+
updated_at: new Date().toISOString(),
|
|
921
|
+
};
|
|
922
|
+
// Handle closed_at for status changes
|
|
923
|
+
if (updates.status === "closed" && issues[issueIndex].status !== "closed") {
|
|
924
|
+
updatedIssue.closed_at = new Date().toISOString();
|
|
925
|
+
}
|
|
926
|
+
else if (updates.status && updates.status !== "closed" && issues[issueIndex].status === "closed") {
|
|
927
|
+
updatedIssue.closed_at = undefined;
|
|
928
|
+
}
|
|
929
|
+
issues[issueIndex] = updatedIssue;
|
|
930
|
+
// Write back to JSONL
|
|
931
|
+
await writeJSONL(issuesPath, issues);
|
|
932
|
+
console.log(`[SequentialWorkflowEngine] Updated issue ${issueId} in worktree JSONL:`, updates);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Close an issue after successful step completion.
|
|
936
|
+
* If a worktree path is provided, updates the worktree's JSONL directly.
|
|
937
|
+
* Otherwise, updates the main database.
|
|
938
|
+
*
|
|
939
|
+
* @param issueId - The issue ID to close
|
|
940
|
+
* @param worktreePath - Optional path to the worktree for isolated updates
|
|
941
|
+
*/
|
|
942
|
+
async closeIssue(issueId, worktreePath) {
|
|
943
|
+
try {
|
|
944
|
+
if (worktreePath) {
|
|
945
|
+
// Update the worktree's JSONL file directly
|
|
946
|
+
await this.updateIssueInWorktree(worktreePath, issueId, {
|
|
947
|
+
status: "closed",
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
// Fall back to main database
|
|
952
|
+
updateIssue(this.db, issueId, { status: "closed" });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
catch (error) {
|
|
956
|
+
// Non-fatal - log but don't fail
|
|
957
|
+
console.warn(`Failed to close issue ${issueId}:`, error);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Handle step failure.
|
|
962
|
+
*
|
|
963
|
+
* @param workflow - The workflow containing the step
|
|
964
|
+
* @param step - The failed step
|
|
965
|
+
* @param execution - The execution result
|
|
966
|
+
*/
|
|
967
|
+
async handleStepFailure(workflow, step, execution) {
|
|
968
|
+
const errorMessage = execution.error_message || "Unknown error";
|
|
969
|
+
// Update step status
|
|
970
|
+
this.updateStep(workflow.id, step.id, {
|
|
971
|
+
status: "failed",
|
|
972
|
+
error: errorMessage,
|
|
973
|
+
});
|
|
974
|
+
// Emit event
|
|
975
|
+
this.eventEmitter.emit({
|
|
976
|
+
type: "step_failed",
|
|
977
|
+
workflowId: workflow.id,
|
|
978
|
+
step: { ...step, status: "failed", error: errorMessage },
|
|
979
|
+
error: errorMessage,
|
|
980
|
+
timestamp: Date.now(),
|
|
981
|
+
});
|
|
982
|
+
// Handle based on failure strategy
|
|
983
|
+
switch (workflow.config.onFailure) {
|
|
984
|
+
case "stop":
|
|
985
|
+
await this.failWorkflow(workflow.id, `Step ${step.id} failed: ${errorMessage}`);
|
|
986
|
+
break;
|
|
987
|
+
case "pause":
|
|
988
|
+
await this.pauseWorkflow(workflow.id);
|
|
989
|
+
break;
|
|
990
|
+
case "skip_dependents":
|
|
991
|
+
await this.skipDependentSteps(workflow, step, `Dependency ${step.issueId} failed`);
|
|
992
|
+
break;
|
|
993
|
+
case "continue":
|
|
994
|
+
// Mark dependents as blocked, continue with other steps
|
|
995
|
+
await this.blockDependentSteps(workflow, step);
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// ===========================================================================
|
|
1000
|
+
// Helper Methods
|
|
1001
|
+
// ===========================================================================
|
|
1002
|
+
/**
|
|
1003
|
+
* Mark workflow as failed.
|
|
1004
|
+
*/
|
|
1005
|
+
async failWorkflow(workflowId, error) {
|
|
1006
|
+
this.updateWorkflow(workflowId, {
|
|
1007
|
+
status: "failed",
|
|
1008
|
+
completedAt: new Date().toISOString(),
|
|
1009
|
+
});
|
|
1010
|
+
this.eventEmitter.emit({
|
|
1011
|
+
type: "workflow_failed",
|
|
1012
|
+
workflowId,
|
|
1013
|
+
error,
|
|
1014
|
+
timestamp: Date.now(),
|
|
1015
|
+
});
|
|
1016
|
+
this.activeWorkflows.delete(workflowId);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Mark workflow as completed.
|
|
1020
|
+
*/
|
|
1021
|
+
async completeWorkflow(workflowId) {
|
|
1022
|
+
const workflow = await this.getWorkflowOrThrow(workflowId);
|
|
1023
|
+
this.updateWorkflow(workflowId, {
|
|
1024
|
+
status: "completed",
|
|
1025
|
+
completedAt: new Date().toISOString(),
|
|
1026
|
+
});
|
|
1027
|
+
this.eventEmitter.emit({
|
|
1028
|
+
type: "workflow_completed",
|
|
1029
|
+
workflowId,
|
|
1030
|
+
workflow: { ...workflow, status: "completed" },
|
|
1031
|
+
timestamp: Date.now(),
|
|
1032
|
+
});
|
|
1033
|
+
this.activeWorkflows.delete(workflowId);
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Check if workflow is complete (all steps done or skipped).
|
|
1037
|
+
*/
|
|
1038
|
+
isWorkflowComplete(workflow) {
|
|
1039
|
+
return workflow.steps.every((step) => step.status === "completed" ||
|
|
1040
|
+
step.status === "skipped" ||
|
|
1041
|
+
step.status === "blocked");
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Find all steps that transitively depend on a given step.
|
|
1045
|
+
*/
|
|
1046
|
+
findDependentSteps(workflow, stepId) {
|
|
1047
|
+
const dependents = [];
|
|
1048
|
+
const visited = new Set();
|
|
1049
|
+
const queue = [stepId];
|
|
1050
|
+
while (queue.length > 0) {
|
|
1051
|
+
const current = queue.shift();
|
|
1052
|
+
for (const step of workflow.steps) {
|
|
1053
|
+
if (step.dependencies.includes(current) && !visited.has(step.id)) {
|
|
1054
|
+
visited.add(step.id);
|
|
1055
|
+
dependents.push(step);
|
|
1056
|
+
queue.push(step.id);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
return dependents;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Skip all steps that depend on a failed/skipped step.
|
|
1064
|
+
*/
|
|
1065
|
+
async skipDependentSteps(workflow, failedStep, reason) {
|
|
1066
|
+
const dependents = this.findDependentSteps(workflow, failedStep.id);
|
|
1067
|
+
for (const step of dependents) {
|
|
1068
|
+
if (step.status === "pending" || step.status === "ready") {
|
|
1069
|
+
this.updateStep(workflow.id, step.id, {
|
|
1070
|
+
status: "skipped",
|
|
1071
|
+
error: reason,
|
|
1072
|
+
});
|
|
1073
|
+
this.eventEmitter.emit({
|
|
1074
|
+
type: "step_skipped",
|
|
1075
|
+
workflowId: workflow.id,
|
|
1076
|
+
step: { ...step, status: "skipped", error: reason },
|
|
1077
|
+
reason,
|
|
1078
|
+
timestamp: Date.now(),
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Block all steps that depend on a failed step.
|
|
1085
|
+
*/
|
|
1086
|
+
async blockDependentSteps(workflow, failedStep) {
|
|
1087
|
+
const dependents = this.findDependentSteps(workflow, failedStep.id);
|
|
1088
|
+
for (const step of dependents) {
|
|
1089
|
+
if (step.status === "pending" || step.status === "ready") {
|
|
1090
|
+
this.updateStep(workflow.id, step.id, {
|
|
1091
|
+
status: "blocked",
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Unblock steps that were blocked due to a failed dependency.
|
|
1098
|
+
*/
|
|
1099
|
+
async unblockDependentSteps(workflow, retriedStep) {
|
|
1100
|
+
const dependents = this.findDependentSteps(workflow, retriedStep.id);
|
|
1101
|
+
for (const step of dependents) {
|
|
1102
|
+
if (step.status === "blocked") {
|
|
1103
|
+
this.updateStep(workflow.id, step.id, {
|
|
1104
|
+
status: "pending",
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
//# sourceMappingURL=sequential-engine.js.map
|