keystone-cli 1.0.3 → 1.1.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.
Files changed (153) hide show
  1. package/README.md +276 -32
  2. package/package.json +8 -4
  3. package/src/cli.ts +350 -416
  4. package/src/commands/doc.ts +31 -0
  5. package/src/commands/event.ts +29 -0
  6. package/src/commands/graph.ts +37 -0
  7. package/src/commands/index.ts +14 -0
  8. package/src/commands/init.ts +185 -0
  9. package/src/commands/run.ts +124 -0
  10. package/src/commands/schema.ts +40 -0
  11. package/src/commands/utils.ts +78 -0
  12. package/src/commands/validate.ts +111 -0
  13. package/src/db/workflow-db.test.ts +314 -0
  14. package/src/db/workflow-db.ts +810 -210
  15. package/src/expression/evaluator-audit.test.ts +4 -2
  16. package/src/expression/evaluator.test.ts +14 -1
  17. package/src/expression/evaluator.ts +166 -19
  18. package/src/parser/config-schema.ts +18 -0
  19. package/src/parser/schema.ts +153 -22
  20. package/src/parser/test-schema.ts +6 -6
  21. package/src/parser/workflow-parser.test.ts +24 -0
  22. package/src/parser/workflow-parser.ts +65 -3
  23. package/src/runner/auto-heal.test.ts +5 -6
  24. package/src/runner/blueprint-executor.test.ts +2 -2
  25. package/src/runner/debug-repl.test.ts +5 -8
  26. package/src/runner/debug-repl.ts +59 -16
  27. package/src/runner/durable-timers.test.ts +11 -2
  28. package/src/runner/engine-executor.test.ts +1 -1
  29. package/src/runner/events.ts +57 -0
  30. package/src/runner/executors/artifact-executor.ts +166 -0
  31. package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
  32. package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
  33. package/src/runner/executors/file-executor.test.ts +48 -0
  34. package/src/runner/executors/file-executor.ts +324 -0
  35. package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
  36. package/src/runner/executors/human-executor.ts +144 -0
  37. package/src/runner/executors/join-executor.ts +75 -0
  38. package/src/runner/executors/llm-executor.ts +1266 -0
  39. package/src/runner/executors/memory-executor.ts +71 -0
  40. package/src/runner/executors/plan-executor.ts +104 -0
  41. package/src/runner/executors/request-executor.ts +265 -0
  42. package/src/runner/executors/script-executor.ts +43 -0
  43. package/src/runner/executors/shell-executor.ts +403 -0
  44. package/src/runner/executors/subworkflow-executor.ts +114 -0
  45. package/src/runner/executors/types.ts +69 -0
  46. package/src/runner/executors/wait-executor.ts +59 -0
  47. package/src/runner/join-scheduling.test.ts +197 -0
  48. package/src/runner/llm-adapter-runtime.test.ts +209 -0
  49. package/src/runner/llm-adapter.test.ts +419 -24
  50. package/src/runner/llm-adapter.ts +130 -26
  51. package/src/runner/llm-clarification.test.ts +2 -1
  52. package/src/runner/llm-executor.test.ts +532 -17
  53. package/src/runner/mcp-client-audit.test.ts +1 -2
  54. package/src/runner/mcp-client.ts +136 -46
  55. package/src/runner/mcp-manager.test.ts +4 -0
  56. package/src/runner/mcp-server.test.ts +58 -0
  57. package/src/runner/mcp-server.ts +26 -0
  58. package/src/runner/memoization.test.ts +190 -0
  59. package/src/runner/optimization-runner.ts +4 -9
  60. package/src/runner/quality-gate.test.ts +69 -0
  61. package/src/runner/reflexion.test.ts +6 -17
  62. package/src/runner/resource-pool.ts +102 -14
  63. package/src/runner/services/context-builder.ts +144 -0
  64. package/src/runner/services/secret-manager.ts +105 -0
  65. package/src/runner/services/workflow-validator.ts +131 -0
  66. package/src/runner/shell-executor.test.ts +28 -4
  67. package/src/runner/standard-tools-ast.test.ts +196 -0
  68. package/src/runner/standard-tools-execution.test.ts +27 -0
  69. package/src/runner/standard-tools-integration.test.ts +6 -10
  70. package/src/runner/standard-tools.ts +339 -102
  71. package/src/runner/step-executor.test.ts +216 -4
  72. package/src/runner/step-executor.ts +69 -941
  73. package/src/runner/stream-utils.ts +7 -3
  74. package/src/runner/test-harness.ts +20 -1
  75. package/src/runner/timeout.test.ts +10 -0
  76. package/src/runner/timeout.ts +11 -2
  77. package/src/runner/tool-integration.test.ts +1 -1
  78. package/src/runner/wait-step.test.ts +102 -0
  79. package/src/runner/workflow-runner.test.ts +208 -15
  80. package/src/runner/workflow-runner.ts +890 -818
  81. package/src/runner/workflow-scheduler.ts +75 -0
  82. package/src/runner/workflow-state.ts +269 -0
  83. package/src/runner/workflow-subflows.test.ts +13 -12
  84. package/src/scripts/generate-schemas.ts +16 -0
  85. package/src/templates/agents/explore.md +1 -0
  86. package/src/templates/agents/general.md +1 -0
  87. package/src/templates/agents/handoff-router.md +14 -0
  88. package/src/templates/agents/handoff-specialist.md +15 -0
  89. package/src/templates/agents/keystone-architect.md +13 -44
  90. package/src/templates/agents/my-agent.md +1 -0
  91. package/src/templates/agents/software-engineer.md +1 -0
  92. package/src/templates/agents/summarizer.md +1 -0
  93. package/src/templates/agents/test-agent.md +1 -0
  94. package/src/templates/agents/tester.md +1 -0
  95. package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
  96. package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
  97. package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
  98. package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
  99. package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
  100. package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
  101. package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
  102. package/src/templates/control-flow/idempotency-example.yaml +30 -0
  103. package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
  104. package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
  105. package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
  106. package/src/templates/features/artifact-example.yaml +39 -0
  107. package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
  108. package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
  109. package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
  110. package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
  111. package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
  112. package/src/templates/features/script-example.yaml +27 -0
  113. package/src/templates/patterns/agent-handoff.yaml +53 -0
  114. package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
  115. package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
  116. package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
  117. package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
  118. package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
  119. package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
  120. package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
  121. package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
  122. package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
  123. package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
  124. package/src/templates/scaffolding/review-loop.yaml +97 -0
  125. package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
  126. package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
  127. package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
  128. package/src/templates/testing/invalid.yaml +6 -0
  129. package/src/ui/dashboard.tsx +191 -33
  130. package/src/utils/auth-manager.test.ts +337 -0
  131. package/src/utils/auth-manager.ts +157 -61
  132. package/src/utils/blueprint-utils.ts +4 -6
  133. package/src/utils/config-loader.test.ts +2 -0
  134. package/src/utils/config-loader.ts +12 -3
  135. package/src/utils/constants.ts +76 -0
  136. package/src/utils/container.ts +63 -0
  137. package/src/utils/context-injector.test.ts +200 -0
  138. package/src/utils/context-injector.ts +244 -0
  139. package/src/utils/doc-generator.ts +85 -0
  140. package/src/utils/env-filter.ts +45 -0
  141. package/src/utils/json-parser.test.ts +12 -0
  142. package/src/utils/json-parser.ts +30 -5
  143. package/src/utils/logger.ts +12 -1
  144. package/src/utils/mermaid.ts +4 -0
  145. package/src/utils/paths.ts +52 -1
  146. package/src/utils/process-sandbox-worker.test.ts +46 -0
  147. package/src/utils/process-sandbox.ts +227 -14
  148. package/src/utils/redactor.test.ts +11 -6
  149. package/src/utils/redactor.ts +25 -9
  150. package/src/utils/sandbox.ts +3 -0
  151. package/src/runner/llm-executor.ts +0 -638
  152. package/src/runner/shell-executor.ts +0 -366
  153. package/src/templates/invalid.yaml +0 -5
