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.
- package/README.md +276 -32
- package/package.json +8 -4
- package/src/cli.ts +350 -416
- package/src/commands/doc.ts +31 -0
- package/src/commands/event.ts +29 -0
- package/src/commands/graph.ts +37 -0
- package/src/commands/index.ts +14 -0
- package/src/commands/init.ts +185 -0
- package/src/commands/run.ts +124 -0
- package/src/commands/schema.ts +40 -0
- package/src/commands/utils.ts +78 -0
- package/src/commands/validate.ts +111 -0
- package/src/db/workflow-db.test.ts +314 -0
- package/src/db/workflow-db.ts +810 -210
- package/src/expression/evaluator-audit.test.ts +4 -2
- package/src/expression/evaluator.test.ts +14 -1
- package/src/expression/evaluator.ts +166 -19
- package/src/parser/config-schema.ts +18 -0
- package/src/parser/schema.ts +153 -22
- package/src/parser/test-schema.ts +6 -6
- package/src/parser/workflow-parser.test.ts +24 -0
- package/src/parser/workflow-parser.ts +65 -3
- package/src/runner/auto-heal.test.ts +5 -6
- package/src/runner/blueprint-executor.test.ts +2 -2
- package/src/runner/debug-repl.test.ts +5 -8
- package/src/runner/debug-repl.ts +59 -16
- package/src/runner/durable-timers.test.ts +11 -2
- package/src/runner/engine-executor.test.ts +1 -1
- package/src/runner/events.ts +57 -0
- package/src/runner/executors/artifact-executor.ts +166 -0
- package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
- package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
- package/src/runner/executors/file-executor.test.ts +48 -0
- package/src/runner/executors/file-executor.ts +324 -0
- package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
- package/src/runner/executors/human-executor.ts +144 -0
- package/src/runner/executors/join-executor.ts +75 -0
- package/src/runner/executors/llm-executor.ts +1266 -0
- package/src/runner/executors/memory-executor.ts +71 -0
- package/src/runner/executors/plan-executor.ts +104 -0
- package/src/runner/executors/request-executor.ts +265 -0
- package/src/runner/executors/script-executor.ts +43 -0
- package/src/runner/executors/shell-executor.ts +403 -0
- package/src/runner/executors/subworkflow-executor.ts +114 -0
- package/src/runner/executors/types.ts +69 -0
- package/src/runner/executors/wait-executor.ts +59 -0
- package/src/runner/join-scheduling.test.ts +197 -0
- package/src/runner/llm-adapter-runtime.test.ts +209 -0
- package/src/runner/llm-adapter.test.ts +419 -24
- package/src/runner/llm-adapter.ts +130 -26
- package/src/runner/llm-clarification.test.ts +2 -1
- package/src/runner/llm-executor.test.ts +532 -17
- package/src/runner/mcp-client-audit.test.ts +1 -2
- package/src/runner/mcp-client.ts +136 -46
- package/src/runner/mcp-manager.test.ts +4 -0
- package/src/runner/mcp-server.test.ts +58 -0
- package/src/runner/mcp-server.ts +26 -0
- package/src/runner/memoization.test.ts +190 -0
- package/src/runner/optimization-runner.ts +4 -9
- package/src/runner/quality-gate.test.ts +69 -0
- package/src/runner/reflexion.test.ts +6 -17
- package/src/runner/resource-pool.ts +102 -14
- package/src/runner/services/context-builder.ts +144 -0
- package/src/runner/services/secret-manager.ts +105 -0
- package/src/runner/services/workflow-validator.ts +131 -0
- package/src/runner/shell-executor.test.ts +28 -4
- package/src/runner/standard-tools-ast.test.ts +196 -0
- package/src/runner/standard-tools-execution.test.ts +27 -0
- package/src/runner/standard-tools-integration.test.ts +6 -10
- package/src/runner/standard-tools.ts +339 -102
- package/src/runner/step-executor.test.ts +216 -4
- package/src/runner/step-executor.ts +69 -941
- package/src/runner/stream-utils.ts +7 -3
- package/src/runner/test-harness.ts +20 -1
- package/src/runner/timeout.test.ts +10 -0
- package/src/runner/timeout.ts +11 -2
- package/src/runner/tool-integration.test.ts +1 -1
- package/src/runner/wait-step.test.ts +102 -0
- package/src/runner/workflow-runner.test.ts +208 -15
- package/src/runner/workflow-runner.ts +890 -818
- package/src/runner/workflow-scheduler.ts +75 -0
- package/src/runner/workflow-state.ts +269 -0
- package/src/runner/workflow-subflows.test.ts +13 -12
- package/src/scripts/generate-schemas.ts +16 -0
- package/src/templates/agents/explore.md +1 -0
- package/src/templates/agents/general.md +1 -0
- package/src/templates/agents/handoff-router.md +14 -0
- package/src/templates/agents/handoff-specialist.md +15 -0
- package/src/templates/agents/keystone-architect.md +13 -44
- package/src/templates/agents/my-agent.md +1 -0
- package/src/templates/agents/software-engineer.md +1 -0
- package/src/templates/agents/summarizer.md +1 -0
- package/src/templates/agents/test-agent.md +1 -0
- package/src/templates/agents/tester.md +1 -0
- package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
- package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
- package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
- package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
- package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
- package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
- package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
- package/src/templates/control-flow/idempotency-example.yaml +30 -0
- package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
- package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
- package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
- package/src/templates/features/artifact-example.yaml +39 -0
- package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
- package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
- package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
- package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
- package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
- package/src/templates/features/script-example.yaml +27 -0
- package/src/templates/patterns/agent-handoff.yaml +53 -0
- package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
- package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
- package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
- package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
- package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
- package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
- package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
- package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
- package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
- package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
- package/src/templates/scaffolding/review-loop.yaml +97 -0
- package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
- package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
- package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
- package/src/templates/testing/invalid.yaml +6 -0
- package/src/ui/dashboard.tsx +191 -33
- package/src/utils/auth-manager.test.ts +337 -0
- package/src/utils/auth-manager.ts +157 -61
- package/src/utils/blueprint-utils.ts +4 -6
- package/src/utils/config-loader.test.ts +2 -0
- package/src/utils/config-loader.ts +12 -3
- package/src/utils/constants.ts +76 -0
- package/src/utils/container.ts +63 -0
- package/src/utils/context-injector.test.ts +200 -0
- package/src/utils/context-injector.ts +244 -0
- package/src/utils/doc-generator.ts +85 -0
- package/src/utils/env-filter.ts +45 -0
- package/src/utils/json-parser.test.ts +12 -0
- package/src/utils/json-parser.ts +30 -5
- package/src/utils/logger.ts +12 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.ts +52 -1
- package/src/utils/process-sandbox-worker.test.ts +46 -0
- package/src/utils/process-sandbox.ts +227 -14
- package/src/utils/redactor.test.ts +11 -6
- package/src/utils/redactor.ts +25 -9
- package/src/utils/sandbox.ts +3 -0
- package/src/runner/llm-executor.ts +0 -638
- package/src/runner/shell-executor.ts +0 -366
- package/src/templates/invalid.yaml +0 -5
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import type { WorkflowDb } from '
|
|
3
|
-
import { type ExpressionContext, ExpressionEvaluator } from '
|
|
4
|
-
import type { Step } from '
|
|
5
|
-
import { StepStatus, WorkflowStatus } from '
|
|
6
|
-
import
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
9
|
-
import type { ForeachStepContext, StepContext } from '
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
222
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
+
}
|