keystone-cli 1.0.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) 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} +4 -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 +40 -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 +28 -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} +2 -1
  117. package/src/templates/patterns/composition-parent.yaml +18 -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/composition-parent.yaml +0 -14
  154. package/src/templates/invalid.yaml +0 -5
@@ -0,0 +1,69 @@
1
+ import { describe, expect, mock, test } from 'bun:test';
2
+ import type { Step, Workflow } from '../parser/schema';
3
+ import { WorkflowRunner } from './workflow-runner';
4
+
5
+ describe('WorkflowRunner qualityGate', () => {
6
+ test('should refine output until the quality gate approves', async () => {
7
+ const workflow: Workflow = {
8
+ name: 'quality-gate-test',
9
+ outputs: {
10
+ final: '${{ steps.generate.output }}',
11
+ },
12
+ steps: [
13
+ {
14
+ id: 'generate',
15
+ type: 'llm',
16
+ agent: 'test-agent',
17
+ prompt: 'draft the content',
18
+ qualityGate: {
19
+ agent: 'reviewer',
20
+ maxAttempts: 1,
21
+ },
22
+ } as Step,
23
+ ],
24
+ };
25
+
26
+ let draftAttempt = 0;
27
+ let reviewAttempt = 0;
28
+
29
+ const executeStepMock = mock(async (step: Step) => {
30
+ if (step.id === 'generate') {
31
+ draftAttempt += 1;
32
+ return {
33
+ status: 'success',
34
+ output: draftAttempt === 1 ? 'draft v1' : 'draft v2',
35
+ };
36
+ }
37
+
38
+ if (step.id === 'generate-quality-review') {
39
+ reviewAttempt += 1;
40
+ return {
41
+ status: 'success',
42
+ output: {
43
+ approved: reviewAttempt > 1,
44
+ issues: reviewAttempt > 1 ? [] : ['Needs more detail'],
45
+ suggestions: reviewAttempt > 1 ? [] : ['Expand the draft'],
46
+ },
47
+ };
48
+ }
49
+
50
+ return { status: 'failed', output: null, error: 'Unexpected step' };
51
+ });
52
+
53
+ const runner = new WorkflowRunner(workflow, {
54
+ dbPath: ':memory:',
55
+ executeStep: executeStepMock as unknown as typeof import('./step-executor').executeStep,
56
+ logger: {
57
+ log: () => {},
58
+ error: () => {},
59
+ warn: () => {},
60
+ info: () => {},
61
+ },
62
+ });
63
+
64
+ const outputs = await runner.run();
65
+
66
+ expect(outputs.final).toBe('draft v2');
67
+ expect(executeStepMock).toHaveBeenCalledTimes(4);
68
+ });
69
+ });
@@ -3,8 +3,6 @@ import type { Step, Workflow } from '../parser/schema';
3
3
  import * as StepExecutor from './step-executor';
4
4
  import { WorkflowRunner } from './workflow-runner';
5
5
 
6
- // Mock the LLM Adapter
7
-
8
6
  describe('WorkflowRunner Reflexion', () => {
9
7
  beforeEach(() => {
10
8
  jest.restoreAllMocks();
@@ -33,42 +31,36 @@ describe('WorkflowRunner Reflexion', () => {
33
31
  content: JSON.stringify({ run: 'echo "fixed"' }),
34
32
  },
35
33
  }),
36
- // biome-ignore lint/suspicious/noExplicitAny: mock adapter
37
34
  } as any,
38
35
  resolvedModel: 'mock-model',
39
36
  });
40
37
 
38
+ const spy = jest.fn();
39
+
41
40
  const runner = new WorkflowRunner(workflow, {
42
41
  logger: { log: () => {}, error: () => {}, warn: () => {} },
43
42
  dbPath: ':memory:',
44
43
  getAdapter: mockGetAdapter,
44
+ executeStep: spy as any,
45
45
  });
46
46
 
