keystone-cli 0.8.0 → 1.0.1

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.
Files changed (105) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +809 -90
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +469 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +489 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-integration.test.ts +36 -36
  44. package/src/runner/standard-tools.test.ts +18 -0
  45. package/src/runner/standard-tools.ts +110 -37
  46. package/src/runner/step-executor.test.ts +176 -16
  47. package/src/runner/step-executor.ts +530 -86
  48. package/src/runner/stream-utils.test.ts +14 -0
  49. package/src/runner/subflow-outputs.test.ts +103 -0
  50. package/src/runner/test-harness.ts +161 -0
  51. package/src/runner/tool-integration.test.ts +73 -79
  52. package/src/runner/workflow-runner.test.ts +492 -15
  53. package/src/runner/workflow-runner.ts +1438 -79
  54. package/src/runner/workflow-subflows.test.ts +255 -0
  55. package/src/templates/agents/keystone-architect.md +19 -14
  56. package/src/templates/agents/tester.md +21 -0
  57. package/src/templates/batch-processor.yaml +1 -1
  58. package/src/templates/child-rollback.yaml +11 -0
  59. package/src/templates/decompose-implement.yaml +53 -0
  60. package/src/templates/decompose-problem.yaml +159 -0
  61. package/src/templates/decompose-research.yaml +52 -0
  62. package/src/templates/decompose-review.yaml +51 -0
  63. package/src/templates/dev.yaml +134 -0
  64. package/src/templates/engine-example.yaml +33 -0
  65. package/src/templates/fan-out-fan-in.yaml +61 -0
  66. package/src/templates/loop-parallel.yaml +1 -1
  67. package/src/templates/memory-service.yaml +1 -1
  68. package/src/templates/parent-rollback.yaml +16 -0
  69. package/src/templates/robust-automation.yaml +1 -1
  70. package/src/templates/scaffold-feature.yaml +29 -27
  71. package/src/templates/scaffold-generate.yaml +41 -0
  72. package/src/templates/scaffold-plan.yaml +53 -0
  73. package/src/types/status.ts +3 -0
  74. package/src/ui/dashboard.tsx +4 -3
  75. package/src/utils/assets.macro.ts +36 -0
  76. package/src/utils/auth-manager.ts +585 -8
  77. package/src/utils/blueprint-utils.test.ts +49 -0
  78. package/src/utils/blueprint-utils.ts +80 -0
  79. package/src/utils/circuit-breaker.test.ts +177 -0
  80. package/src/utils/circuit-breaker.ts +160 -0
  81. package/src/utils/config-loader.test.ts +100 -13
  82. package/src/utils/config-loader.ts +44 -17
  83. package/src/utils/constants.ts +62 -0
  84. package/src/utils/error-renderer.test.ts +267 -0
  85. package/src/utils/error-renderer.ts +320 -0
  86. package/src/utils/json-parser.test.ts +4 -0
  87. package/src/utils/json-parser.ts +18 -1
  88. package/src/utils/mermaid.ts +4 -0
  89. package/src/utils/paths.test.ts +46 -0
  90. package/src/utils/paths.ts +70 -0
  91. package/src/utils/process-sandbox.test.ts +128 -0
  92. package/src/utils/process-sandbox.ts +293 -0
  93. package/src/utils/rate-limiter.test.ts +143 -0
  94. package/src/utils/rate-limiter.ts +221 -0
  95. package/src/utils/redactor.test.ts +23 -15
  96. package/src/utils/redactor.ts +65 -25
  97. package/src/utils/resource-loader.test.ts +54 -0
  98. package/src/utils/resource-loader.ts +158 -0
  99. package/src/utils/sandbox.test.ts +69 -4
  100. package/src/utils/sandbox.ts +69 -6
  101. package/src/utils/schema-validator.ts +65 -0
  102. package/src/utils/workflow-registry.test.ts +57 -0
  103. package/src/utils/workflow-registry.ts +45 -25
  104. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  105. /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
@@ -1,8 +1,9 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
1
  import { dirname, join } from 'node:path';
3
2
  import * as yaml from 'js-yaml';
4
3
  import { z } from 'zod';
5
4
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
5
+ import { ResourceLoader } from '../utils/resource-loader.ts';
6
+ import { validateJsonSchemaDefinition } from '../utils/schema-validator.ts';
6
7
  import { resolveAgentPath } from './agent-parser.ts';
