keystone-cli 0.4.3 → 0.5.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 (36) hide show
  1. package/README.md +29 -4
  2. package/package.json +4 -16
  3. package/src/cli.ts +64 -4
  4. package/src/db/workflow-db.ts +16 -7
  5. package/src/expression/evaluator.audit.test.ts +67 -0
  6. package/src/expression/evaluator.test.ts +15 -2
  7. package/src/expression/evaluator.ts +102 -29
  8. package/src/parser/agent-parser.test.ts +6 -2
  9. package/src/parser/config-schema.ts +2 -0
  10. package/src/parser/schema.ts +2 -0
  11. package/src/parser/workflow-parser.test.ts +6 -2
  12. package/src/parser/workflow-parser.ts +22 -11
  13. package/src/runner/audit-verification.test.ts +12 -8
  14. package/src/runner/llm-adapter.ts +49 -12
  15. package/src/runner/llm-executor.test.ts +24 -6
  16. package/src/runner/llm-executor.ts +76 -44
  17. package/src/runner/mcp-client.audit.test.ts +79 -0
  18. package/src/runner/mcp-client.ts +103 -20
  19. package/src/runner/mcp-manager.ts +8 -2
  20. package/src/runner/shell-executor.test.ts +33 -15
  21. package/src/runner/shell-executor.ts +110 -39
  22. package/src/runner/step-executor.test.ts +30 -2
  23. package/src/runner/timeout.ts +2 -2
  24. package/src/runner/tool-integration.test.ts +8 -2
  25. package/src/runner/workflow-runner.ts +95 -29
  26. package/src/templates/agents/keystone-architect.md +5 -3
  27. package/src/types/status.ts +25 -0
  28. package/src/ui/dashboard.tsx +3 -1
  29. package/src/utils/auth-manager.test.ts +3 -1
  30. package/src/utils/auth-manager.ts +12 -2
  31. package/src/utils/config-loader.test.ts +2 -17
  32. package/src/utils/mermaid.ts +0 -8
  33. package/src/utils/redactor.ts +115 -22
  34. package/src/utils/sandbox.test.ts +9 -13
  35. package/src/utils/sandbox.ts +40 -53
  36. 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
  };
@@ -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
- throw new Error(
148
- `Failed to parse inputs from run: ${error instanceof Error ? error.message : String(error)}`
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
- const output = exec.output ? JSON.parse(exec.output) : null;
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
- // We need to know the total expected items to decide if the whole step is complete
215
- // Evaluate the foreach expression again
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
- try {
218
- const baseContext = this.buildContext();
219
- const foreachExpr = stepDef.foreach;
220
- if (foreachExpr) {
221
- const foreachItems = ExpressionEvaluator.evaluate(foreachExpr, baseContext);
222
- if (Array.isArray(foreachItems)) {
223
- expectedCount = foreachItems.length;
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
- const output = exec.output ? JSON.parse(exec.output) : null;
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
- try {
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
- const output = existingExec.output ? JSON.parse(existingExec.output) : null;
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`, `foreach`, `concurrency`, `transform`.
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 `{ name, command, args, env }`.
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];
@@ -34,7 +34,9 @@ const Dashboard = () => {
34
34
  }
35
35
  return sum;
36
36
  }, 0);
37
- } catch (e) {}
37
+ } catch (e) {
38
+ // Ignore write error
39
+ }
38
40
  return { ...run, total_tokens };
39
41
  });
40
42
  setRuns(runsWithUsage);
@@ -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
- writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2));
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 + 300
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
 
@@ -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 });