47
- // biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
48
47
  const db = (runner as any).db;
49
48
  await db.createRun(runner.runId, workflow.name, {});
50
49
 
51
- const spy = jest.spyOn(StepExecutor, 'executeStep');
52
-
53
50
  // First call fails, Reflexion logic kicks in (calling mocked getAdapter),
54
51
  // then it retries with corrected command.
55
- spy.mockImplementation(async (step, _context) => {
56
- // Original failing command
57
- // biome-ignore lint/suspicious/noExplicitAny: Accessing run property dynamically
58
- if ((step as any).run === 'exit 1') {
52
+ spy.mockImplementation(async (step: any) => {
53
+ if (step.run === 'exit 1') {
59
54
  return { status: 'failed', output: null, error: 'Command failed' };
60
55
  }
61
56
 
62
- // Corrected command from mock
63
- // biome-ignore lint/suspicious/noExplicitAny: Accessing run property dynamically
64
- if ((step as any).run === 'echo "fixed"') {
57
+ if (step.run === 'echo "fixed"') {
65
58
  return { status: 'success', output: 'fixed' };
66
59
  }
67
60
 
68
61
  return { status: 'failed', output: null, error: 'Unknown step' };
69
62
  });
70
63
 
71
- // biome-ignore lint/suspicious/noExplicitAny: Accessing private property for testing
72
64
  await (runner as any).executeStepWithForeach(workflow.steps[0]);
73
65
 
74
66
  // Expectations:
@@ -78,10 +70,7 @@ describe('WorkflowRunner Reflexion', () => {
78
70
  expect(spy).toHaveBeenCalledTimes(2);
79
71
 
80
72
  // Verify the second call had the corrected command
81
- // biome-ignore lint/suspicious/noExplicitAny: mock call args typing
82
73
  const secondCallArg = spy.mock.calls[1][0] as any;
83
74
  expect(secondCallArg.run).toBe('echo "fixed"');
84
-
85
- spy.mockRestore();
86
75
  });
87
76
  });
@@ -1,3 +1,4 @@
1
+ import { LIMITS } from '../utils/constants.ts';
1
2
  import type { Logger } from '../utils/logger.ts';
2
3
 
3
4
  export type ReleaseFunction = () => void;
@@ -19,6 +20,12 @@ interface QueuedRequest {
19
20
  timestamp: number;
20
21
  }
21
22
 
23
+ /** Default maximum queue size to prevent unbounded memory growth */
24
+ const DEFAULT_MAX_QUEUE_SIZE = 1000;
25
+
26
+ /** Default resource pool limit */
27
+ const DEFAULT_POOL_LIMIT = 10;
28
+
22
29
  export class ResourcePoolManager {
23
30
  private pools = new Map<
24
31
  string,
@@ -31,12 +38,14 @@ export class ResourcePoolManager {
31
38
  }
32
39
  >();
33
40
  private globalLimit: number;
41
+ private maxQueueSize: number;
34
42
 
35
43
  constructor(
36
44
  private logger: Logger,
37
- options: { defaultLimit?: number; pools?: Record<string, number> } = {}
45
+ options: { defaultLimit?: number; maxQueueSize?: number; pools?: Record<string, number> } = {}
38
46
  ) {
39
- this.globalLimit = options.defaultLimit || 10;
47
+ this.globalLimit = options.defaultLimit || DEFAULT_POOL_LIMIT;
48
+ this.maxQueueSize = options.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE;
40
49
  if (options.pools) {
41
50
  for (const [name, limit] of Object.entries(options.pools)) {
42
51
  this.pools.set(name, { limit, active: 0, queue: [], totalAcquired: 0, totalWaitTimeMs: 0 });
@@ -47,11 +56,30 @@ export class ResourcePoolManager {
47
56
  /**
48
57
  * Acquire a resource from a pool.
49
58
  * If the pool doesn't exist, it uses the global limit.
59
+ *
60
+ * @param poolName - Name of the pool to acquire from
61
+ * @param options.priority - Higher priority requests are processed first (default: 0)
62
+ * @param options.signal - AbortSignal for cancellation
63
+ * @param options.timeout - Maximum time to wait for a slot (ms), rejects with timeout error if exceeded
50
64
  */
51
65
  async acquire(
52
66
  poolName: string,
53
- options: { priority?: number; signal?: AbortSignal } = {}
67
+ options: { priority?: number; signal?: AbortSignal; timeout?: number } = {}
54
68
  ): Promise<ReleaseFunction> {
69
+ // Validate timeout parameter - must be positive, finite, and within max bounds
70
+ if (options.timeout !== undefined) {
71
+ if (
72
+ typeof options.timeout !== 'number' ||
73
+ !Number.isFinite(options.timeout) ||
74
+ options.timeout <= 0 ||
75
+ options.timeout > LIMITS.MAX_RESOURCE_POOL_TIMEOUT_MS
76
+ ) {
77
+ throw new Error(
78
+ `Invalid timeout value: ${options.timeout}. Timeout must be a positive number <= ${LIMITS.MAX_RESOURCE_POOL_TIMEOUT_MS}ms.`
79
+ );
80
+ }
81
+ }
82
+
55
83
  let pool = this.pools.get(poolName);
56
84
  if (!pool) {
57
85
  // Create a pool for this name if it doesn't exist, using global limit
@@ -71,6 +99,13 @@ export class ResourcePoolManager {
71
99
  return this.createReleaseFn(poolName);
72
100
  }
73
101
 
102
+ // Check queue size limit
103
+ if (pool.queue.length >= this.maxQueueSize) {
104
+ throw new Error(
105
+ `Resource pool "${poolName}" queue is full (${this.maxQueueSize}). Consider increasing concurrency limits or reducing parallel work.`
106
+ );
107
+ }
108
+
74
109
  // Queue the request
75
110
  const timestamp = Date.now();
76
111
  const poolRef = pool;
@@ -90,19 +125,63 @@ export class ResourcePoolManager {
90
125
  return a.timestamp - b.timestamp;
91
126
  });
92
127
 
128
+ // Handle timeout
129
+ let timeoutId: Timer | undefined;
130
+ if (options.timeout && options.timeout > 0) {
131
+ timeoutId = setTimeout(() => {
132
+ const index = poolRef.queue.indexOf(request);
133
+ if (index !== -1) {
134
+ poolRef.queue.splice(index, 1);
135
+ reject(
136
+ new Error(`Resource pool "${poolName}" acquire timeout after ${options.timeout}ms`)
137
+ );
138
+ }
139
+ }, options.timeout);
140
+ }
141
+
93
142
  // Handle abort signal
143
+ const cleanup = () => {
144
+ if (timeoutId) clearTimeout(timeoutId);
145
+ };
146
+
94
147
  if (options.signal) {
95
- options.signal.addEventListener(
96
- 'abort',
97
- () => {
98
- const index = poolRef.queue.indexOf(request);
99
- if (index !== -1) {
100
- poolRef.queue.splice(index, 1);
101
- reject(new Error('Acquisition aborted'));
102
- }
103
- },
104
- { once: true }
105
- );
148
+ const onAbort = () => {
149
+ cleanup();
150
+ const index = poolRef.queue.indexOf(request);
151
+ if (index !== -1) {
152
+ poolRef.queue.splice(index, 1);
153
+ reject(new Error('Acquisition aborted'));
154
+ }
155
+ };
156
+ options.signal.addEventListener('abort', onAbort, { once: true });
157
+
158
+ // Wrap accessors to remove listener
159
+ const originalResolve = resolve;
160
+ const originalReject = reject;
161
+
162
+ request.resolve = (release) => {
163
+ cleanup();
164
+ options.signal?.removeEventListener('abort', onAbort);
165
+ originalResolve(release);
166
+ };
167
+ request.reject = (err) => {
168
+ cleanup();
169
+ options.signal?.removeEventListener('abort', onAbort);
170
+ originalReject(err);
171
+ };
172
+ } else {
173
+ // Still need to wrap for timeout cleanup
174
+ const originalResolve = resolve;
175
+ const originalReject = reject;
176
+
177
+ request.resolve = (release) => {
178
+ cleanup();
179
+ originalResolve(release);
180
+ };
181
+ request.reject = (err) => {
182
+ cleanup();
183
+ originalReject(err);
184
+ };
106
185
  }
107
186
  });
108
187
  }
@@ -161,4 +240,13 @@ export class ResourcePoolManager {
161
240
  totalWaitTimeMs: pool.totalWaitTimeMs,
162
241
  }));
163
242
  }
243
+
244
+ /**
245
+ * Check if a pool has capacity for another task.
246
+ */
247
+ hasCapacity(poolName: string): boolean {
248
+ const pool = this.pools.get(poolName);
249
+ if (!pool) return true; // Global limit will be checked on acquire
250
+ return pool.active < pool.limit;
251
+ }
164
252
  }
@@ -0,0 +1,144 @@
1
+ import type { ExpressionContext } from '../../expression/evaluator.ts';
2
+ import { ExpressionEvaluator } from '../../expression/evaluator.ts';
3
+ import type { Workflow } from '../../parser/schema.ts';
4
+ import type { Logger } from '../../utils/logger.ts';
5
+ import type { WorkflowState } from '../workflow-state.ts';
6
+
7
+ /**
8
+ * Service for building the expression context for workflow steps.
9
+ */
10
+ export class ContextBuilder {
11
+ constructor(
12
+ private workflow: Workflow,
13
+ private inputs: Record<string, unknown>,
14
+ private secretValues: string[],
15
+ private state: WorkflowState,
16
+ private logger: Logger
17
+ ) {}
18
+
19
+ /**
20
+ * Builds the context object used for expression evaluation.
21
+ */
22
+ public buildContext(
23
+ secrets: Record<string, string>,
24
+ envOverrides: Record<string, string> = {},
25
+ memory: Record<string, unknown> = {},
26
+ lastFailedStep?: string,
27
+ item?: unknown,
28
+ index?: number
29
+ ): ExpressionContext {
30
+ const stepsContext: Record<
31
+ string,
32
+ {
33
+ output?: unknown;
34
+ outputs?: Record<string, unknown>;
35
+ status?: string;
36
+ error?: string;
37
+ items?: any[];
38
+ }
39
+ > = {};
40
+
41
+ for (const [stepId, ctx] of this.state.entries()) {
42
+ stepsContext[stepId] = {
43
+ output: ctx.output,
44
+ outputs: ctx.outputs,
45
+ status: ctx.status,
46
+ error: ctx.error,
47
+ ...(ctx && 'items' in ctx ? { items: (ctx as any).items } : {}),
48
+ };
49
+ }
50
+
51
+ const baseContext: ExpressionContext = {
52
+ inputs: this.inputs,
53
+ secrets: secrets,
54
+ secretValues: this.secretValues,
55
+ steps: stepsContext,
56
+ item,
57
+ index,
58
+ env: {},
59
+ envOverrides: envOverrides,
60
+ memory: memory,
61
+ output: item
62
+ ? undefined
63
+ : this.state.get(this.workflow.steps.find((s) => !s.foreach)?.id || '')?.output,
64
+ last_failed_step: lastFailedStep ? { id: lastFailedStep, error: '' } : undefined,
65
+ };
66
+
67
+ const resolvedEnv: Record<string, string> = {};
68
+ for (const [key, value] of Object.entries(process.env)) {
69
+ if (value !== undefined) {
70
+ resolvedEnv[key] = value;
71
+ }
72
+ }
73
+
74
+ if (this.workflow.env) {
75
+ for (const [key, value] of Object.entries(this.workflow.env)) {
76
+ try {
77
+ resolvedEnv[key] = ExpressionEvaluator.evaluateString(value, {
78
+ ...baseContext,
79
+ env: resolvedEnv,
80
+ });
81
+ } catch (error) {
82
+ this.logger.warn(
83
+ `Warning: Failed to evaluate workflow env "${key}": ${error instanceof Error ? error.message : String(error)}`
84
+ );
85
+ }
86
+ }
87
+ }
88
+
89
+ baseContext.env = { ...resolvedEnv, ...envOverrides };
90
+ return baseContext;
91
+ }
92
+ /**
93
+ * Builds input object for a specific step.
94
+ */
95
+ public buildStepInputs(step: any, context: ExpressionContext): Record<string, unknown> {
96
+ const stripUndefined = (value: Record<string, unknown>) => {
97
+ const result: Record<string, unknown> = {};
98
+ for (const [key, val] of Object.entries(value)) {
99
+ if (val !== undefined) {
100
+ result[key] = val;
101
+ }
102
+ }
103
+ return result;
104
+ };
105
+
106
+ switch (step.type) {
107
+ case 'shell': {
108
+ let env: Record<string, string> | undefined;
109
+ if (step.env) {
110
+ env = {};
111
+ for (const [key, value] of Object.entries(step.env)) {
112
+ env[key] = ExpressionEvaluator.evaluateString(value as string, context);
113
+ }
114
+ }
115
+ return stripUndefined({
116
+ run: ExpressionEvaluator.evaluateString((step as any).run, context),
117
+ env,
118
+ });
119
+ }
120
+ case 'file': {
121
+ return stripUndefined({
122
+ path: ExpressionEvaluator.evaluateString((step as any).path, context),
123
+ content: (step as any).content
124
+ ? ExpressionEvaluator.evaluateString((step as any).content, context)
125
+ : undefined,
126
+ op: (step as any).op,
127
+ });
128
+ }
129
+ default: {
130
+ // For most steps, we just pass through properties which might contain expressions
131
+ const inputs: Record<string, unknown> = {};
132
+ for (const [key, value] of Object.entries(step)) {
133
+ if (key === 'id' || key === 'type' || key === 'if' || key === 'foreach') continue;
134
+ if (typeof value === 'string' && value.includes('{{')) {
135
+ inputs[key] = ExpressionEvaluator.evaluateString(value, context);
136
+ } else {
137
+ inputs[key] = value;
138
+ }
139
+ }
140
+ return stripUndefined(inputs);
141
+ }
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,105 @@
1
+ import { RedactionBuffer, Redactor } from '../../utils/redactor';
2
+
3
+ export class SecretManager {
4
+ private secretValues: string[] = [];
5
+ private redactor: Redactor;
6
+
7
+ constructor(private initialSecrets: Record<string, string> = {}) {
8
+ this.redactor = new Redactor(initialSecrets, { forcedSecrets: [] });
9
+ }
10
+
11
+ public static collectSecretValues(
12
+ value: unknown,
13
+ sink: Set<string>,
14
+ seen: WeakSet<object> = new WeakSet()
15
+ ): void {
16
+ if (value === null || value === undefined) return;
17
+
18
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
19
+ sink.add(String(value));
20
+ return;
21
+ }
22
+
23
+ if (typeof value !== 'object') return;
24
+
25
+ if (seen.has(value)) return;
26
+ seen.add(value);
27
+
28
+ if (Array.isArray(value)) {
29
+ for (const item of value) {
30
+ SecretManager.collectSecretValues(item, sink, seen);
31
+ }
32
+ return;
33
+ }
34
+
35
+ for (const item of Object.values(value as Record<string, unknown>)) {
36
+ SecretManager.collectSecretValues(item, sink, seen);
37
+ }
38
+ }
39
+
40
+ public loadSecrets(optionsSecrets: Record<string, string> = {}): Record<string, string> {
41
+ const secrets: Record<string, string> = { ...this.initialSecrets, ...optionsSecrets };
42
+
43
+ // Strict allowlist for safe system environment variables
44
+ const safeSystemVars = ['PATH', 'HOME', 'PWD', 'SHELL', 'LANG', 'TZ'];
45
+ for (const key of safeSystemVars) {
46
+ const value = process.env[key];
47
+ if (value) {
48
+ secrets[key] = value;
49
+ }
50
+ }
51
+
52
+ // Include pattern-matched secrets from Bun.env (safe-ish way to get common secrets)
53
+ const secretPatterns = [/token/i, /key/i, /secret/i, /password/i, /auth/i, /api/i];
54
+ for (const [key, value] of Object.entries(Bun.env)) {
55
+ if (value && secretPatterns.some((p) => p.test(key))) {
56
+ // Skip common system non-secret variables that might match patterns
57
+ if (safeSystemVars.includes(key)) continue;
58
+ secrets[key] = value;
59
+ }
60
+ }
61
+
62
+ return secrets;
63
+ }
64
+
65
+ public getSecrets(optionsSecrets: Record<string, string> = {}): Record<string, string> {
66
+ return this.loadSecrets(optionsSecrets);
67
+ }
68
+
69
+ public getRedactor(): Redactor {
70
+ return this.redactor;
71
+ }
72
+
73
+ public setSecretValues(values: string[]): void {
74
+ this.secretValues = values;
75
+ this.redactor = new Redactor(this.initialSecrets, { forcedSecrets: this.secretValues });
76
+ }
77
+
78
+ public getSecretValues(): string[] {
79
+ return this.secretValues;
80
+ }
81
+
82
+ public redact(content: string): string {
83
+ return this.redactor.redact(content);
84
+ }
85
+
86
+ public createRedactionBuffer(): RedactionBuffer {
87
+ return new RedactionBuffer(this.redactor);
88
+ }
89
+ public redactAtRest = true;
90
+
91
+ /**
92
+ * Redact secrets from a value for storage (if enabled)
93
+ */
94
+ public redactForStorage<T>(value: T): T {
95
+ if (!this.redactAtRest) return value;
96
+ return this.redactor.redactValue(value) as T;
97
+ }
98
+
99
+ /**
100
+ * Redact secrets from a value
101
+ */
102
+ public redactValue<T>(value: T): T {
103
+ return this.redactor.redactValue(value) as T;
104
+ }
105
+ }
@@ -0,0 +1,131 @@
1
+ import type { Workflow, WorkflowInput } from '../../parser/schema.ts';
2
+ import { validateJsonSchema } from '../../utils/schema-validator.ts';
3
+ import { SecretManager } from './secret-manager.ts';
4
+
5
+ /**
6
+ * Service for validating workflow inputs and applying defaults.
7
+ */
8
+ export class WorkflowValidator {
9
+ public static readonly REDACTED_PLACEHOLDER = '[REDACTED]';
10
+
11
+ constructor(
12
+ private workflow: Workflow,
13
+ private inputs: Record<string, unknown>
14
+ ) {}
15
+
16
+ /**
17
+ * Apply workflow defaults to inputs and validate types.
18
+ * Returns the set of secret values found in the inputs.
19
+ */
20
+ public applyDefaultsAndValidate(): { secretValues: string[] } {
21
+ if (!this.workflow.inputs) return { secretValues: [] };
22
+
23
+ const secretValues = new Set<string>();
24
+
25
+ for (const [key, config] of Object.entries(this.workflow.inputs)) {
26
+ const inputConfig = config as WorkflowInput;
27
+
28
+ // Apply default if missing
29
+ if (this.inputs[key] === undefined && inputConfig.default !== undefined) {
30
+ this.inputs[key] = inputConfig.default;
31
+ }
32
+
33
+ if (inputConfig.secret) {
34
+ if (this.inputs[key] === WorkflowValidator.REDACTED_PLACEHOLDER) {
35
+ throw new Error(
36
+ `Secret input "${key}" was redacted at rest. Please provide it again to resume this run.`
37
+ );
38
+ }
39
+ }
40
+
41
+ // Validate required inputs
42
+ if (this.inputs[key] === undefined) {
43
+ throw new Error(`Missing required input: ${key}`);
44
+ }
45
+
46
+ // Basic type validation
47
+ const value = this.inputs[key];
48
+ const type = inputConfig.type.toLowerCase();
49
+
50
+ if (type === 'string' && typeof value !== 'string') {
51
+ throw new Error(`Input "${key}" must be a string, got ${typeof value}`);
52
+ }
53
+ if (type === 'number' && typeof value !== 'number') {
54
+ throw new Error(`Input "${key}" must be a number, got ${typeof value}`);
55
+ }
56
+ if (type === 'boolean' && typeof value !== 'boolean') {
57
+ throw new Error(`Input "${key}" must be a boolean, got ${typeof value}`);
58
+ }
59
+ if (type === 'array' && !Array.isArray(value)) {
60
+ throw new Error(`Input "${key}" must be an array, got ${typeof value}`);
61
+ }
62
+ if (
63
+ type === 'object' &&
64
+ (typeof value !== 'object' || value === null || Array.isArray(value))
65
+ ) {
66
+ throw new Error(`Input "${key}" must be an object, got ${typeof value}`);
67
+ }
68
+
69
+ if (inputConfig.values) {
70
+ if (type !== 'string' && type !== 'number' && type !== 'boolean') {
71
+ throw new Error(`Input "${key}" cannot use enum values with type "${type}"`);
72
+ }
73
+ for (const allowed of inputConfig.values) {
74
+ const matchesType =
75
+ (type === 'string' && typeof allowed === 'string') ||
76
+ (type === 'number' && typeof allowed === 'number') ||
77
+ (type === 'boolean' && typeof allowed === 'boolean');
78
+ if (!matchesType) {
79
+ throw new Error(
80
+ `Input "${key}" enum value ${JSON.stringify(allowed)} must be a ${type}`
81
+ );
82
+ }
83
+ }
84
+ if (!inputConfig.values.includes(value as string | number | boolean)) {
85
+ throw new Error(
86
+ `Input "${key}" must be one of: ${inputConfig.values.map((v) => JSON.stringify(v)).join(', ')}`
87
+ );
88
+ }
89
+ }
90
+
91
+ if (
92
+ inputConfig.secret &&
93
+ value !== undefined &&
94
+ value !== WorkflowValidator.REDACTED_PLACEHOLDER
95
+ ) {
96
+ SecretManager.collectSecretValues(value, secretValues, new WeakSet());
97
+ }
98
+ }
99
+
100
+ return { secretValues: Array.from(secretValues) };
101
+ }
102
+
103
+ /**
104
+ * Validate data against a JSON schema.
105
+ */
106
+ public validateSchema(
107
+ kind: 'input' | 'output',
108
+ schema: unknown,
109
+ data: unknown,
110
+ stepId: string
111
+ ): void {
112
+ try {
113
+ const result = validateJsonSchema(schema, data);
114
+ if (result.valid) return;
115
+ const details = result.errors.map((line: string) => ` - ${line}`).join('\n');
116
+ throw new Error(
117
+ `${kind === 'input' ? 'Input' : 'Output'} schema validation failed for step "${stepId}":\n${details}`
118
+ );
119
+ } catch (error) {
120
+ if (error instanceof Error) {
121
+ if (error.message.includes('schema validation failed for step')) {
122
+ throw error;
123
+ }
124
+ throw new Error(
125
+ `${kind === 'input' ? 'Input' : 'Output'} schema error for step "${stepId}": ${error.message}`
126
+ );
127
+ }
128
+ throw error;
129
+ }
130
+ }
131
+ }