keystone-cli 1.2.0 → 1.3.0
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/README.md +65 -14
- package/package.json +1 -1
- package/src/commands/init.ts +6 -0
- package/src/db/dynamic-state-manager.test.ts +319 -0
- package/src/db/dynamic-state-manager.ts +411 -0
- package/src/db/workflow-db.ts +64 -0
- package/src/parser/schema.ts +34 -1
- package/src/parser/workflow-parser.test.ts +3 -4
- package/src/parser/workflow-parser.ts +3 -62
- package/src/runner/executors/dynamic-executor.test.ts +613 -0
- package/src/runner/executors/dynamic-executor.ts +718 -0
- package/src/runner/executors/dynamic-types.ts +69 -0
- package/src/runner/step-executor.ts +20 -0
- package/src/templates/dynamic-demo.yaml +31 -0
- package/src/templates/scaffolding/decompose-problem.yaml +1 -1
- package/src/templates/scaffolding/dynamic-decompose.yaml +39 -0
- package/src/utils/topo-sort.ts +47 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic State Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages persistence for dynamic workflow steps, enabling resumability
|
|
5
|
+
* of LLM-orchestrated workflows.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Database } from 'bun:sqlite';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import type {
|
|
11
|
+
DynamicPlan,
|
|
12
|
+
DynamicStepExecution,
|
|
13
|
+
DynamicStepState,
|
|
14
|
+
GeneratedStep,
|
|
15
|
+
} from '../runner/executors/dynamic-types.ts';
|
|
16
|
+
|
|
17
|
+
export type { DynamicPlan, DynamicStepExecution, DynamicStepState, GeneratedStep };
|
|
18
|
+
import type { WorkflowDb } from './workflow-db.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Raw row from dynamic_workflow_state table
|
|
22
|
+
*/
|
|
23
|
+
interface DynamicStateRow {
|
|
24
|
+
id: string;
|
|
25
|
+
run_id: string;
|
|
26
|
+
step_id: string;
|
|
27
|
+
workflow_id: string | null;
|
|
28
|
+
status: string;
|
|
29
|
+
generated_plan: string;
|
|
30
|
+
current_step_index: number;
|
|
31
|
+
started_at: string;
|
|
32
|
+
completed_at: string | null;
|
|
33
|
+
error: string | null;
|
|
34
|
+
metadata: string | null;
|
|
35
|
+
replan_count: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Raw row from dynamic_step_executions table
|
|
40
|
+
*/
|
|
41
|
+
interface DynamicExecRow {
|
|
42
|
+
id: string;
|
|
43
|
+
state_id: string;
|
|
44
|
+
step_id: string;
|
|
45
|
+
step_name: string;
|
|
46
|
+
step_type: string;
|
|
47
|
+
step_definition: string;
|
|
48
|
+
status: string;
|
|
49
|
+
output: string | null;
|
|
50
|
+
error: string | null;
|
|
51
|
+
started_at: string | null;
|
|
52
|
+
completed_at: string | null;
|
|
53
|
+
execution_order: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Manages persistence for dynamic workflow steps
|
|
58
|
+
*/
|
|
59
|
+
export class DynamicStateManager {
|
|
60
|
+
constructor(private db: WorkflowDb) {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Access the underlying database for direct queries
|
|
64
|
+
*/
|
|
65
|
+
private getDatabase(): Database {
|
|
66
|
+
return this.db.getDatabase();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a new dynamic state record
|
|
71
|
+
*/
|
|
72
|
+
async create(params: {
|
|
73
|
+
runId: string;
|
|
74
|
+
stepId: string;
|
|
75
|
+
workflowId?: string;
|
|
76
|
+
}): Promise<DynamicStepState> {
|
|
77
|
+
const id = randomUUID();
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
const state: DynamicStepState = {
|
|
81
|
+
id,
|
|
82
|
+
runId: params.runId,
|
|
83
|
+
stepId: params.stepId,
|
|
84
|
+
workflowId: params.workflowId || params.runId, // Fallback to runId if workflowId not provided
|
|
85
|
+
status: 'planning',
|
|
86
|
+
generatedPlan: { steps: [] },
|
|
87
|
+
currentStepIndex: 0,
|
|
88
|
+
stepResults: new Map(),
|
|
89
|
+
startedAt: now,
|
|
90
|
+
replanCount: 0,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const db = this.getDatabase();
|
|
94
|
+
db.prepare(`
|
|
95
|
+
INSERT INTO dynamic_workflow_state
|
|
96
|
+
(id, run_id, step_id, workflow_id, status, generated_plan, current_step_index, started_at, replan_count)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
98
|
+
`).run(
|
|
99
|
+
id,
|
|
100
|
+
params.runId,
|
|
101
|
+
params.stepId,
|
|
102
|
+
params.workflowId || null,
|
|
103
|
+
'planning',
|
|
104
|
+
JSON.stringify(state.generatedPlan),
|
|
105
|
+
0,
|
|
106
|
+
now,
|
|
107
|
+
0
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return state;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load existing state for resume
|
|
115
|
+
*/
|
|
116
|
+
async load(runId: string, stepId: string): Promise<DynamicStepState | null> {
|
|
117
|
+
const db = this.getDatabase();
|
|
118
|
+
const row = db
|
|
119
|
+
.prepare(`
|
|
120
|
+
SELECT * FROM dynamic_workflow_state
|
|
121
|
+
WHERE run_id = ? AND step_id = ?
|
|
122
|
+
`)
|
|
123
|
+
.get(runId, stepId) as DynamicStateRow | null;
|
|
124
|
+
|
|
125
|
+
if (!row) return null;
|
|
126
|
+
|
|
127
|
+
return this.rowToState(row);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load state by ID
|
|
132
|
+
*/
|
|
133
|
+
async loadById(id: string): Promise<DynamicStepState | null> {
|
|
134
|
+
const db = this.getDatabase();
|
|
135
|
+
const row = db
|
|
136
|
+
.prepare(`
|
|
137
|
+
SELECT * FROM dynamic_workflow_state WHERE id = ?
|
|
138
|
+
`)
|
|
139
|
+
.get(id) as DynamicStateRow | null;
|
|
140
|
+
|
|
141
|
+
if (!row) return null;
|
|
142
|
+
|
|
143
|
+
return this.rowToState(row);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Convert a database row to DynamicStepState
|
|
148
|
+
*/
|
|
149
|
+
private rowToState(row: DynamicStateRow): DynamicStepState {
|
|
150
|
+
return {
|
|
151
|
+
id: row.id,
|
|
152
|
+
runId: row.run_id,
|
|
153
|
+
stepId: row.step_id,
|
|
154
|
+
workflowId: row.workflow_id || row.run_id,
|
|
155
|
+
status: row.status as DynamicStepState['status'],
|
|
156
|
+
generatedPlan: JSON.parse(row.generated_plan),
|
|
157
|
+
currentStepIndex: row.current_step_index,
|
|
158
|
+
stepResults: new Map(),
|
|
159
|
+
startedAt: row.started_at,
|
|
160
|
+
completedAt: row.completed_at || undefined,
|
|
161
|
+
error: row.error || undefined,
|
|
162
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
163
|
+
replanCount: row.replan_count || 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Update state after plan generation
|
|
169
|
+
*/
|
|
170
|
+
async setPlan(
|
|
171
|
+
stateId: string,
|
|
172
|
+
plan: DynamicPlan,
|
|
173
|
+
status: DynamicStepState['status'] = 'executing'
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const db = this.getDatabase();
|
|
176
|
+
const now = new Date().toISOString();
|
|
177
|
+
|
|
178
|
+
// Load current state to get replanCount
|
|
179
|
+
const current = db
|
|
180
|
+
.prepare('SELECT replan_count FROM dynamic_workflow_state WHERE id = ?')
|
|
181
|
+
.get(stateId) as { replan_count: number };
|
|
182
|
+
const replanCount = current?.replan_count || 0;
|
|
183
|
+
|
|
184
|
+
db.prepare(`
|
|
185
|
+
UPDATE dynamic_workflow_state
|
|
186
|
+
SET generated_plan = ?, status = ?, updated_at = ?, replan_count = ?
|
|
187
|
+
WHERE id = ?
|
|
188
|
+
`).run(JSON.stringify(plan), status, now, replanCount, stateId);
|
|
189
|
+
|
|
190
|
+
// Delete previous execution records IF this is a re-plan (optional, but cleaner)
|
|
191
|
+
if (replanCount > 0) {
|
|
192
|
+
db.prepare('DELETE FROM dynamic_step_executions WHERE state_id = ?').run(stateId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Create execution records for each step
|
|
196
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
197
|
+
const step = plan.steps[i];
|
|
198
|
+
db.prepare(`
|
|
199
|
+
INSERT INTO dynamic_step_executions
|
|
200
|
+
(id, state_id, step_id, step_name, step_type, step_definition, status, execution_order)
|
|
201
|
+
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
|
|
202
|
+
`).run(randomUUID(), stateId, step.id, step.name, step.type, JSON.stringify(step), i);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Update status and optionally replan count
|
|
208
|
+
*/
|
|
209
|
+
async updateStatus(
|
|
210
|
+
stateId: string,
|
|
211
|
+
status: DynamicStepState['status'],
|
|
212
|
+
replanCount?: number
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
const db = this.getDatabase();
|
|
215
|
+
const now = new Date().toISOString();
|
|
216
|
+
|
|
217
|
+
if (replanCount !== undefined) {
|
|
218
|
+
db.prepare(`
|
|
219
|
+
UPDATE dynamic_workflow_state
|
|
220
|
+
SET status = ?, replan_count = ?, updated_at = ?
|
|
221
|
+
WHERE id = ?
|
|
222
|
+
`).run(status, replanCount, now, stateId);
|
|
223
|
+
} else {
|
|
224
|
+
db.prepare(`
|
|
225
|
+
UPDATE dynamic_workflow_state
|
|
226
|
+
SET status = ?, updated_at = ?
|
|
227
|
+
WHERE id = ?
|
|
228
|
+
`).run(status, now, stateId);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Mark plan as confirmed and ready for execution
|
|
234
|
+
*/
|
|
235
|
+
async confirmPlan(stateId: string): Promise<void> {
|
|
236
|
+
const db = this.getDatabase();
|
|
237
|
+
const now = new Date().toISOString();
|
|
238
|
+
|
|
239
|
+
db.prepare(`
|
|
240
|
+
UPDATE dynamic_workflow_state
|
|
241
|
+
SET status = 'executing', updated_at = ?
|
|
242
|
+
WHERE id = ?
|
|
243
|
+
`).run(now, stateId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Update current step index (for resume)
|
|
248
|
+
*/
|
|
249
|
+
async updateProgress(stateId: string, stepIndex: number): Promise<void> {
|
|
250
|
+
const db = this.getDatabase();
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
|
|
253
|
+
db.prepare(`
|
|
254
|
+
UPDATE dynamic_workflow_state
|
|
255
|
+
SET current_step_index = ?, updated_at = ?
|
|
256
|
+
WHERE id = ?
|
|
257
|
+
`).run(stepIndex, now, stateId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Record step execution start
|
|
262
|
+
*/
|
|
263
|
+
async startStep(stateId: string, stepId: string): Promise<void> {
|
|
264
|
+
const db = this.getDatabase();
|
|
265
|
+
const now = new Date().toISOString();
|
|
266
|
+
|
|
267
|
+
db.prepare(`
|
|
268
|
+
UPDATE dynamic_step_executions
|
|
269
|
+
SET status = 'running', started_at = ?
|
|
270
|
+
WHERE state_id = ? AND step_id = ?
|
|
271
|
+
`).run(now, stateId, stepId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Record step completion
|
|
276
|
+
*/
|
|
277
|
+
async completeStep(
|
|
278
|
+
stateId: string,
|
|
279
|
+
stepId: string,
|
|
280
|
+
result: { status: string; output?: unknown; error?: string }
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
const db = this.getDatabase();
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
|
|
285
|
+
// Map result.status to our status enum
|
|
286
|
+
let status: string;
|
|
287
|
+
switch (result.status) {
|
|
288
|
+
case 'success':
|
|
289
|
+
status = 'success';
|
|
290
|
+
break;
|
|
291
|
+
case 'failed':
|
|
292
|
+
status = 'failed';
|
|
293
|
+
break;
|
|
294
|
+
case 'skipped':
|
|
295
|
+
status = 'skipped';
|
|
296
|
+
break;
|
|
297
|
+
default:
|
|
298
|
+
status = 'failed';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
db.prepare(`
|
|
302
|
+
UPDATE dynamic_step_executions
|
|
303
|
+
SET status = ?, output = ?, error = ?, completed_at = ?
|
|
304
|
+
WHERE state_id = ? AND step_id = ?
|
|
305
|
+
`).run(
|
|
306
|
+
status,
|
|
307
|
+
result.output !== undefined ? JSON.stringify(result.output) : null,
|
|
308
|
+
result.error || null,
|
|
309
|
+
now,
|
|
310
|
+
stateId,
|
|
311
|
+
stepId
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Mark workflow as completed or failed
|
|
317
|
+
*/
|
|
318
|
+
async finish(stateId: string, status: 'completed' | 'failed', error?: string): Promise<void> {
|
|
319
|
+
const db = this.getDatabase();
|
|
320
|
+
const now = new Date().toISOString();
|
|
321
|
+
|
|
322
|
+
db.prepare(`
|
|
323
|
+
UPDATE dynamic_workflow_state
|
|
324
|
+
SET status = ?, error = ?, completed_at = ?, updated_at = ?
|
|
325
|
+
WHERE id = ?
|
|
326
|
+
`).run(status, error || null, now, now, stateId);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get all step executions for a dynamic state
|
|
331
|
+
*/
|
|
332
|
+
async getStepExecutions(stateId: string): Promise<DynamicStepExecution[]> {
|
|
333
|
+
const db = this.getDatabase();
|
|
334
|
+
const rows = db
|
|
335
|
+
.prepare(`
|
|
336
|
+
SELECT * FROM dynamic_step_executions
|
|
337
|
+
WHERE state_id = ?
|
|
338
|
+
ORDER BY execution_order ASC
|
|
339
|
+
`)
|
|
340
|
+
.all(stateId) as DynamicExecRow[];
|
|
341
|
+
|
|
342
|
+
return rows.map((row) => ({
|
|
343
|
+
id: row.id,
|
|
344
|
+
stateId: row.state_id,
|
|
345
|
+
stepId: row.step_id,
|
|
346
|
+
stepName: row.step_name,
|
|
347
|
+
stepType: row.step_type,
|
|
348
|
+
stepDefinition: JSON.parse(row.step_definition),
|
|
349
|
+
status: row.status as DynamicStepExecution['status'],
|
|
350
|
+
output: row.output ? JSON.parse(row.output) : undefined,
|
|
351
|
+
error: row.error || undefined,
|
|
352
|
+
startedAt: row.started_at || undefined,
|
|
353
|
+
completedAt: row.completed_at || undefined,
|
|
354
|
+
executionOrder: row.execution_order,
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get step results as a Map (for executor integration)
|
|
360
|
+
*/
|
|
361
|
+
async getStepResultsMap(
|
|
362
|
+
stateId: string
|
|
363
|
+
): Promise<Map<string, { output: unknown; status: string; error?: string }>> {
|
|
364
|
+
const executions = await this.getStepExecutions(stateId);
|
|
365
|
+
const map = new Map<string, { output: unknown; status: string; error?: string }>();
|
|
366
|
+
|
|
367
|
+
for (const exec of executions) {
|
|
368
|
+
if (exec.status !== 'pending') {
|
|
369
|
+
map.set(exec.stepId, {
|
|
370
|
+
output: exec.output,
|
|
371
|
+
status: exec.status,
|
|
372
|
+
error: exec.error,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return map;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* List all active (non-completed) dynamic states
|
|
382
|
+
*/
|
|
383
|
+
async listActive(): Promise<DynamicStepState[]> {
|
|
384
|
+
const db = this.getDatabase();
|
|
385
|
+
const rows = db
|
|
386
|
+
.prepare(`
|
|
387
|
+
SELECT * FROM dynamic_workflow_state
|
|
388
|
+
WHERE status IN ('planning', 'executing')
|
|
389
|
+
ORDER BY started_at DESC
|
|
390
|
+
`)
|
|
391
|
+
.all() as DynamicStateRow[];
|
|
392
|
+
|
|
393
|
+
return rows.map((row) => this.rowToState(row));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* List dynamic states by run ID
|
|
398
|
+
*/
|
|
399
|
+
async listByRun(runId: string): Promise<DynamicStepState[]> {
|
|
400
|
+
const db = this.getDatabase();
|
|
401
|
+
const rows = db
|
|
402
|
+
.prepare(`
|
|
403
|
+
SELECT * FROM dynamic_workflow_state
|
|
404
|
+
WHERE run_id = ?
|
|
405
|
+
ORDER BY started_at ASC
|
|
406
|
+
`)
|
|
407
|
+
.all(runId) as DynamicStateRow[];
|
|
408
|
+
|
|
409
|
+
return rows.map((row) => this.rowToState(row));
|
|
410
|
+
}
|
|
411
|
+
}
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -101,6 +101,14 @@ export class DatabaseError extends Error {
|
|
|
101
101
|
export class WorkflowDb {
|
|
102
102
|
private db: Database;
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Access the underlying database instance.
|
|
106
|
+
* This is intended for internal extensions like DynamicStateManager.
|
|
107
|
+
*/
|
|
108
|
+
public getDatabase(): Database {
|
|
109
|
+
return this.db;
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
private createRunStmt!: Statement;
|
|
105
113
|
private updateRunStatusStmt!: Statement;
|
|
106
114
|
private getRunStmt!: Statement;
|
|
@@ -549,6 +557,62 @@ export class WorkflowDb {
|
|
|
549
557
|
PRAGMA user_version = 4;
|
|
550
558
|
`);
|
|
551
559
|
}
|
|
560
|
+
|
|
561
|
+
// Version 5: Add dynamic workflow state tables
|
|
562
|
+
if (version < 5) {
|
|
563
|
+
this.db.exec(`
|
|
564
|
+
-- Table for tracking dynamic step execution state
|
|
565
|
+
CREATE TABLE IF NOT EXISTS dynamic_workflow_state (
|
|
566
|
+
id TEXT PRIMARY KEY,
|
|
567
|
+
run_id TEXT NOT NULL,
|
|
568
|
+
step_id TEXT NOT NULL,
|
|
569
|
+
workflow_id TEXT,
|
|
570
|
+
status TEXT NOT NULL DEFAULT 'planning',
|
|
571
|
+
generated_plan TEXT NOT NULL DEFAULT '{"steps":[]}',
|
|
572
|
+
current_step_index INTEGER DEFAULT 0,
|
|
573
|
+
started_at TEXT NOT NULL,
|
|
574
|
+
completed_at TEXT,
|
|
575
|
+
error TEXT,
|
|
576
|
+
metadata TEXT,
|
|
577
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
578
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
579
|
+
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
580
|
+
UNIQUE (run_id, step_id)
|
|
581
|
+
);
|
|
582
|
+
CREATE INDEX IF NOT EXISTS idx_dynamic_state_run ON dynamic_workflow_state(run_id);
|
|
583
|
+
CREATE INDEX IF NOT EXISTS idx_dynamic_state_status ON dynamic_workflow_state(status);
|
|
584
|
+
|
|
585
|
+
-- Table for individual generated step executions
|
|
586
|
+
CREATE TABLE IF NOT EXISTS dynamic_step_executions (
|
|
587
|
+
id TEXT PRIMARY KEY,
|
|
588
|
+
state_id TEXT NOT NULL,
|
|
589
|
+
step_id TEXT NOT NULL,
|
|
590
|
+
step_name TEXT NOT NULL,
|
|
591
|
+
step_type TEXT NOT NULL,
|
|
592
|
+
step_definition TEXT NOT NULL,
|
|
593
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
594
|
+
output TEXT,
|
|
595
|
+
error TEXT,
|
|
596
|
+
started_at TEXT,
|
|
597
|
+
completed_at TEXT,
|
|
598
|
+
execution_order INTEGER NOT NULL,
|
|
599
|
+
FOREIGN KEY (state_id) REFERENCES dynamic_workflow_state(id) ON DELETE CASCADE,
|
|
600
|
+
UNIQUE (state_id, step_id)
|
|
601
|
+
);
|
|
602
|
+
CREATE INDEX IF NOT EXISTS idx_dynamic_exec_state ON dynamic_step_executions(state_id);
|
|
603
|
+
CREATE INDEX IF NOT EXISTS idx_dynamic_exec_order ON dynamic_step_executions(state_id, execution_order);
|
|
604
|
+
|
|
605
|
+
PRAGMA user_version = 5;
|
|
606
|
+
`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Version 6: Add replan_count to dynamic_workflow_state
|
|
610
|
+
if (version < 6) {
|
|
611
|
+
this.db.exec(`
|
|
612
|
+
ALTER TABLE dynamic_workflow_state ADD COLUMN replan_count INTEGER DEFAULT 0;
|
|
613
|
+
PRAGMA user_version = 6;
|
|
614
|
+
`);
|
|
615
|
+
}
|
|
552
616
|
}
|
|
553
617
|
|
|
554
618
|
private initSchema(): void {
|
package/src/parser/schema.ts
CHANGED
|
@@ -415,6 +415,35 @@ const WaitStepSchema = BaseStepSchema.extend({
|
|
|
415
415
|
// timeout is already in BaseStepSchema, but let's make it explicit here if needed
|
|
416
416
|
});
|
|
417
417
|
|
|
418
|
+
const DynamicStepSchema = BaseStepSchema.extend({
|
|
419
|
+
type: z.literal('dynamic'),
|
|
420
|
+
goal: z.string(), // The high-level goal to accomplish
|
|
421
|
+
context: z.string().optional(), // Additional context for the supervisor
|
|
422
|
+
prompt: z.string().optional(), // Custom supervisor prompt (overrides default)
|
|
423
|
+
supervisor: z.string().optional(), // Supervisor agent (defaults to agent or keystone-architect)
|
|
424
|
+
agent: z.string().optional().default('keystone-architect'), // Default agent for generated steps
|
|
425
|
+
provider: z.string().optional(),
|
|
426
|
+
model: z.string().optional(),
|
|
427
|
+
templates: z.record(z.string()).optional(), // Role -> Agent mapping, e.g., { "planner": "@org-planner" }
|
|
428
|
+
maxSteps: z.number().int().positive().default(20), // Max steps the supervisor can generate
|
|
429
|
+
maxIterations: z.number().int().positive().default(5), // Max LLM iterations for planning
|
|
430
|
+
allowStepFailure: z.boolean().optional().default(false), // Continue on individual step failure
|
|
431
|
+
stateFile: z.string().optional(), // Path to persist workflow state (for external tools)
|
|
432
|
+
concurrency: z.union([z.number().int().positive(), z.string()]).optional().default(1), // Max parallel steps
|
|
433
|
+
library: z
|
|
434
|
+
.array(
|
|
435
|
+
z.object({
|
|
436
|
+
name: z.string(),
|
|
437
|
+
description: z.string(),
|
|
438
|
+
steps: z.array(z.any()), // Pre-defined steps in this pattern
|
|
439
|
+
})
|
|
440
|
+
)
|
|
441
|
+
.optional(), // Library of pre-defined step patterns
|
|
442
|
+
confirmPlan: z.boolean().optional().default(false), // Review and approve plan before execution
|
|
443
|
+
maxReplans: z.number().int().nonnegative().default(3), // Max automatic recovery attempts
|
|
444
|
+
allowInsecure: z.boolean().optional(), // Allow generated steps to use insecure commands (e.g. shell redirects)
|
|
445
|
+
});
|
|
446
|
+
|
|
418
447
|
// ===== Discriminated Union for Steps =====
|
|
419
448
|
|
|
420
449
|
export const StepSchema: z.ZodType<any> = z.lazy(() =>
|
|
@@ -435,6 +464,7 @@ export const StepSchema: z.ZodType<any> = z.lazy(() =>
|
|
|
435
464
|
ArtifactStepSchema as any,
|
|
436
465
|
WaitStepSchema as any,
|
|
437
466
|
GitStepSchema as any,
|
|
467
|
+
DynamicStepSchema as any,
|
|
438
468
|
])
|
|
439
469
|
);
|
|
440
470
|
|
|
@@ -518,7 +548,8 @@ export type Step =
|
|
|
518
548
|
| z.infer<typeof BlueprintStepSchema>
|
|
519
549
|
| z.infer<typeof ArtifactStepSchema>
|
|
520
550
|
| z.infer<typeof WaitStepSchema>
|
|
521
|
-
| z.infer<typeof GitStepSchema
|
|
551
|
+
| z.infer<typeof GitStepSchema>
|
|
552
|
+
| z.infer<typeof DynamicStepSchema>;
|
|
522
553
|
|
|
523
554
|
export type ShellStep = z.infer<typeof ShellStepSchema>;
|
|
524
555
|
export type LlmStep = z.infer<typeof LlmStepSchema>;
|
|
@@ -539,6 +570,7 @@ export type Blueprint = z.infer<typeof BlueprintSchema>;
|
|
|
539
570
|
export type Workflow = z.infer<typeof WorkflowSchema>;
|
|
540
571
|
export type AgentTool = z.infer<typeof AgentToolSchema>;
|
|
541
572
|
export type WaitStep = z.infer<typeof WaitStepSchema>;
|
|
573
|
+
export type DynamicStep = z.infer<typeof DynamicStepSchema>;
|
|
542
574
|
|
|
543
575
|
// ===== Helper Schemas =====
|
|
544
576
|
export {
|
|
@@ -565,5 +597,6 @@ export {
|
|
|
565
597
|
MemoryStepSchema,
|
|
566
598
|
ArtifactStepSchema,
|
|
567
599
|
GitStepSchema,
|
|
600
|
+
DynamicStepSchema,
|
|
568
601
|
};
|
|
569
602
|
export type Agent = z.infer<typeof AgentSchema>;
|
|
@@ -56,13 +56,12 @@ describe('WorkflowParser', () => {
|
|
|
56
56
|
expect(() => WorkflowParser.topologicalSort(workflow)).toThrow(/circular dependency/i);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test('should throw on missing dependencies', () => {
|
|
59
|
+
test('should NOT throw on missing dependencies (leniency for partial execution)', () => {
|
|
60
60
|
const workflow = {
|
|
61
61
|
steps: [{ id: 'step1', type: 'shell', run: 'echo 1', needs: ['non-existent'] }],
|
|
62
62
|
} as unknown as Workflow;
|
|
63
|
-
expect(() => WorkflowParser.topologicalSort(workflow)).toThrow(
|
|
64
|
-
|
|
65
|
-
);
|
|
63
|
+
expect(() => WorkflowParser.topologicalSort(workflow)).not.toThrow();
|
|
64
|
+
expect(WorkflowParser.topologicalSort(workflow)).toEqual(['step1']);
|
|
66
65
|
});
|
|
67
66
|
});
|
|
68
67
|
|
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
5
5
|
import { ResourceLoader } from '../utils/resource-loader.ts';
|
|
6
6
|
import { validateJsonSchemaDefinition } from '../utils/schema-validator.ts';
|
|
7
|
+
import { topologicalSort } from '../utils/topo-sort.ts';
|
|
7
8
|
import { resolveAgentPath } from './agent-parser.ts';
|
|
8
9
|
import { type Workflow, WorkflowSchema } from './schema.ts';
|
|
9
10
|
|
|
@@ -298,68 +299,8 @@ export class WorkflowParser {
|
|
|
298
299
|
* Returns steps in execution order
|
|
299
300
|
*/
|
|
300
301
|
static topologicalSort(workflow: Workflow): string[] {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// Validate all dependencies exist before sorting
|
|
305
|
-
for (const step of workflow.steps) {
|
|
306
|
-
const needs = step.needs || [];
|
|
307
|
-
for (const dep of needs) {
|
|
308
|
-
if (!stepMap.has(dep)) {
|
|
309
|
-
throw new Error(`Step "${step.id}" depends on non-existent step "${dep}"`);
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Calculate in-degree
|
|
315
|
-
// In-degree = number of dependencies a step has
|
|
316
|
-
for (const step of workflow.steps) {
|
|
317
|
-
const needs = step.needs || [];
|
|
318
|
-
inDegree.set(step.id, needs.length);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Build reverse dependency map for O(1) lookups instead of O(n)
|
|
322
|
-
const dependents = new Map<string, string[]>();
|
|
323
|
-
for (const step of workflow.steps) {
|
|
324
|
-
const needs = step.needs || [];
|
|
325
|
-
for (const dep of needs) {
|
|
326
|
-
if (!dependents.has(dep)) dependents.set(dep, []);
|
|
327
|
-
dependents.get(dep)?.push(step.id);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Kahn's algorithm
|
|
332
|
-
const queue: string[] = [];
|
|
333
|
-
const result: string[] = [];
|
|
334
|
-
|
|
335
|
-
// Add all nodes with in-degree 0
|
|
336
|
-
for (const [stepId, degree] of inDegree.entries()) {
|
|
337
|
-
if (degree === 0) {
|
|
338
|
-
queue.push(stepId);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
let queueIndex = 0;
|
|
343
|
-
while (queueIndex < queue.length) {
|
|
344
|
-
const stepId = queue[queueIndex];
|
|
345
|
-
queueIndex += 1;
|
|
346
|
-
result.push(stepId);
|
|
347
|
-
|
|
348
|
-
// Find all steps that depend on this step (O(1) lookup)
|
|
349
|
-
for (const dependentId of dependents.get(stepId) || []) {
|
|
350
|
-
const newDegree = (inDegree.get(dependentId) || 0) - 1;
|
|
351
|
-
inDegree.set(dependentId, newDegree);
|
|
352
|
-
if (newDegree === 0) {
|
|
353
|
-
queue.push(dependentId);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
if (result.length !== workflow.steps.length) {
|
|
359
|
-
throw new Error('Topological sort failed - circular dependency detected');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return result;
|
|
302
|
+
const sorted = topologicalSort(workflow.steps);
|
|
303
|
+
return sorted.map((s) => s.id);
|
|
363
304
|
}
|
|
364
305
|
|
|
365
306
|
/**
|