keystone-cli 0.4.4 → 0.5.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.
- package/README.md +29 -4
- package/package.json +1 -2
- package/src/cli.ts +64 -4
- package/src/db/workflow-db.ts +16 -7
- package/src/expression/evaluator.audit.test.ts +67 -0
- package/src/expression/evaluator.test.ts +15 -2
- package/src/expression/evaluator.ts +102 -29
- package/src/parser/agent-parser.test.ts +6 -2
- package/src/parser/schema.ts +2 -0
- package/src/parser/workflow-parser.test.ts +6 -2
- package/src/parser/workflow-parser.ts +22 -11
- package/src/runner/audit-verification.test.ts +12 -8
- package/src/runner/llm-adapter.ts +49 -12
- package/src/runner/llm-executor.test.ts +75 -13
- package/src/runner/llm-executor.ts +84 -47
- package/src/runner/mcp-client.audit.test.ts +79 -0
- package/src/runner/mcp-client.ts +102 -19
- package/src/runner/shell-executor.test.ts +33 -15
- package/src/runner/shell-executor.ts +110 -39
- package/src/runner/step-executor.test.ts +30 -2
- package/src/runner/timeout.ts +2 -2
- package/src/runner/tool-integration.test.ts +8 -2
- package/src/runner/workflow-runner.ts +95 -29
- package/src/templates/agents/keystone-architect.md +5 -3
- package/src/types/status.ts +25 -0
- package/src/ui/dashboard.tsx +3 -1
- package/src/utils/auth-manager.test.ts +3 -1
- package/src/utils/auth-manager.ts +12 -2
- package/src/utils/config-loader.test.ts +2 -17
- package/src/utils/mermaid.ts +0 -8
- package/src/utils/redactor.ts +115 -22
- package/src/utils/sandbox.test.ts +9 -13
- package/src/utils/sandbox.ts +40 -53
- package/src/utils/workflow-registry.test.ts +6 -2
|
@@ -49,13 +49,17 @@ describe('step-executor', () => {
|
|
|
49
49
|
beforeAll(() => {
|
|
50
50
|
try {
|
|
51
51
|
mkdirSync(tempDir, { recursive: true });
|
|
52
|
-
} catch (e) {
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// Ignore error
|
|
54
|
+
}
|
|
53
55
|
});
|
|
54
56
|
|
|
55
57
|
afterAll(() => {
|
|
56
58
|
try {
|
|
57
59
|
rmSync(tempDir, { recursive: true, force: true });
|
|
58
|
-
} catch (e) {
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// Ignore error
|
|
62
|
+
}
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
beforeEach(() => {
|
|
@@ -70,6 +74,7 @@ describe('step-executor', () => {
|
|
|
70
74
|
const step: ShellStep = {
|
|
71
75
|
id: 's1',
|
|
72
76
|
type: 'shell',
|
|
77
|
+
needs: [],
|
|
73
78
|
run: 'echo "hello"',
|
|
74
79
|
};
|
|
75
80
|
const result = await executeStep(step, context);
|
|
@@ -81,6 +86,7 @@ describe('step-executor', () => {
|
|
|
81
86
|
const step: ShellStep = {
|
|
82
87
|
id: 's1',
|
|
83
88
|
type: 'shell',
|
|
89
|
+
needs: [],
|
|
84
90
|
run: 'exit 1',
|
|
85
91
|
};
|
|
86
92
|
const result = await executeStep(step, context);
|
|
@@ -95,6 +101,7 @@ describe('step-executor', () => {
|
|
|
95
101
|
const writeStep: FileStep = {
|
|
96
102
|
id: 'w1',
|
|
97
103
|
type: 'file',
|
|
104
|
+
needs: [],
|
|
98
105
|
op: 'write',
|
|
99
106
|
path: filePath,
|
|
100
107
|
content: 'hello file',
|
|
@@ -105,6 +112,7 @@ describe('step-executor', () => {
|
|
|
105
112
|
const readStep: FileStep = {
|
|
106
113
|
id: 'r1',
|
|
107
114
|
type: 'file',
|
|
115
|
+
needs: [],
|
|
108
116
|
op: 'read',
|
|
109
117
|
path: filePath,
|
|
110
118
|
};
|
|
@@ -119,6 +127,7 @@ describe('step-executor', () => {
|
|
|
119
127
|
{
|
|
120
128
|
id: 'w1',
|
|
121
129
|
type: 'file',
|
|
130
|
+
needs: [],
|
|
122
131
|
op: 'write',
|
|
123
132
|
path: filePath,
|
|
124
133
|
content: 'line 1\n',
|
|
@@ -130,6 +139,7 @@ describe('step-executor', () => {
|
|
|
130
139
|
{
|
|
131
140
|
id: 'a1',
|
|
132
141
|
type: 'file',
|
|
142
|
+
needs: [],
|
|
133
143
|
op: 'append',
|
|
134
144
|
path: filePath,
|
|
135
145
|
content: 'line 2',
|
|
@@ -145,6 +155,7 @@ describe('step-executor', () => {
|
|
|
145
155
|
const readStep: FileStep = {
|
|
146
156
|
id: 'r1',
|
|
147
157
|
type: 'file',
|
|
158
|
+
needs: [],
|
|
148
159
|
op: 'read',
|
|
149
160
|
path: join(tempDir, 'non-existent.txt'),
|
|
150
161
|
};
|
|
@@ -183,6 +194,7 @@ describe('step-executor', () => {
|
|
|
183
194
|
const step: SleepStep = {
|
|
184
195
|
id: 'sl1',
|
|
185
196
|
type: 'sleep',
|
|
197
|
+
needs: [],
|
|
186
198
|
duration: 10,
|
|
187
199
|
};
|
|
188
200
|
const start = Date.now();
|
|
@@ -217,6 +229,7 @@ describe('step-executor', () => {
|
|
|
217
229
|
const step: RequestStep = {
|
|
218
230
|
id: 'req1',
|
|
219
231
|
type: 'request',
|
|
232
|
+
needs: [],
|
|
220
233
|
url: 'https://api.example.com/test',
|
|
221
234
|
method: 'GET',
|
|
222
235
|
};
|
|
@@ -233,6 +246,7 @@ describe('step-executor', () => {
|
|
|
233
246
|
const step: RequestStep = {
|
|
234
247
|
id: 'req1',
|
|
235
248
|
type: 'request',
|
|
249
|
+
needs: [],
|
|
236
250
|
url: 'https://api.example.com/post',
|
|
237
251
|
method: 'POST',
|
|
238
252
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
@@ -253,6 +267,7 @@ describe('step-executor', () => {
|
|
|
253
267
|
const step: RequestStep = {
|
|
254
268
|
id: 'req1',
|
|
255
269
|
type: 'request',
|
|
270
|
+
needs: [],
|
|
256
271
|
url: 'https://api.example.com/post',
|
|
257
272
|
method: 'POST',
|
|
258
273
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
@@ -273,6 +288,7 @@ describe('step-executor', () => {
|
|
|
273
288
|
const step: RequestStep = {
|
|
274
289
|
id: 'req1',
|
|
275
290
|
type: 'request',
|
|
291
|
+
needs: [],
|
|
276
292
|
url: 'https://api.example.com/post',
|
|
277
293
|
method: 'POST',
|
|
278
294
|
body: { foo: 'bar' },
|
|
@@ -298,6 +314,7 @@ describe('step-executor', () => {
|
|
|
298
314
|
const step: RequestStep = {
|
|
299
315
|
id: 'req1',
|
|
300
316
|
type: 'request',
|
|
317
|
+
needs: [],
|
|
301
318
|
url: 'https://api.example.com/text',
|
|
302
319
|
method: 'GET',
|
|
303
320
|
};
|
|
@@ -320,6 +337,7 @@ describe('step-executor', () => {
|
|
|
320
337
|
const step: RequestStep = {
|
|
321
338
|
id: 'req1',
|
|
322
339
|
type: 'request',
|
|
340
|
+
needs: [],
|
|
323
341
|
url: 'https://api.example.com/fail',
|
|
324
342
|
method: 'POST',
|
|
325
343
|
};
|
|
@@ -348,6 +366,7 @@ describe('step-executor', () => {
|
|
|
348
366
|
const step: HumanStep = {
|
|
349
367
|
id: 'h1',
|
|
350
368
|
type: 'human',
|
|
369
|
+
needs: [],
|
|
351
370
|
message: 'Proceed?',
|
|
352
371
|
inputType: 'confirm',
|
|
353
372
|
};
|
|
@@ -365,6 +384,7 @@ describe('step-executor', () => {
|
|
|
365
384
|
const step: HumanStep = {
|
|
366
385
|
id: 'h1',
|
|
367
386
|
type: 'human',
|
|
387
|
+
needs: [],
|
|
368
388
|
message: 'What is your name?',
|
|
369
389
|
inputType: 'text',
|
|
370
390
|
};
|
|
@@ -379,6 +399,7 @@ describe('step-executor', () => {
|
|
|
379
399
|
const step: HumanStep = {
|
|
380
400
|
id: 'h1',
|
|
381
401
|
type: 'human',
|
|
402
|
+
needs: [],
|
|
382
403
|
message: 'Proceed?',
|
|
383
404
|
inputType: 'confirm',
|
|
384
405
|
};
|
|
@@ -408,6 +429,7 @@ describe('step-executor', () => {
|
|
|
408
429
|
const step: HumanStep = {
|
|
409
430
|
id: 'h1',
|
|
410
431
|
type: 'human',
|
|
432
|
+
needs: [],
|
|
411
433
|
message: 'Proceed?',
|
|
412
434
|
inputType: 'confirm',
|
|
413
435
|
};
|
|
@@ -424,6 +446,7 @@ describe('step-executor', () => {
|
|
|
424
446
|
const step: HumanStep = {
|
|
425
447
|
id: 'h1',
|
|
426
448
|
type: 'human',
|
|
449
|
+
needs: [],
|
|
427
450
|
message: 'Proceed?',
|
|
428
451
|
inputType: 'confirm',
|
|
429
452
|
};
|
|
@@ -440,6 +463,7 @@ describe('step-executor', () => {
|
|
|
440
463
|
const step: WorkflowStep = {
|
|
441
464
|
id: 'w1',
|
|
442
465
|
type: 'workflow',
|
|
466
|
+
needs: [],
|
|
443
467
|
path: 'child.yaml',
|
|
444
468
|
};
|
|
445
469
|
// @ts-ignore
|
|
@@ -458,6 +482,7 @@ describe('step-executor', () => {
|
|
|
458
482
|
const step: WorkflowStep = {
|
|
459
483
|
id: 'w1',
|
|
460
484
|
type: 'workflow',
|
|
485
|
+
needs: [],
|
|
461
486
|
path: 'child.yaml',
|
|
462
487
|
};
|
|
463
488
|
const result = await executeStep(step, context);
|
|
@@ -485,6 +510,7 @@ describe('step-executor', () => {
|
|
|
485
510
|
const step: ShellStep = {
|
|
486
511
|
id: 's1',
|
|
487
512
|
type: 'shell',
|
|
513
|
+
needs: [],
|
|
488
514
|
run: 'echo "json string"',
|
|
489
515
|
transform: 'output.stdout.toUpperCase().trim()',
|
|
490
516
|
};
|
|
@@ -497,6 +523,7 @@ describe('step-executor', () => {
|
|
|
497
523
|
const step: ShellStep = {
|
|
498
524
|
id: 's1',
|
|
499
525
|
type: 'shell',
|
|
526
|
+
needs: [],
|
|
500
527
|
run: 'echo "hello"',
|
|
501
528
|
transform: '${{ output.stdout.trim() + " world" }}',
|
|
502
529
|
};
|
|
@@ -509,6 +536,7 @@ describe('step-executor', () => {
|
|
|
509
536
|
const step: ShellStep = {
|
|
510
537
|
id: 's1',
|
|
511
538
|
type: 'shell',
|
|
539
|
+
needs: [],
|
|
512
540
|
run: 'echo "hello"',
|
|
513
541
|
transform: 'nonexistent.property',
|
|
514
542
|
};
|
package/src/runner/timeout.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function withTimeout<T>(
|
|
|
14
14
|
timeoutMs: number,
|
|
15
15
|
operation = 'Operation'
|
|
16
16
|
): Promise<T> {
|
|
17
|
-
let timeoutId: Timer;
|
|
17
|
+
let timeoutId: Timer | undefined;
|
|
18
18
|
|
|
19
19
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
20
20
|
timeoutId = setTimeout(() => {
|
|
@@ -25,6 +25,6 @@ export async function withTimeout<T>(
|
|
|
25
25
|
try {
|
|
26
26
|
return await Promise.race([promise, timeoutPromise]);
|
|
27
27
|
} finally {
|
|
28
|
-
clearTimeout(timeoutId);
|
|
28
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
@@ -28,7 +28,9 @@ describe('llm-executor with tools and MCP', () => {
|
|
|
28
28
|
beforeAll(() => {
|
|
29
29
|
try {
|
|
30
30
|
mkdirSync(agentsDir, { recursive: true });
|
|
31
|
-
} catch (e) {
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Ignore error
|
|
33
|
+
}
|
|
32
34
|
const agentContent = `---
|
|
33
35
|
name: tool-test-agent
|
|
34
36
|
tools:
|
|
@@ -45,7 +47,9 @@ Test system prompt`;
|
|
|
45
47
|
afterAll(() => {
|
|
46
48
|
try {
|
|
47
49
|
unlinkSync(agentPath);
|
|
48
|
-
} catch (e) {
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// Ignore error
|
|
52
|
+
}
|
|
49
53
|
});
|
|
50
54
|
|
|
51
55
|
it('should merge tools from agent, step and MCP', async () => {
|
|
@@ -90,6 +94,7 @@ Test system prompt`;
|
|
|
90
94
|
agent: 'tool-test-agent',
|
|
91
95
|
prompt: 'test',
|
|
92
96
|
needs: [],
|
|
97
|
+
maxIterations: 10,
|
|
93
98
|
tools: [
|
|
94
99
|
{
|
|
95
100
|
name: 'step-tool',
|
|
@@ -179,6 +184,7 @@ Test system prompt`;
|
|
|
179
184
|
agent: 'tool-test-agent',
|
|
180
185
|
prompt: 'test',
|
|
181
186
|
needs: [],
|
|
187
|
+
maxIterations: 10,
|
|
182
188
|
mcpServers: [{ name: 'test-mcp', command: 'node', args: ['-e', ''] }],
|
|
183
189
|
};
|
|
184
190
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { WorkflowDb } from '../db/workflow-db.ts';
|
|
3
|
+
import { type RunStatus, WorkflowDb } from '../db/workflow-db.ts';
|
|
4
4
|
import type { ExpressionContext } from '../expression/evaluator.ts';
|
|
5
5
|
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
6
6
|
import type { Step, Workflow, WorkflowStep } from '../parser/schema.ts';
|
|
@@ -89,6 +89,9 @@ export class WorkflowRunner {
|
|
|
89
89
|
private mcpManager: MCPManager;
|
|
90
90
|
private options: RunOptions;
|
|
91
91
|
private signalHandler?: (signal: string) => void;
|
|
92
|
+
private isStopping = false;
|
|
93
|
+
private hasWarnedMemory = false;
|
|
94
|
+
private static readonly MEMORY_WARNING_THRESHOLD = 1000;
|
|
92
95
|
|
|
93
96
|
constructor(workflow: Workflow, options: RunOptions = {}) {
|
|
94
97
|
this.workflow = workflow;
|
|
@@ -144,9 +147,11 @@ export class WorkflowRunner {
|
|
|
144
147
|
const storedInputs = JSON.parse(run.inputs);
|
|
145
148
|
this.inputs = { ...storedInputs, ...this.inputs };
|
|
146
149
|
} catch (error) {
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
// Log warning but continue with default empty inputs instead of crashing
|
|
151
|
+
this.logger.warn(
|
|
152
|
+
`Failed to parse inputs from run ${this.runId}, using defaults: ${error instanceof Error ? error.message : String(error)}`
|
|
149
153
|
);
|
|
154
|
+
// Keep existing inputs (from resumeInputs or empty)
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
// Load all step executions for this run
|
|
@@ -190,7 +195,15 @@ export class WorkflowRunner {
|
|
|
190
195
|
if (exec.iteration_index === null) continue; // Skip parent step record
|
|
191
196
|
|
|
192
197
|
if (exec.status === 'success' || exec.status === 'skipped') {
|
|
193
|
-
|
|
198
|
+
let output: unknown = null;
|
|
199
|
+
try {
|
|
200
|
+
output = exec.output ? JSON.parse(exec.output) : null;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
this.logger.warn(
|
|
203
|
+
`Failed to parse output for step ${stepId} iteration ${exec.iteration_index}: ${error}`
|
|
204
|
+
);
|
|
205
|
+
output = { error: 'Failed to parse output' };
|
|
206
|
+
}
|
|
194
207
|
items[exec.iteration_index] = {
|
|
195
208
|
output,
|
|
196
209
|
outputs:
|
|
@@ -211,21 +224,36 @@ export class WorkflowRunner {
|
|
|
211
224
|
}
|
|
212
225
|
}
|
|
213
226
|
|
|
214
|
-
//
|
|
215
|
-
//
|
|
227
|
+
// Use persisted foreach items from parent step for deterministic resume
|
|
228
|
+
// This ensures the resume uses the same array as the initial run
|
|
216
229
|
let expectedCount = -1;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
230
|
+
const parentExec = stepExecutions.find((e) => e.iteration_index === null);
|
|
231
|
+
if (parentExec?.output) {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(parentExec.output);
|
|
234
|
+
if (parsed.__foreachItems && Array.isArray(parsed.__foreachItems)) {
|
|
235
|
+
expectedCount = parsed.__foreachItems.length;
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// Parse error, fall through to expression evaluation
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Fallback to expression evaluation if persisted items not found
|
|
243
|
+
if (expectedCount === -1) {
|
|
244
|
+
try {
|
|
245
|
+
const baseContext = this.buildContext();
|
|
246
|
+
const foreachExpr = stepDef.foreach;
|
|
247
|
+
if (foreachExpr) {
|
|
248
|
+
const foreachItems = ExpressionEvaluator.evaluate(foreachExpr, baseContext);
|
|
249
|
+
if (Array.isArray(foreachItems)) {
|
|
250
|
+
expectedCount = foreachItems.length;
|
|
251
|
+
}
|
|
224
252
|
}
|
|
253
|
+
} catch (e) {
|
|
254
|
+
// If we can't evaluate yet (dependencies not met?), we can't be sure it's complete
|
|
255
|
+
allSuccess = false;
|
|
225
256
|
}
|
|
226
|
-
} catch (e) {
|
|
227
|
-
// If we can't evaluate yet (dependencies not met?), we can't be sure it's complete
|
|
228
|
-
allSuccess = false;
|
|
229
257
|
}
|
|
230
258
|
|
|
231
259
|
// Check if we have all items (no gaps)
|
|
@@ -261,7 +289,13 @@ export class WorkflowRunner {
|
|
|
261
289
|
// Single execution step
|
|
262
290
|
const exec = stepExecutions[0];
|
|
263
291
|
if (exec.status === 'success' || exec.status === 'skipped' || exec.status === 'suspended') {
|
|
264
|
-
|
|
292
|
+
let output: unknown = null;
|
|
293
|
+
try {
|
|
294
|
+
output = exec.output ? JSON.parse(exec.output) : null;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.logger.warn(`Failed to parse output for step ${stepId}: ${error}`);
|
|
297
|
+
output = { error: 'Failed to parse output' };
|
|
298
|
+
}
|
|
265
299
|
this.stepContexts.set(stepId, {
|
|
266
300
|
output,
|
|
267
301
|
outputs:
|
|
@@ -286,18 +320,9 @@ export class WorkflowRunner {
|
|
|
286
320
|
*/
|
|
287
321
|
private setupSignalHandlers(): void {
|
|
288
322
|
const handler = async (signal: string) => {
|
|
323
|
+
if (this.isStopping) return;
|
|
289
324
|
this.logger.log(`\n\n🛑 Received ${signal}. Cleaning up...`);
|
|
290
|
-
|
|
291
|
-
await this.db.updateRunStatus(
|
|
292
|
-
this.runId,
|
|
293
|
-
'failed',
|
|
294
|
-
undefined,
|
|
295
|
-
`Cancelled by user (${signal})`
|
|
296
|
-
);
|
|
297
|
-
this.logger.log('✓ Run status updated to failed');
|
|
298
|
-
} catch (error) {
|
|
299
|
-
this.logger.error(`Error during cleanup: ${error}`);
|
|
300
|
-
}
|
|
325
|
+
await this.stop('failed', `Cancelled by user (${signal})`);
|
|
301
326
|
|
|
302
327
|
// Only exit if not embedded
|
|
303
328
|
if (!this.options.preventExit) {
|
|
@@ -311,6 +336,28 @@ export class WorkflowRunner {
|
|
|
311
336
|
process.on('SIGTERM', handler);
|
|
312
337
|
}
|
|
313
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Stop the runner and cleanup resources
|
|
341
|
+
*/
|
|
342
|
+
public async stop(status: RunStatus = 'failed', error?: string): Promise<void> {
|
|
343
|
+
if (this.isStopping) return;
|
|
344
|
+
this.isStopping = true;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
this.removeSignalHandlers();
|
|
348
|
+
|
|
349
|
+
// Update run status in DB
|
|
350
|
+
await this.db.updateRunStatus(this.runId, status, undefined, error);
|
|
351
|
+
|
|
352
|
+
// Stop all MCP clients
|
|
353
|
+
await this.mcpManager.stopAll();
|
|
354
|
+
|
|
355
|
+
this.db.close();
|
|
356
|
+
} catch (err) {
|
|
357
|
+
this.logger.error(`Error during stop/cleanup: ${err}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
314
361
|
/**
|
|
315
362
|
* Remove signal handlers
|
|
316
363
|
*/
|
|
@@ -611,6 +658,13 @@ export class WorkflowRunner {
|
|
|
611
658
|
|
|
612
659
|
this.logger.log(` ⤷ Executing step ${step.id} for ${items.length} items`);
|
|
613
660
|
|
|
661
|
+
if (items.length > WorkflowRunner.MEMORY_WARNING_THRESHOLD && !this.hasWarnedMemory) {
|
|
662
|
+
this.logger.warn(
|
|
663
|
+
` ⚠️ Warning: Large foreach loop detected (${items.length} items). This may consume significant memory and lead to instability.`
|
|
664
|
+
);
|
|
665
|
+
this.hasWarnedMemory = true;
|
|
666
|
+
}
|
|
667
|
+
|
|
614
668
|
// Evaluate concurrency if it's an expression, otherwise use the number directly
|
|
615
669
|
let concurrencyLimit = items.length;
|
|
616
670
|
if (step.concurrency !== undefined) {
|
|
@@ -631,6 +685,10 @@ export class WorkflowRunner {
|
|
|
631
685
|
await this.db.createStep(parentStepExecId, this.runId, step.id);
|
|
632
686
|
await this.db.startStep(parentStepExecId);
|
|
633
687
|
|
|
688
|
+
// Persist the foreach items in parent step for deterministic resume
|
|
689
|
+
// This ensures resume uses the same array even if expression would evaluate differently
|
|
690
|
+
await this.db.completeStep(parentStepExecId, 'pending', { __foreachItems: items });
|
|
691
|
+
|
|
634
692
|
try {
|
|
635
693
|
// Initialize results array with existing context or empty slots
|
|
636
694
|
const existingContext = this.stepContexts.get(step.id) as ForeachStepContext;
|
|
@@ -667,7 +725,15 @@ export class WorkflowRunner {
|
|
|
667
725
|
existingExec &&
|
|
668
726
|
(existingExec.status === 'success' || existingExec.status === 'skipped')
|
|
669
727
|
) {
|
|
670
|
-
|
|
728
|
+
let output: unknown = null;
|
|
729
|
+
try {
|
|
730
|
+
output = existingExec.output ? JSON.parse(existingExec.output) : null;
|
|
731
|
+
} catch (error) {
|
|
732
|
+
this.logger.warn(
|
|
733
|
+
`Failed to parse output for step ${step.id} iteration ${i}: ${error}`
|
|
734
|
+
);
|
|
735
|
+
output = { error: 'Failed to parse output' };
|
|
736
|
+
}
|
|
671
737
|
itemResults[i] = {
|
|
672
738
|
output,
|
|
673
739
|
outputs:
|
|
@@ -12,7 +12,7 @@ You are the Keystone Architect. Your goal is to design and generate high-quality
|
|
|
12
12
|
## Workflow Schema (.yaml)
|
|
13
13
|
- **name**: Unique identifier for the workflow.
|
|
14
14
|
- **description**: (Optional) Description of the workflow.
|
|
15
|
-
- **inputs**: Map of `{ type: string, default: any, description: string }` under the `inputs` key.
|
|
15
|
+
- **inputs**: Map of `{ type: 'string'|'number'|'boolean'|'array'|'object', default: any, description: string }` under the `inputs` key.
|
|
16
16
|
- **outputs**: Map of expressions (e.g., `${{ steps.id.output }}`) under the `outputs` key.
|
|
17
17
|
- **env**: (Optional) Map of workflow-level environment variables.
|
|
18
18
|
- **concurrency**: (Optional) Global concurrency limit for the workflow (number or expression).
|
|
@@ -25,7 +25,7 @@ You are the Keystone Architect. Your goal is to design and generate high-quality
|
|
|
25
25
|
- **human**: `{ id, type: 'human', message, inputType: 'confirm'|'text' }` (Note: 'confirm' returns boolean but automatically fallbacks to text if input is not yes/no)
|
|
26
26
|
- **sleep**: `{ id, type: 'sleep', duration }` (duration can be a number or expression string)
|
|
27
27
|
- **script**: `{ id, type: 'script', run, allowInsecure }` (Executes JS in a secure sandbox; set allowInsecure to true to allow fallback to insecure VM)
|
|
28
|
-
- **Common Step Fields**: `needs` (array of IDs), `if` (expression), `timeout` (ms), `retry
|
|
28
|
+
- **Common Step Fields**: `needs` (array of IDs), `if` (expression), `timeout` (ms), `retry` (`{ count, backoff: 'linear'|'exponential', baseDelay }`), `foreach`, `concurrency`, `transform`.
|
|
29
29
|
- **finally**: Optional array of steps to run at the end of the workflow, regardless of success or failure.
|
|
30
30
|
- **IMPORTANT**: Steps run in **parallel** by default. To ensure sequential execution, a step must explicitly list the previous step's ID in its `needs` array.
|
|
31
31
|
|
|
@@ -52,7 +52,9 @@ Markdown files with YAML frontmatter:
|
|
|
52
52
|
- **Custom Logic**: Use `script` steps for data manipulation that is too complex for expressions.
|
|
53
53
|
- **Agent Collaboration**: Create specialized agents for complex sub-tasks and coordinate them via `llm` steps.
|
|
54
54
|
- **Clarification**: Enable `allowClarification` in `llm` steps if the agent should be able to ask the user for missing info.
|
|
55
|
-
- **Discovery**: Use `mcpServers` in `llm` steps when the agent needs access to external tools or systems. `mcpServers` can be a list of server names or configuration objects
|
|
55
|
+
- **Discovery**: Use `mcpServers` in `llm` steps when the agent needs access to external tools or systems. `mcpServers` can be a list of server names or configuration objects:
|
|
56
|
+
- Local: `{ name, command, args, env, timeout }`
|
|
57
|
+
- Remote: `{ name, type: 'remote', url, headers, timeout }`
|
|
56
58
|
|
|
57
59
|
# Seeking Clarification
|
|
58
60
|
If you have access to an `ask` tool and the user requirements are unclear, **use it** before generating output. Ask about:
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized status constants for workflow and step execution
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const StepStatus = {
|
|
6
|
+
PENDING: 'pending',
|
|
7
|
+
SUCCESS: 'success',
|
|
8
|
+
FAILED: 'failed',
|
|
9
|
+
PAUSED: 'paused',
|
|
10
|
+
SUSPENDED: 'suspended',
|
|
11
|
+
SKIPPED: 'skipped',
|
|
12
|
+
RUNNING: 'running',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export type StepStatusType = (typeof StepStatus)[keyof typeof StepStatus];
|
|
16
|
+
|
|
17
|
+
export const WorkflowStatus = {
|
|
18
|
+
COMPLETED: 'completed',
|
|
19
|
+
FAILED: 'failed',
|
|
20
|
+
PAUSED: 'paused',
|
|
21
|
+
SUSPENDED: 'suspended',
|
|
22
|
+
RUNNING: 'running',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type WorkflowStatusType = (typeof WorkflowStatus)[keyof typeof WorkflowStatus];
|
package/src/ui/dashboard.tsx
CHANGED
|
@@ -28,7 +28,9 @@ describe('AuthManager', () => {
|
|
|
28
28
|
if (fs.existsSync(TEMP_AUTH_FILE)) {
|
|
29
29
|
try {
|
|
30
30
|
fs.rmSync(TEMP_AUTH_FILE);
|
|
31
|
-
} catch (e) {
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// Ignore likely missing file error
|
|
33
|
+
}
|
|
32
34
|
}
|
|
33
35
|
global.fetch = originalFetch;
|
|
34
36
|
// Set environment variable for EACH test to be safe
|
|
@@ -26,6 +26,9 @@ export const COPILOT_HEADERS = {
|
|
|
26
26
|
|
|
27
27
|
const GITHUB_CLIENT_ID = '013444988716b5155f4c'; // GitHub CLI Client ID
|
|
28
28
|
|
|
29
|
+
/** Buffer time in seconds before token expiry to trigger refresh (5 minutes) */
|
|
30
|
+
const TOKEN_REFRESH_BUFFER_SECONDS = 300;
|
|
31
|
+
|
|
29
32
|
export class AuthManager {
|
|
30
33
|
private static getAuthPath(): string {
|
|
31
34
|
if (process.env.KEYSTONE_AUTH_PATH) {
|
|
@@ -53,7 +56,14 @@ export class AuthManager {
|
|
|
53
56
|
static save(data: AuthData): void {
|
|
54
57
|
const path = AuthManager.getAuthPath();
|
|
55
58
|
const current = AuthManager.load();
|
|
56
|
-
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(
|
|
63
|
+
'Failed to save auth data:',
|
|
64
|
+
error instanceof Error ? error.message : String(error)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
static async initGitHubDeviceLogin(): Promise<{
|
|
@@ -156,7 +166,7 @@ export class AuthManager {
|
|
|
156
166
|
if (
|
|
157
167
|
auth.copilot_token &&
|
|
158
168
|
auth.copilot_expires_at &&
|
|
159
|
-
auth.copilot_expires_at > Date.now() / 1000 +
|
|
169
|
+
auth.copilot_expires_at > Date.now() / 1000 + TOKEN_REFRESH_BUFFER_SECONDS
|
|
160
170
|
) {
|
|
161
171
|
return auth.copilot_token;
|
|
162
172
|
}
|
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
-
import { existsSync, mkdirSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
2
|
import type { Config } from '../parser/config-schema';
|
|
5
3
|
import { ConfigLoader } from './config-loader';
|
|
6
4
|
|
|
7
5
|
describe('ConfigLoader', () => {
|
|
8
|
-
const tempDir = join(process.cwd(), '.keystone-test');
|
|
9
|
-
|
|
10
6
|
afterEach(() => {
|
|
11
7
|
ConfigLoader.clear();
|
|
12
|
-
if (existsSync(tempDir)) {
|
|
13
|
-
try {
|
|
14
|
-
// Simple recursive delete
|
|
15
|
-
const files = ['config.yaml', 'config.yml'];
|
|
16
|
-
for (const file of files) {
|
|
17
|
-
const path = join(tempDir, file);
|
|
18
|
-
if (existsSync(path)) {
|
|
19
|
-
// fs.unlinkSync(path);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
// rmdirSync(tempDir);
|
|
23
|
-
} catch (e) {}
|
|
24
|
-
}
|
|
25
8
|
});
|
|
26
9
|
|
|
27
10
|
it('should allow setting and clearing config', () => {
|
|
@@ -33,6 +16,7 @@ describe('ConfigLoader', () => {
|
|
|
33
16
|
model_mappings: {},
|
|
34
17
|
storage: { retention_days: 30 },
|
|
35
18
|
workflows_directory: 'workflows',
|
|
19
|
+
mcp_servers: {},
|
|
36
20
|
};
|
|
37
21
|
|
|
38
22
|
ConfigLoader.setConfig(mockConfig);
|
|
@@ -58,6 +42,7 @@ describe('ConfigLoader', () => {
|
|
|
58
42
|
},
|
|
59
43
|
storage: { retention_days: 30 },
|
|
60
44
|
workflows_directory: 'workflows',
|
|
45
|
+
mcp_servers: {},
|
|
61
46
|
};
|
|
62
47
|
ConfigLoader.setConfig(mockConfig);
|
|
63
48
|
|
package/src/utils/mermaid.ts
CHANGED
|
@@ -71,14 +71,6 @@ export function generateMermaidGraph(workflow: Workflow): string {
|
|
|
71
71
|
return lines.join('\n');
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
* Renders a workflow as a local ASCII graph using dagre for layout.
|
|
76
|
-
*/
|
|
77
|
-
export async function renderMermaidAsAscii(_mermaid: string): Promise<string | null> {
|
|
78
|
-
// We no longer use the mermaid string for ASCII, we use the workflow object directly.
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
74
|
export function renderWorkflowAsAscii(workflow: Workflow): string {
|
|
83
75
|
const g = new dagre.graphlib.Graph();
|
|
84
76
|
g.setGraph({ rankdir: 'LR', nodesep: 2, edgesep: 1, ranksep: 4 });
|