@@ -1,12 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import type { WorkflowDb } from '../db/workflow-db.ts';
3
- import { type ExpressionContext, ExpressionEvaluator } from '../expression/evaluator.ts';
4
- import type { Step } from '../parser/schema.ts';
5
- import { StepStatus, WorkflowStatus } from '../types/status.ts';
6
- import type { Logger } from '../utils/logger.ts';
7
- import type { ResourcePoolManager } from './resource-pool.ts';
8
- import { WorkflowSuspendedError } from './step-executor.ts';
9
- import type { ForeachStepContext, StepContext } from './workflow-runner.ts';
2
+ import type { WorkflowDb } from '../../db/workflow-db.ts';
3
+ import { type ExpressionContext, ExpressionEvaluator } from '../../expression/evaluator.ts';
4
+ import type { Step } from '../../parser/schema.ts';
5
+ import { StepStatus, type StepStatusType, WorkflowStatus } from '../../types/status.ts';
6
+ import { LIMITS } from '../../utils/constants.ts';
7
+ import type { Logger } from '../../utils/logger.ts';
8
+ import type { ResourcePoolManager } from '../resource-pool.ts';
9
+ import type { ForeachStepContext, StepContext } from '../workflow-state.ts';
10
+ import { WorkflowSuspendedError } from './types.ts';
10
11
 