7
8
  import { type Workflow, WorkflowSchema } from './schema.ts';
8
9
 
@@ -12,8 +13,12 @@ export class WorkflowParser {
12
13
  */
13
14
  static loadWorkflow(path: string): Workflow {
14
15
  try {
15
- const content = readFileSync(path, 'utf-8');
16
+ const content = ResourceLoader.readFile(path);
17
+ if (content === null) {
18
+ throw new Error(`Workflow file not found at ${path}`);
19
+ }
16
20
  const raw = yaml.load(content);
21
+ WorkflowParser.normalizeAliases(raw);
17
22
  const workflow = WorkflowSchema.parse(raw);
18
23
  const workflowDir = dirname(path);
19
24
 
@@ -26,6 +31,9 @@ export class WorkflowParser {
26
31
  // Validate agents exist
27
32
  WorkflowParser.validateAgents(workflow, workflowDir);
28
33
 
34
+ // Validate errors block
35
+ WorkflowParser.validateErrors(workflow);
36
+
29
37
  // Validate finally block
30
38
  WorkflowParser.validateFinally(workflow);
31
39
 
@@ -44,11 +52,36 @@ export class WorkflowParser {
44
52
  }
45
53
  }
46
54
 
55
+ /**
56
+ * Normalize legacy or alias field names before schema validation.
57
+ */
58
+ private static normalizeAliases(value: unknown): void {
59
+ if (!value || typeof value !== 'object') return;
60
+ if (Array.isArray(value)) {
61
+ for (const item of value) {
62
+ WorkflowParser.normalizeAliases(item);
63
+ }
64
+ return;
65
+ }
66
+
67
+ const record = value as Record<string, unknown>;
68
+ if ('autoHeal' in record && !('auto_heal' in record)) {
69
+ record.auto_heal = record.autoHeal;
70
+ }
71
+ if ('autoHeal' in record) {
72
+ record.autoHeal = undefined;
73
+ }
74
+
75
+ for (const child of Object.values(record)) {
76
+ WorkflowParser.normalizeAliases(child);
77
+ }
78
+ }
79
+
47
80
  /**
48
81
  * Automatically detect step dependencies from expressions
49
82
  */
50
83
  private static resolveImplicitDependencies(workflow: Workflow): void {
51
- const allSteps = [...workflow.steps, ...(workflow.finally || [])];
84
+ const allSteps = [...workflow.steps, ...(workflow.errors || []), ...(workflow.finally || [])];
52
85
  for (const step of allSteps) {
53
86
  const detected = new Set<string>();
54
87
 
@@ -127,7 +160,7 @@ export class WorkflowParser {
127
160
  * Validate that all agents referenced in LLM steps exist
128
161
  */
129
162
  private static validateAgents(workflow: Workflow, baseDir?: string): void {
130
- const allSteps = [...workflow.steps, ...(workflow.finally || [])];
163
+ const allSteps = [...workflow.steps, ...(workflow.errors || []), ...(workflow.finally || [])];
131
164
  for (const step of allSteps) {
132
165
  if (step.type === 'llm') {
133
166
  try {
@@ -168,6 +201,39 @@ export class WorkflowParser {
168
201
  }
169
202
  }
170
203
 
204
+ /**
205
+ * Validate errors block
206
+ */
207
+ private static validateErrors(workflow: Workflow): void {
208
+ if (!workflow.errors) return;
209
+
210
+ const mainStepIds = new Set(workflow.steps.map((s) => s.id));
211
+ const errorsStepIds = new Set<string>();
212
+ const finallyStepIds = new Set((workflow.finally || []).map((s) => s.id));
213
+
214
+ for (const step of workflow.errors) {
215
+ if (mainStepIds.has(step.id)) {
216
+ throw new Error(`Step ID "${step.id}" in errors block conflicts with main steps`);
217
+ }
218
+ if (finallyStepIds.has(step.id)) {
219
+ throw new Error(`Step ID "${step.id}" in errors block conflicts with finally steps`);
220
+ }
221
+ if (errorsStepIds.has(step.id)) {
222
+ throw new Error(`Duplicate Step ID "${step.id}" in errors block`);
223
+ }
224
+ errorsStepIds.add(step.id);
225
+
226
+ // Errors steps can only depend on main steps or previous errors steps
227
+ for (const dep of step.needs) {
228
+ if (!mainStepIds.has(dep) && !errorsStepIds.has(dep)) {
229
+ throw new Error(
230
+ `Errors step "${step.id}" depends on non-existent step "${dep}". Errors steps can only depend on main steps or previous errors steps.`
231
+ );
232
+ }
233
+ }
234
+ }
235
+ }
236
+
171
237
  /**
172
238
  * Perform topological sort on steps
173
239
  * Returns steps in execution order
@@ -211,9 +277,10 @@ export class WorkflowParser {
211
277
  }
212
278
  }
213
279
 
214
- while (queue.length > 0) {
215
- const stepId = queue.shift();
216
- if (!stepId) continue;
280
+ let queueIndex = 0;
281
+ while (queueIndex < queue.length) {
282
+ const stepId = queue[queueIndex];
283
+ queueIndex += 1;
217
284
  result.push(stepId);
218
285
 
219
286
  // Find all steps that depend on this step (O(1) lookup)
@@ -232,4 +299,83 @@ export class WorkflowParser {
232
299
 
233
300
  return result;
234
301
  }
302
+
303
+ /**
304
+ * Strict validation for schema definitions and enums.
305
+ */
306
+ static validateStrict(workflow: Workflow, source?: string): void {
307
+ const errors: string[] = [];
308
+
309
+ const locateSchema = (
310
+ stepId: string,
311
+ field: 'inputSchema' | 'outputSchema'
312
+ ): { line: number; column: number } | null => {
313
+ if (!source) return null;
314
+ const lines = source.split('\n');
315
+ const escaped = stepId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
316
+ const inlineId = new RegExp(`^\\s*-\\s*id:\\s*['"]?${escaped}['"]?\\s*(#.*)?$`);
317
+ const idLine = new RegExp(`^\\s*id:\\s*['"]?${escaped}['"]?\\s*(#.*)?$`);
318
+
319
+ let inStep = false;
320
+ let stepIndent = 0;
321
+
322
+ for (let i = 0; i < lines.length; i++) {
323
+ const line = lines[i];
324
+ const trimmed = line.trim();
325
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
326
+
327
+ if (!inStep) {
328
+ if (inlineId.test(line) || idLine.test(line)) {
329
+ inStep = true;
330
+ stepIndent = indent;
331
+ }
332
+ continue;
333
+ }
334
+
335
+ if (trimmed.startsWith('- ') && indent <= stepIndent) {
336
+ inStep = false;
337
+ if (inlineId.test(line) || idLine.test(line)) {
338
+ inStep = true;
339
+ stepIndent = indent;
340
+ }
341
+ continue;
342
+ }
343
+
344
+ if (trimmed.startsWith(`${field}:`)) {
345
+ const column = line.indexOf(field) + 1;
346
+ return { line: i + 1, column: column > 0 ? column : 1 };
347
+ }
348
+ }
349
+
350
+ return null;
351
+ };
352
+
353
+ const allSteps = [...workflow.steps, ...(workflow.errors || []), ...(workflow.finally || [])];
354
+ for (const step of allSteps) {
355
+ if (step.inputSchema) {
356
+ const result = validateJsonSchemaDefinition(step.inputSchema);
357
+ if (!result.valid) {
358
+ const location = locateSchema(step.id, 'inputSchema');
359
+ const locSuffix = location
360
+ ? ` (at line ${location.line}, column ${location.column})`
361
+ : '';
362
+ errors.push(`step "${step.id}" inputSchema${locSuffix}: ${result.error}`);
363
+ }
364
+ }
365
+ if (step.outputSchema) {
366
+ const result = validateJsonSchemaDefinition(step.outputSchema);
367
+ if (!result.valid) {
368
+ const location = locateSchema(step.id, 'outputSchema');
369
+ const locSuffix = location
370
+ ? ` (at line ${location.line}, column ${location.column})`
371
+ : '';
372
+ errors.push(`step "${step.id}" outputSchema${locSuffix}: ${result.error}`);
373
+ }
374
+ }
375
+ }
376
+
377
+ if (errors.length > 0) {
378
+ throw new Error(`Strict validation failed:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
379
+ }
380
+ }
235
381
  }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { AggregateWorkflowError } from './aggregate-error';
3
+
4
+ describe('AggregateWorkflowError', () => {
5
+ it('should create with multiple errors', () => {
6
+ const errors = [new Error('Error 1'), new Error('Error 2'), new Error('Error 3')];
7
+ const aggregate = new AggregateWorkflowError('test-step', errors);
8
+
9
+ expect(aggregate.name).toBe('AggregateWorkflowError');
10
+ expect(aggregate.stepId).toBe('test-step');
11
+ expect(aggregate.errors).toHaveLength(3);
12
+ expect(aggregate.count).toBe(3);
13
+ });
14
+
15
+ it('should format message with all errors', () => {
16
+ const errors = [new Error('First error'), new Error('Second error')];
17
+ const aggregate = new AggregateWorkflowError('my-step', errors);
18
+
19
+ expect(aggregate.message).toContain('my-step');
20
+ expect(aggregate.message).toContain('2 error(s)');
21
+ expect(aggregate.message).toContain('[1] First error');
22
+ expect(aggregate.message).toContain('[2] Second error');
23
+ });
24
+
25
+ it('should return first error', () => {
26
+ const first = new Error('First');
27
+ const errors = [first, new Error('Second')];
28
+ const aggregate = new AggregateWorkflowError('step', errors);
29
+
30
+ expect(aggregate.firstError).toBe(first);
31
+ });
32
+
33
+ it('should return undefined for empty errors', () => {
34
+ const aggregate = new AggregateWorkflowError('step', []);
35
+ expect(aggregate.firstError).toBeUndefined();
36
+ });
37
+
38
+ it('should check if all errors are of specific type', () => {
39
+ class CustomError extends Error {}
40
+ const errors = [new CustomError('a'), new CustomError('b')];
41
+ const aggregate = new AggregateWorkflowError('step', errors);
42
+
43
+ expect(aggregate.allOfType(CustomError)).toBe(true);
44
+ expect(aggregate.allOfType(TypeError)).toBe(false);
45
+ });
46
+
47
+ it('should filter errors by type', () => {
48
+ class CustomError extends Error {}
49
+ const custom = new CustomError('custom');
50
+ const errors = [custom, new Error('regular'), new CustomError('another')];
51
+ const aggregate = new AggregateWorkflowError('step', errors);
52
+
53
+ const customErrors = aggregate.ofType(CustomError);
54
+ expect(customErrors).toHaveLength(2);
55
+ expect(customErrors[0]).toBe(custom);
56
+ });
57
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Aggregate workflow error that collects multiple errors from parallel execution.
3
+ *
4
+ * This allows capturing all failures from a foreach loop or parallel workflow
5
+ * execution rather than failing on the first error.
6
+ */
7
+ export class AggregateWorkflowError extends Error {
8
+ readonly errors: Error[];
9
+ readonly stepId: string;
10
+
11
+ constructor(stepId: string, errors: Error[]) {
12
+ const messages = errors.map((e, i) => ` [${i + 1}] ${e.message}`).join('\n');
13
+ super(`Step ${stepId} failed with ${errors.length} error(s):\n${messages}`);
14
+ this.name = 'AggregateWorkflowError';
15
+ this.stepId = stepId;
16
+ this.errors = errors;
17
+ }
18
+
19
+ /**
20
+ * Get the first error in the collection.
21
+ */
22
+ get firstError(): Error | undefined {
23
+ return this.errors[0];
24
+ }
25
+
26
+ /**
27
+ * Get the count of errors.
28
+ */
29
+ get count(): number {
30
+ return this.errors.length;
31
+ }
32
+
33
+ /**
34
+ * Check if all errors are of a specific type.
35
+ */
36
+ allOfType<T extends Error>(errorClass: new (...args: unknown[]) => T): boolean {
37
+ return this.errors.every((e) => e instanceof errorClass);
38
+ }
39
+
40
+ /**
41
+ * Filter errors by type.
42
+ */
43
+ ofType<T extends Error>(errorClass: new (...args: unknown[]) => T): T[] {
44
+ return this.errors.filter((e) => e instanceof errorClass) as T[];
45
+ }
46
+ }
@@ -45,14 +45,14 @@ describe('Audit Fixes Verification', () => {
45
45
  // The sandbox now uses node:vm directly with security warnings.
46
46
  SafeSandbox.resetWarning();
47
47
  const code = '1 + 1';
48
- const result = await SafeSandbox.execute(code, {});
48
+ const result = await SafeSandbox.execute(code, {}, { useProcessIsolation: false });
49
49
  expect(result).toBe(2);
50
50
  });
51
51
 
52
52
  it('should show security warning on first execution', async () => {
53
53
  SafeSandbox.resetWarning();
54
54
  const code = '2 + 2';
55
- const result = await SafeSandbox.execute(code, {});
55
+ const result = await SafeSandbox.execute(code, {}, { useProcessIsolation: false });
56
56
  expect(result).toBe(4);
57
57
  // Warning is shown to stderr, we just verify execution works
58
58
  });
@@ -31,7 +31,7 @@ describe('WorkflowRunner Auto-Heal', () => {
31
31
 
32
32
  // biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
33
33
  const db = (runner as any).db;
34
- await db.createRun(runner.getRunId(), workflow.name, {});
34
+ await db.createRun(runner.runId, workflow.name, {});
35
35
 
36
36
  const spy = jest.spyOn(StepExecutor, 'executeStep');
37
37
 
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import type { ExpressionContext } from '../expression/evaluator';
5
+ import type { Blueprint, BlueprintStep, Step } from '../parser/schema';
6
+ import type { Logger } from '../utils/logger';
7
+ import { executeBlueprintStep } from './blueprint-executor';
8
+ import type { executeLlmStep } from './llm-executor';
9
+ import type { StepResult } from './step-executor';
10
+
11
+ describe('BlueprintExecutor', () => {
12
+ const tempDir = path.join(process.cwd(), '.tmp-blueprint-test');
13
+
14
+ it('should generate and persist a blueprint', async () => {
15
+ mkdirSync(tempDir, { recursive: true });
16
+
17
+ const mockStep: BlueprintStep = {
18
+ id: 'test_blueprint',
19
+ type: 'blueprint',
20
+ prompt: 'Build a todo app',
21
+ needs: [],
22
+ agent: 'keystone-architect',
23
+ };
24
+
25
+ const mockBlueprint: Blueprint = {
26
+ architecture: { description: 'Todo Architecture' },
27
+ files: [{ path: 'todo.ts', purpose: 'logic' }],
28
+ };
29
+
30
+ const mockExecuteLlmStep = mock(async () => ({
31
+ status: 'success',
32
+ output: mockBlueprint,
33
+ usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
34
+ })) as unknown as typeof executeLlmStep;
35
+
36
+ const mockExecuteStep = mock(async () => ({ status: 'success', output: null }) as StepResult);
37
+
38
+ const context: ExpressionContext = { steps: {}, inputs: {}, env: {}, secrets: {} };
39
+ const logger: Logger = {
40
+ log: () => {},
41
+ error: () => {},
42
+ warn: () => {},
43
+ info: () => {},
44
+ };
45
+
46
+ try {
47
+ const result = await executeBlueprintStep(mockStep, context, mockExecuteStep, logger, {
48
+ artifactRoot: tempDir,
49
+ runId: 'test-run',
50
+ executeLlmStep: mockExecuteLlmStep,
51
+ });
52
+
53
+ expect(result.status).toBe('success');
54
+ expect(result.output).toMatchObject(mockBlueprint);
55
+ const output = result.output as Blueprint & { __hash: string; __artifactPath: string };
56
+ expect(output.__hash).toBeDefined();
57
+
58
+ expect(existsSync(output.__artifactPath)).toBe(true);
59
+ } finally {
60
+ rmSync(tempDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
@@ -0,0 +1,157 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { ExpressionContext } from '../expression/evaluator.ts';
4
+ import type { Blueprint, BlueprintStep, LlmStep, Step } from '../parser/schema.ts';
5
+ import { BlueprintUtils } from '../utils/blueprint-utils.ts';
6
+ import type { Logger } from '../utils/logger.ts';
7
+ import { executeLlmStep } from './llm-executor.ts';
8
+ import type { MCPManager } from './mcp-manager.ts';
9
+ import type { StepResult } from './step-executor.ts';
10
+
11
+ /**
12
+ * Execute a blueprint step
13
+ */
14
+ export async function executeBlueprintStep(
15
+ step: BlueprintStep,
16
+ context: ExpressionContext,
17
+ executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
18
+ logger: Logger,
19
+ options: {
20
+ mcpManager?: MCPManager;
21
+ workflowDir?: string;
22
+ abortSignal?: AbortSignal;
23
+ runId?: string;
24
+ artifactRoot?: string;
25
+ executeLlmStep?: typeof executeLlmStep;
26
+ }
27
+ ): Promise<StepResult> {
28
+ const {
29
+ mcpManager,
30
+ workflowDir,
31
+ abortSignal,
32
+ runId,
33
+ artifactRoot,
34
+ executeLlmStep: injected,
35
+ } = options;
36
+ const runLlmStep = injected || executeLlmStep;
37
+
38
+ // 1. Create a virtual LLM step to generate the blueprint
39
+ // We reuse the BlueprintSchema as the outputSchema for validation
40
+ const llmStep: LlmStep = {
41
+ id: `${step.id}_generation`,
42
+ type: 'llm',
43
+ agent: step.agent || 'keystone-architect',
44
+ prompt: step.prompt,
45
+ outputSchema: {
46
+ // Reference the actual BlueprintSchema structure
47
+ // Since we are in runtime, we need the raw object or a way to get it from Zod
48
+ // For now, let's assume BlueprintSchema is available or we define it here
49
+ // Actually, it's better to just use the Zod schema for validation later
50
+ // But the LLM needs a JSON Schema.
51
+ type: 'object',
52
+ properties: {
53
+ architecture: {
54
+ type: 'object',
55
+ properties: {
56
+ description: { type: 'string' },
57
+ patterns: { type: 'array', items: { type: 'string' } },
58
+ },
59
+ required: ['description'],
60
+ },
61
+ apis: {
62
+ type: 'array',
63
+ items: {
64
+ type: 'object',
65
+ properties: {
66
+ name: { type: 'string' },
67
+ description: { type: 'string' },
68
+ endpoints: {
69
+ type: 'array',
70
+ items: {
71
+ type: 'object',
72
+ properties: {
73
+ path: { type: 'string' },
74
+ method: { type: 'string' },
75
+ purpose: { type: 'string' },
76
+ },
77
+ required: ['path', 'method', 'purpose'],
78
+ },
79
+ },
80
+ },
81
+ required: ['name', 'description'],
82
+ },
83
+ },
84
+ files: {
85
+ type: 'array',
86
+ items: {
87
+ type: 'object',
88
+ properties: {
89
+ path: { type: 'string' },
90
+ purpose: { type: 'string' },
91
+ constraints: { type: 'array', items: { type: 'string' } },
92
+ },
93
+ required: ['path', 'purpose'],
94
+ },
95
+ },
96
+ dependencies: {
97
+ type: 'array',
98
+ items: {
99
+ type: 'object',
100
+ properties: {
101
+ name: { type: 'string' },
102
+ version: { type: 'string' },
103
+ purpose: { type: 'string' },
104
+ },
105
+ required: ['name', 'purpose'],
106
+ },
107
+ },
108
+ constraints: { type: 'array', items: { type: 'string' } },
109
+ },
110
+ required: ['architecture', 'files'],
111
+ },
112
+ useStandardTools: true,
113
+ needs: [],
114
+ maxIterations: 10,
115
+ };
116
+
117
+ logger.log(` 🎨 Generating system blueprint using agent: ${llmStep.agent}`);
118
+
119
+ const llmResult = await runLlmStep(
120
+ llmStep,
121
+ context,
122
+ executeStepFn,
123
+ logger,
124
+ mcpManager,
125
+ workflowDir,
126
+ abortSignal
127
+ );
128
+
129
+ if (llmResult.status !== 'success') {
130
+ return llmResult;
131
+ }
132
+
133
+ const blueprint = llmResult.output as Blueprint;
134
+
135
+ // 2. Calculate hash for immutability check
136
+ const hash = BlueprintUtils.calculateHash(blueprint);
137
+
138
+ // 3. Persist as artifact
139
+ const root = artifactRoot || path.join(process.cwd(), '.keystone', 'artifacts');
140
+ const runDir = runId ? path.join(root, runId) : root;
141
+ mkdirSync(runDir, { recursive: true });
142
+
143
+ const artifactPath = path.join(runDir, `blueprint-${hash.substring(0, 8)}.json`);
144
+ await Bun.write(artifactPath, JSON.stringify(blueprint, null, 2));
145
+
146
+ logger.log(` 📦 Blueprint persisted: ${path.relative(process.cwd(), artifactPath)}`);
147
+
148
+ return {
149
+ output: {
150
+ ...blueprint,
151
+ __hash: hash,
152
+ __artifactPath: artifactPath,
153
+ },
154
+ status: 'success',
155
+ usage: llmResult.usage,
156
+ };
157
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { Workflow } from '../parser/schema';
3
+ import { WorkflowRunner } from './workflow-runner';
4
+
5
+ describe('Workflow Concurrency Integration', () => {
6
+ const dbPath = ':memory:';
7
+
8
+ it('should respect workflow-level concurrency limit', async () => {
9
+ const workflow: Workflow = {
10
+ name: 'concurrency-wf',
11
+ concurrency: 2,
12
+ steps: [
13
+ { id: 's1', type: 'sleep', duration: 100, needs: [] },
14
+ { id: 's2', type: 'sleep', duration: 100, needs: [] },
15
+ { id: 's3', type: 'sleep', duration: 100, needs: [] },
16
+ { id: 's4', type: 'sleep', duration: 100, needs: [] },
17
+ ],
18
+ } as unknown as Workflow;
19
+
20
+ const start = Date.now();
21
+ const runner = new WorkflowRunner(workflow, { dbPath });
22
+ await runner.run();
23
+ const duration = Date.now() - start;
24
+
25
+ // Concurrent=2, Total=4 steps, 100ms each -> should take ~200ms
26
+ // seq=400ms, parallel=100ms.
27
+ // We expect 200ms <= duration < 250ms
28
+ expect(duration).toBeGreaterThanOrEqual(200);
29
+ expect(duration).toBeLessThan(350); // Safe buffer
30
+ });
31
+
32
+ it('should respect pool-level limits', async () => {
33
+ const workflow: Workflow = {
34
+ name: 'pool-wf',
35
+ pools: {
36
+ slow: 1,
37
+ },
38
+ steps: [
39
+ { id: 's1', type: 'sleep', duration: 100, pool: 'slow', needs: [] },
40
+ { id: 's2', type: 'sleep', duration: 100, pool: 'slow', needs: [] },
41
+ { id: 's3', type: 'sleep', duration: 100, needs: [] }, // Default pool (type=sleep)
42
+ { id: 's4', type: 'sleep', duration: 100, needs: [] }, // Default pool
43
+ ],
44
+ } as unknown as Workflow;
45
+
46
+ const start = Date.now();
47
+ const runner = new WorkflowRunner(workflow, { dbPath });
48
+ await runner.run();
49
+ const duration = Date.now() - start;
50
+
51
+ // 'slow' pool limit 1 -> s1, s2 run sequentially (200ms)
52
+ // default pool (sleep) limit 10 (default) -> s3, s4 run parallel (100ms)
53
+ // Overall should take ~200ms
54
+ expect(duration).toBeGreaterThanOrEqual(200);
55
+ expect(duration).toBeLessThan(280);
56
+ });
57
+
58
+ it('should respect foreach concurrency limit', async () => {
59
+ const workflow: Workflow = {
60
+ name: 'foreach-concurrency-wf',
61
+ steps: [
62
+ {
63
+ id: 'process',
64
+ type: 'sleep',
65
+ duration: 50,
66
+ concurrency: 2,
67
+ foreach: '${{ [1, 2, 3, 4] }}',
68
+ needs: [],
69
+ },
70
+ ],
71
+ } as unknown as Workflow;
72
+
73
+ const start = Date.now();
74
+ const runner = new WorkflowRunner(workflow, { dbPath });
75
+ await runner.run();
76
+ const duration = Date.now() - start;
77
+
78
+ // 4 items, concurrency 2, 50ms each -> ~100ms
79
+ expect(duration).toBeGreaterThanOrEqual(100);
80
+ expect(duration).toBeLessThan(180);
81
+ });
82
+ });