11
12
  export type ExecuteStepCallback = (
12
13
  step: Step,
@@ -15,7 +16,6 @@ export type ExecuteStepCallback = (
15
16
  ) => Promise<StepContext>;
16
17
 
17
18
  export class ForeachExecutor {
18
- private static readonly MEMORY_WARNING_THRESHOLD = 1000;
19
19
  private hasWarnedMemory = false;
20
20
 
21
21
  constructor(
@@ -77,14 +77,34 @@ export class ForeachExecutor {
77
77
  throw new Error('Step is not a foreach step');
78
78
  }
79
79
 
80
- const items = ExpressionEvaluator.evaluate(step.foreach, baseContext);
81
- if (!Array.isArray(items)) {
82
- throw new Error(`foreach expression must evaluate to an array: ${step.foreach}`);
80
+ let items: unknown[];
81
+ const persistedItems = existingContext?.foreachItems;
82
+ if (Array.isArray(persistedItems)) {
83
+ items = persistedItems;
84
+ } else {
85
+ if (persistedItems !== undefined) {
86
+ this.logger.warn(
87
+ ` ⚠️ Warning: Persisted foreach items for step ${step.id} are invalid. Re-evaluating expression.`
88
+ );
89
+ }
90
+ const evaluatedItems = ExpressionEvaluator.evaluate(step.foreach, baseContext);
91
+ if (!Array.isArray(evaluatedItems)) {
92
+ throw new Error(`foreach expression must evaluate to an array: ${step.foreach}`);
93
+ }
94
+ items = evaluatedItems;
95
+ }
96
+
97
+ // Validate iteration count to prevent memory exhaustion
98
+ if (items.length > LIMITS.MAX_FOREACH_ITERATIONS) {
99
+ throw new Error(
100
+ `Foreach step "${step.id}" exceeds maximum iteration limit of ${LIMITS.MAX_FOREACH_ITERATIONS}. ` +
101
+ `Got ${items.length} items. Consider batching or reducing the dataset.`
102
+ );
83
103
  }
84
104
 
85
105
  this.logger.log(` ⤷ Executing step ${step.id} for ${items.length} items`);
86
106
 
87
- if (items.length > ForeachExecutor.MEMORY_WARNING_THRESHOLD && !this.hasWarnedMemory) {
107
+ if (items.length > LIMITS.FOREACH_MEMORY_WARNING_THRESHOLD && !this.hasWarnedMemory) {
88
108
  this.logger.warn(
89
109
  ` ⚠️ Warning: Large foreach loop detected (${items.length} items). This may consume significant memory and lead to instability.`
90
110
  );
@@ -92,21 +112,32 @@ export class ForeachExecutor {
92
112
  }
93
113
 
94
114
  // Evaluate concurrency
95
- let concurrencyLimit = items.length;
115
+ // Default to a safe limit (50) to prevent resource exhaustion/DoS, unless explicitly overridden.
116
+ const DEFAULT_MAX_CONCURRENCY = 50;
117
+ let concurrencyLimit = Math.min(items.length, DEFAULT_MAX_CONCURRENCY);
118
+
96
119
  if (step.concurrency !== undefined) {
120
+ let explicitConcurrency: number;
121
+
97
122
  if (typeof step.concurrency === 'string') {
98
- concurrencyLimit = Number(ExpressionEvaluator.evaluate(step.concurrency, baseContext));
99
- if (!Number.isInteger(concurrencyLimit) || concurrencyLimit <= 0) {
100
- throw new Error(
101
- `concurrency must evaluate to a positive integer, got: ${concurrencyLimit}`
102
- );
103
- }
123
+ explicitConcurrency = Number(ExpressionEvaluator.evaluate(step.concurrency, baseContext));
104
124
  } else {
105
- concurrencyLimit = step.concurrency;
106
- if (!Number.isInteger(concurrencyLimit) || concurrencyLimit <= 0) {
107
- throw new Error(`concurrency must be a positive integer, got: ${concurrencyLimit}`);
108
- }
125
+ explicitConcurrency = step.concurrency;
126
+ }
127
+
128
+ if (!Number.isInteger(explicitConcurrency) || explicitConcurrency <= 0) {
129
+ throw new Error(
130
+ `concurrency must evaluate to a positive integer, got: ${explicitConcurrency}`
131
+ );
132
+ }
133
+
134
+ // If user explicitly sets a higher concurrency, we respect it but log a debug message
135
+ if (explicitConcurrency > DEFAULT_MAX_CONCURRENCY) {
136
+ this.logger.debug(
137
+ `Step ${step.id} has explicit concurrency ${explicitConcurrency} > default ${DEFAULT_MAX_CONCURRENCY}. Proceeding.`
138
+ );
109
139
  }
140
+ concurrencyLimit = explicitConcurrency;
110
141
  }
111
142
 
112
143
  // Create parent step record in DB
@@ -121,12 +152,91 @@ export class ForeachExecutor {
121
152
  // Initialize results array
122
153
  const itemResults: StepContext[] = existingContext?.items || new Array(items.length);
123
154
  const shouldCheckDb = !!existingContext;
155
+ // Track estimated result size to prevent memory exhaustion
156
+ let estimatedResultsBytes = 0;
124
157
 
125
158
  // Ensure array is correct length
126
159
  if (itemResults.length !== items.length) {
127
160
  itemResults.length = items.length;
128
161
  }
129
162
 
163
+ // Optimization: Fetch all existing iterations in one query
164
+ // This avoids N queries in the loop
165
+ const existingIterations = new Map<number, any>();
166
+ if (shouldCheckDb) {
167
+ try {
168
+ // Use getStepIterations(runId, stepId) for optimized fetch
169
+ const iterations = await this.db.getStepIterations(runId, step.id);
170
+ for (const s of iterations) {
171
+ if (typeof s.iteration_index === 'number') {
172
+ existingIterations.set(s.iteration_index, s);
173
+ }
174
+ }
175
+ } catch (e) {
176
+ /* ignore */
177
+ }
178
+ }
179
+
180
+ // Pre-generate IDs and batch-create step records for all pending iterations
181
+ const iterationIds = new Map<number, string>();
182
+ const toCreate: Array<{
183
+ id: string;
184
+ runId: string;
185
+ stepId: string;
186
+ iterationIndex: number;
187
+ }> = [];
188
+
189
+ for (let i = 0; i < items.length; i++) {
190
+ // Skip if already in results (from existingContext)
191
+ if (
192
+ itemResults[i] &&
193
+ (itemResults[i].status === StepStatus.SUCCESS ||
194
+ itemResults[i].status === StepStatus.SKIPPED)
195
+ ) {
196
+ continue;
197
+ }
198
+
199
+ // Check DB for resume if needed
200
+ if (shouldCheckDb) {
201
+ const existingExec = existingIterations.get(i);
202
+ if (
203
+ existingExec &&
204
+ (existingExec.status === StepStatus.SUCCESS ||
205
+ existingExec.status === StepStatus.SKIPPED)
206
+ ) {
207
+ // Hydrate result from DB
208
+ let output: unknown = null;
209
+ try {
210
+ output = existingExec.output ? JSON.parse(existingExec.output) : null;
211
+ } catch (error) {
212
+ this.logger.warn(
213
+ `Failed to parse output for step ${step.id} iteration ${i}: ${error}`
214
+ );
215
+ }
216
+ itemResults[i] = {
217
+ output,
218
+ outputs:
219
+ typeof output === 'object' && output !== null && !Array.isArray(output)
220
+ ? (output as Record<string, unknown>)
221
+ : {},
222
+ status: existingExec.status as StepStatusType,
223
+ error: existingExec.error || undefined,
224
+ } as StepContext;
225
+ continue;
226
+ }
227
+ }
228
+
229
+ // Needs execution
230
+ const id = randomUUID();
231
+ iterationIds.set(i, id);
232
+ toCreate.push({ id, runId, stepId: step.id, iterationIndex: i });
233
+ }
234
+
235
+ // Batch create all pending iterations
236
+ if (toCreate.length > 0) {
237
+ await this.db.batchCreateSteps(toCreate);
238
+ }
239
+
130
240
  // Worker pool implementation
131
241
  let currentIndex = 0;
132
242
  let aborted = false;
@@ -149,7 +259,7 @@ export class ForeachExecutor {
149
259
 
150
260
  const item = items[i];
151
261
 
152
- // Skip if already successful or skipped
262
+ // Skip if already successful or skipped (either from memory or just hydrated above)
153
263
  if (
154
264
  itemResults[i] &&
155
265
  (itemResults[i].status === StepStatus.SUCCESS ||
@@ -165,61 +275,10 @@ export class ForeachExecutor {
165
275
  index: i,
166
276
  };
167
277
 
168
- // Check DB again for robustness (resume flows only)
169
- const existingExec = shouldCheckDb
170
- ? await this.db.getStepByIteration(runId, step.id, i)
171
- : undefined;
172
- if (
173
- existingExec &&
174
- (existingExec.status === StepStatus.SUCCESS ||
175
- existingExec.status === StepStatus.SKIPPED)
176
- ) {
177
- let output: unknown = null;
178
- let itemStatus = existingExec.status as
179
- | typeof StepStatus.SUCCESS
180
- | typeof StepStatus.SKIPPED
181
- | typeof StepStatus.FAILED;
182
- let itemError: string | undefined = existingExec.error || undefined;
183
-
184
- try {
185
- output = existingExec.output ? JSON.parse(existingExec.output) : null;
186
- } catch (error) {
187
- this.logger.warn(
188
- `Failed to parse output for step ${step.id} iteration ${i}: ${error}`
189
- );
190
- output = { error: 'Failed to parse output' };
191
- itemStatus = StepStatus.FAILED;
192
- itemError = 'Failed to parse output';
193
- aborted = true; // Fail fast if we find corrupted data
194
- try {
195
- await this.db.completeStep(
196
- existingExec.id,
197
- StepStatus.FAILED,
198
- output,
199
- 'Failed to parse output'
200
- );
201
- } catch (dbError) {
202
- this.logger.warn(
203
- `Failed to update DB for corrupted output on step ${step.id} iteration ${i}: ${dbError}`
204
- );
205
- }
206
- }
207
- itemResults[i] = {
208
- output,
209
- outputs:
210
- typeof output === 'object' && output !== null && !Array.isArray(output)
211
- ? (output as Record<string, unknown>)
212
- : {},
213
- status: itemStatus,
214
- error: itemError,
215
- } as StepContext;
216
- continue;
217
- }
218
-
219
278
  if (aborted || this.abortSignal?.aborted) break;
220
279
 
221
- const stepExecId = randomUUID();
222
- await this.db.createStep(stepExecId, runId, step.id, i);
280
+ const stepExecId = iterationIds.get(i);
281
+ if (!stepExecId) continue; // Should not happen
223
282
 
224
283
  // Execute and store result
225
284
  try {
@@ -235,6 +294,23 @@ export class ForeachExecutor {
235
294
 
236
295
  this.logger.log(` ⤷ [${i + 1}/${items.length}] Executing iteration...`);
237
296
  itemResults[i] = await this.executeStepFn(step, itemContext, stepExecId);
297
+
298
+ // Track result size to prevent memory exhaustion
299
+ if (itemResults[i]?.output !== undefined) {
300
+ try {
301
+ estimatedResultsBytes += JSON.stringify(itemResults[i].output).length;
302
+ } catch {
303
+ // If serialization fails, estimate based on type
304
+ estimatedResultsBytes += 1024;
305
+ }
306
+ if (estimatedResultsBytes > LIMITS.MAX_FOREACH_RESULTS_BYTES) {
307
+ throw new Error(
308
+ `Foreach step "${step.id}" accumulated results exceed maximum size of ` +
309
+ `${LIMITS.MAX_FOREACH_RESULTS_BYTES} bytes. Consider reducing output size or batching.`
310
+ );
311
+ }
312
+ }
313
+
238
314
  if (
239
315
  itemResults[i].status === StepStatus.FAILED ||
240
316
  itemResults[i].status === StepStatus.SUSPENDED
@@ -245,6 +321,16 @@ export class ForeachExecutor {
245
321
  release?.();
246
322
  }
247
323
  } catch (error) {
324
+ if (error instanceof WorkflowSuspendedError) {
325
+ itemResults[i] = {
326
+ status: StepStatus.SUSPENDED,
327
+ output: null,
328
+ outputs: {},
329
+ error: error.message,
330
+ };
331
+ aborted = true;
332
+ return;
333
+ }
248
334
  aborted = true;
249
335
  throw error;
250
336
  }
@@ -257,8 +343,10 @@ export class ForeachExecutor {
257
343
  const firstError = workerResults.find((r) => r.status === 'rejected') as
258
344
  | PromiseRejectedResult
259
345
  | undefined;
260
- if (firstError) {
261
- throw firstError.reason;
346
+ const error = firstError?.reason;
347
+
348
+ if (error && !(error instanceof WorkflowSuspendedError)) {
349
+ throw error;
262
350
  }
263
351
 
264
352
  // Aggregate results
@@ -0,0 +1,144 @@
1
+ import * as readlinePromises from 'node:readline/promises';
2
+ import type { ExpressionContext } from '../../expression/evaluator.ts';
3
+ import { ExpressionEvaluator } from '../../expression/evaluator.ts';
4
+ import type { HumanStep, SleepStep } from '../../parser/schema.ts';
5
+ import type { Logger } from '../../utils/logger.ts';
6
+ import { type StepResult, WorkflowSuspendedError, WorkflowWaitingError } from './types.ts';
7
+
8
+ /**
9
+ * Execute a human input step
10
+ */
11
+ export async function executeHumanStep(
12
+ step: HumanStep,
13
+ context: ExpressionContext,
14
+ logger: Logger,
15
+ abortSignal?: AbortSignal
16
+ ): Promise<StepResult> {
17
+ if (abortSignal?.aborted) {
18
+ throw new Error('Step canceled');
19
+ }
20
+ const message = ExpressionEvaluator.evaluateString(step.message, context);
21
+
22
+ // Check if we already have input for this step in context.inputs (from previous suspension)
23
+ const stepInputs = (context.inputs as Record<string, any>)?.[step.id];
24
+ if (stepInputs && stepInputs.__answer !== undefined) {
25
+ logger.log(` ✓ Received human input: ${stepInputs.__answer}`);
26
+ return {
27
+ status: 'success',
28
+ output: stepInputs.__answer,
29
+ };
30
+ }
31
+
32
+ const inputType = step.inputType || 'text';
33
+ if (!process.stdin.isTTY) {
34
+ logger.log(` ⏳ Suspending for human input: ${message}`);
35
+ throw new WorkflowSuspendedError(message, step.id, inputType);
36
+ }
37
+
38
+ const rl = readlinePromises.createInterface({
39
+ input: process.stdin,
40
+ output: process.stdout,
41
+ });
42
+
43
+ try {
44
+ const prompt = inputType === 'confirm' ? `${message} [Y/n] ` : `${message} `;
45
+ const answer = await rl.question(prompt);
46
+
47
+ if (inputType === 'confirm') {
48
+ const normalized = answer.trim().toLowerCase();
49
+ if (normalized === '') {
50
+ return { status: 'success', output: true };
51
+ }
52
+ if (['y', 'yes', 'true', '1'].includes(normalized)) {
53
+ return { status: 'success', output: true };
54
+ }
55
+ if (['n', 'no', 'false', '0'].includes(normalized)) {
56
+ return { status: 'success', output: false };
57
+ }
58
+ return { status: 'success', output: answer.trim() };
59
+ }
60
+
61
+ return { status: 'success', output: answer };
62
+ } finally {
63
+ rl.close();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Execute a sleep step
69
+ */
70
+ export async function executeSleepStep(
71
+ step: SleepStep,
72
+ context: ExpressionContext,
73
+ logger: Logger,
74
+ abortSignal?: AbortSignal
75
+ ): Promise<StepResult> {
76
+ if (abortSignal?.aborted) {
77
+ throw new Error('Step canceled');
78
+ }
79
+
80
+ let durationMs = 0;
81
+ let wakeAt: string | undefined;
82
+
83
+ // Check if this is a resume of a durable sleep
84
+ const previousOutput = (context.steps as Record<string, any>)?.[step.id]?.output;
85
+ if (
86
+ step.durable &&
87
+ previousOutput &&
88
+ typeof previousOutput === 'object' &&
89
+ 'wakeAt' in previousOutput
90
+ ) {
91
+ wakeAt = previousOutput.wakeAt as string;
92
+ const untilDate = new Date(wakeAt);
93
+ if (!Number.isNaN(untilDate.getTime())) {
94
+ durationMs = untilDate.getTime() - Date.now();
95
+ }
96
+ }
97
+
98
+ if (wakeAt) {
99
+ // Already handled by resume logic above
100
+ } else if (step.until) {
101
+ const untilStr = ExpressionEvaluator.evaluateString(step.until, context);
102
+ const untilDate = new Date(untilStr);
103
+ if (Number.isNaN(untilDate.getTime())) {
104
+ throw new Error(`Invalid 'until' date format: ${untilStr}`);
105
+ }
106
+ wakeAt = untilDate.toISOString();
107
+ durationMs = untilDate.getTime() - Date.now();
108
+ } else if (step.duration) {
109
+ let duration: string;
110
+ if (typeof step.duration === 'number') {
111
+ duration = `${step.duration}ms`;
112
+ } else {
113
+ duration = ExpressionEvaluator.evaluateString(step.duration, context);
114
+ }
115
+
116
+ // Parse duration (e.g., "10s", "1m", "1h", "50ms")
117
+ const match = duration.match(/^(\d+)([smh]|ms)$/);
118
+ if (!match) {
119
+ throw new Error(
120
+ `Invalid duration format: ${duration}. Expected e.g. "10s", "1m", "1h", "50ms"`
121
+ );
122
+ }
123
+ const val = Number.parseInt(match[1], 10);
124
+ const unit = match[2];
125
+ durationMs = val * (unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60000 : 3600000);
126
+ wakeAt = new Date(Date.now() + durationMs).toISOString();
127
+ } else {
128
+ throw new Error("Sleep step requires either 'duration' or 'until'");
129
+ }
130
+
131
+ if (durationMs <= 0) {
132
+ logger.log(' ✓ Sleep duration already passed or is zero');
133
+ return { status: 'success', output: 'slept' };
134
+ }
135
+
136
+ logger.log(` ⏳ Sleeping until ${wakeAt} (${Math.round(durationMs / 1000)}s remaining)`);
137
+
138
+ if (step.durable) {
139
+ throw new WorkflowWaitingError(`Sleeping until ${wakeAt}`, step.id, wakeAt);
140
+ }
141
+
142
+ await Bun.sleep(durationMs);
143
+ return { status: 'success', output: 'slept' };
144
+ }
@@ -0,0 +1,75 @@
1
+ import type { ExpressionContext } from '../../expression/evaluator.ts';
2
+ import type { JoinStep } from '../../parser/schema.ts';
3
+ import type { Logger } from '../../utils/logger.ts';
4
+ import type { StepResult } from './types.ts';
5
+
6
+ /**
7
+ * Execute a join step
8
+ */
9
+ export async function executeJoinStep(
10
+ step: JoinStep,
11
+ context: ExpressionContext,
12
+ _logger: Logger,
13
+ abortSignal?: AbortSignal
14
+ ): Promise<StepResult> {
15
+ if (abortSignal?.aborted) {
16
+ throw new Error('Join operation aborted');
17
+ }
18
+ // Join step logic:
19
+ // It aggregates outputs from its 'needs'.
20
+ // Since the runner ensures dependencies are met (or processed),
21
+ // we just need to collect the results from context.steps.
22
+
23
+ const inputs: Record<string, unknown> = {};
24
+ const statusMap: Record<string, string> = {};
25
+ const realStatusMap: Record<string, 'success' | 'failed'> = {}; // Status considering allowFailure errors
26
+ const errors: string[] = [];
27
+
28
+ for (const depId of step.needs) {
29
+ const depContext = (context.steps as Record<string, any>)?.[depId];
30
+ if (depContext) {
31
+ inputs[depId] = depContext.output;
32
+ if (depContext.status) {
33
+ statusMap[depId] = depContext.status;
34
+ }
35
+
36
+ // Determine effective status:
37
+ // If status is success but error exists (allowFailure), treat as failed for the join condition
38
+ const isRealSuccess = depContext.status === 'success' && !depContext.error;
39
+ realStatusMap[depId] = isRealSuccess ? 'success' : 'failed';
40
+
41
+ if (depContext.error) {
42
+ errors.push(`Dependency ${depId} failed: ${depContext.error}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ // Validate condition
48
+ const condition = step.condition || 'all';
49
+ const total = step.needs.length;
50
+ // Use realStatusMap to count successes/failures
51
+ const successCount = Object.values(realStatusMap).filter((s) => s === 'success').length;
52
+
53
+ let passed = false;
54
+
55
+ if (condition === 'all') {
56
+ passed = successCount === total;
57
+ } else if (condition === 'any') {
58
+ passed = successCount > 0;
59
+ } else if (typeof condition === 'number') {
60
+ passed = successCount >= condition;
61
+ }
62
+
63
+ if (!passed) {
64
+ return {
65
+ output: { inputs, status: statusMap },
66
+ status: 'failed',
67
+ error: `Join condition '${condition}' not met. Success: ${successCount}/${total}. Errors: ${errors.join('; ')}`,
68
+ };
69
+ }
70
+
71
+ return {
72
+ output: { inputs, status: statusMap },
73
+ status: 'success',
74
+ };
75
+ }