keystone-cli 1.1.2 → 1.3.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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared types for Dynamic Step feature
3
+ */
4
+ import type { StepResult } from './types.ts';
5
+
6
+ export interface GeneratedStep {
7
+ id: string;
8
+ name: string;
9
+ type: 'llm' | 'shell' | 'workflow' | 'file' | 'request';
10
+ agent?: string;
11
+ prompt?: string;
12
+ run?: string;
13
+ path?: string;
14
+ op?: 'read' | 'write' | 'append';
15
+ content?: string;
16
+ needs?: string[];
17
+ inputs?: Record<string, unknown>;
18
+ allowStepFailure?: boolean;
19
+ }
20
+
21
+ export interface DynamicPlan {
22
+ workflow_id?: string;
23
+ steps: GeneratedStep[];
24
+ notes?: string;
25
+ }
26
+
27
+ /**
28
+ * State for tracking dynamic step execution
29
+ */
30
+ export interface DynamicStepState {
31
+ // Identity
32
+ id?: string; // Database ID (optional for in-memory)
33
+ workflowId: string; // The workflow instance ID
34
+ runId?: string; // The higher-level run ID
35
+ stepId?: string; // The step ID within the workflow
36
+
37
+ // State
38
+ status: 'planning' | 'awaiting_confirmation' | 'executing' | 'completed' | 'failed';
39
+ generatedPlan: DynamicPlan;
40
+
41
+ // Execution tracking
42
+ stepResults: Map<string, StepResult>;
43
+ currentStepIndex: number;
44
+ replanCount: number;
45
+
46
+ // Timing & Metadata
47
+ startedAt: string;
48
+ completedAt?: string;
49
+ error?: string;
50
+ metadata?: Record<string, unknown>;
51
+ }
52
+
53
+ /**
54
+ * Individual generated step execution record
55
+ */
56
+ export interface DynamicStepExecution {
57
+ id: string;
58
+ stateId: string;
59
+ stepId: string;
60
+ stepName: string;
61
+ stepType: string;
62
+ stepDefinition: GeneratedStep;
63
+ status: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
64
+ output?: unknown;
65
+ error?: string;
66
+ startedAt?: string;
67
+ completedAt?: string;
68
+ executionOrder: number;
69
+ }
@@ -36,13 +36,15 @@ describe('file-executor', () => {
36
36
  const blocks: SearchReplaceBlock[] = [
37
37
  { search: ' indent', replace: 'dedent' }, // one space vs two
38
38
  ];
39
- // Should not find ' indent' because actual is ' indent' (double space) if strict?
40
- // Actually ' indent'.split(' indent') -> [' ', ''] -> found once!
41
- // Wait ' indent'.includes(' indent') is true.
42
- // ' indent'.split(' indent') -> [' ', ''] (length 2).
43
- // So it works.
44
39
  const result = applySearchReplaceBlocks(content, blocks);
45
40
  expect(result).toBe(' dedent');
46
41
  });
42
+
43
+ it('should handle CRLF line endings by treating them as LF', () => {
44
+ const content = 'line1\r\nline2\r\nline3';
45
+ const blocks: SearchReplaceBlock[] = [{ search: 'line2', replace: 'modified' }];
46
+ const result = applySearchReplaceBlocks(content, blocks);
47
+ expect(result).toBe('line1\nmodified\nline3');
48
+ });
47
49
  });
48
50
  });
@@ -116,7 +116,7 @@ export function applyUnifiedDiff(content: string, patch: string, targetPath: str
116
116
  const diff = parseUnifiedDiff(patch);
117
117
  assertDiffMatchesTarget(diff.newFile || diff.originalFile, targetPath);
118
118
 
119
- const lines = content.split('\n');
119
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
120
120
  const resultLines = [...lines];
121
121
 
122
122
  // Apply hunks in reverse order to keep line numbers valid
@@ -188,7 +188,7 @@ export function parseSearchReplaceBlocks(patch: string): SearchReplaceBlock[] {
188
188
  }
189
189
 
190
190
  export function applySearchReplaceBlocks(content: string, blocks: SearchReplaceBlock[]): string {
191
- let result = content;
191
+ let result = content.replace(/\r\n/g, '\n');
192
192
 
193
193
  for (const block of blocks) {
194
194
  const parts = result.split(block.search);
@@ -0,0 +1,278 @@
1
+ import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from 'bun:test';
2
+ import type { GitStep } from '../../parser/schema.ts';
3
+ import { ConsoleLogger } from '../../utils/logger.ts';
4
+ import { executeGitStep } from './git-executor.ts';
5
+ import * as shellExecutor from './shell-executor.ts';
6
+
7
+ describe('git-executor', () => {
8
+ const logger = new ConsoleLogger();
9
+ const context = {
10
+ env: {},
11
+ inputs: {},
12
+ steps: {},
13
+ };
14
+
15
+ let executeShellSpy: any;
16
+
17
+ beforeEach(() => {
18
+ executeShellSpy = spyOn(shellExecutor, 'executeShell').mockResolvedValue({
19
+ stdout: 'git output',
20
+ stderr: '',
21
+ exitCode: 0,
22
+ });
23
+ });
24
+
25
+ afterEach(() => {
26
+ executeShellSpy.mockRestore();
27
+ });
28
+
29
+ it('should execute clone operation', async () => {
30
+ const step: GitStep = {
31
+ id: 'git-clone',
32
+ type: 'git',
33
+ op: 'clone',
34
+ url: 'https://github.com/mhingston/keystone-cli.git',
35
+ needs: [],
36
+ };
37
+
38
+ const result = await executeGitStep(step, context, logger);
39
+ expect(result.status).toBe('success');
40
+ expect(executeShellSpy).toHaveBeenCalledWith(
41
+ expect.objectContaining({
42
+ run: 'git clone https://github.com/mhingston/keystone-cli.git ',
43
+ }),
44
+ context,
45
+ logger,
46
+ undefined,
47
+ 'git clone https://github.com/mhingston/keystone-cli.git '
48
+ );
49
+ });
50
+
51
+ it('should execute clone with branch', async () => {
52
+ const step: GitStep = {
53
+ id: 'git-clone-branch',
54
+ type: 'git',
55
+ op: 'clone',
56
+ url: 'https://github.com/mhingston/keystone-cli.git',
57
+ branch: 'main',
58
+ needs: [],
59
+ };
60
+
61
+ const result = await executeGitStep(step, context, logger);
62
+ expect(result.status).toBe('success');
63
+ expect(executeShellSpy).toHaveBeenCalledWith(
64
+ expect.objectContaining({
65
+ run: 'git clone -b main https://github.com/mhingston/keystone-cli.git ',
66
+ }),
67
+ context,
68
+ logger,
69
+ undefined,
70
+ 'git clone -b main https://github.com/mhingston/keystone-cli.git '
71
+ );
72
+ });
73
+
74
+ it('should execute worktree_add operation', async () => {
75
+ const step: GitStep = {
76
+ id: 'git-worktree-add',
77
+ type: 'git',
78
+ op: 'worktree_add',
79
+ path: './tmp-worktree',
80
+ branch: 'feature-branch',
81
+ needs: [],
82
+ };
83
+
84
+ const result = await executeGitStep(step, context, logger);
85
+ expect(result.status).toBe('success');
86
+ expect((result.output as any).worktreePath).toBe('./tmp-worktree');
87
+ expect(executeShellSpy).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ run: 'git worktree add ./tmp-worktree feature-branch',
90
+ }),
91
+ context,
92
+ logger,
93
+ undefined,
94
+ 'git worktree add ./tmp-worktree feature-branch'
95
+ );
96
+ });
97
+
98
+ it('should execute worktree_remove operation', async () => {
99
+ const step: GitStep = {
100
+ id: 'git-worktree-remove',
101
+ type: 'git',
102
+ op: 'worktree_remove',
103
+ path: './tmp-worktree',
104
+ needs: [],
105
+ };
106
+
107
+ const result = await executeGitStep(step, context, logger);
108
+ expect(result.status).toBe('success');
109
+ expect(executeShellSpy).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ run: 'git worktree remove ./tmp-worktree',
112
+ }),
113
+ context,
114
+ logger,
115
+ undefined,
116
+ 'git worktree remove ./tmp-worktree'
117
+ );
118
+ });
119
+
120
+ it('should execute checkout operation', async () => {
121
+ const step: GitStep = {
122
+ id: 'git-checkout',
123
+ type: 'git',
124
+ op: 'checkout',
125
+ branch: 'main',
126
+ needs: [],
127
+ };
128
+
129
+ const result = await executeGitStep(step, context, logger);
130
+ expect(result.status).toBe('success');
131
+ expect(executeShellSpy).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ run: 'git checkout main',
134
+ }),
135
+ context,
136
+ logger,
137
+ undefined,
138
+ 'git checkout main'
139
+ );
140
+ });
141
+
142
+ it('should execute pull operation', async () => {
143
+ const step: GitStep = {
144
+ id: 'git-pull',
145
+ type: 'git',
146
+ op: 'pull',
147
+ branch: 'main',
148
+ needs: [],
149
+ };
150
+
151
+ const result = await executeGitStep(step, context, logger);
152
+ expect(result.status).toBe('success');
153
+ expect(executeShellSpy).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ run: 'git pull origin main',
156
+ }),
157
+ context,
158
+ logger,
159
+ undefined,
160
+ 'git pull origin main'
161
+ );
162
+ });
163
+
164
+ it('should execute push operation', async () => {
165
+ const step: GitStep = {
166
+ id: 'git-push',
167
+ type: 'git',
168
+ op: 'push',
169
+ branch: 'main',
170
+ needs: [],
171
+ };
172
+
173
+ const result = await executeGitStep(step, context, logger);
174
+ expect(result.status).toBe('success');
175
+ expect(executeShellSpy).toHaveBeenCalledWith(
176
+ expect.objectContaining({
177
+ run: 'git push origin main',
178
+ }),
179
+ context,
180
+ logger,
181
+ undefined,
182
+ 'git push origin main'
183
+ );
184
+ });
185
+
186
+ it('should execute commit operation', async () => {
187
+ const step: GitStep = {
188
+ id: 'git-commit',
189
+ type: 'git',
190
+ op: 'commit',
191
+ message: 'test commit',
192
+ needs: [],
193
+ };
194
+
195
+ const result = await executeGitStep(step, context, logger);
196
+ expect(result.status).toBe('success');
197
+ expect(executeShellSpy).toHaveBeenCalledWith(
198
+ expect.objectContaining({
199
+ run: 'git add . && git commit -m "test commit"',
200
+ }),
201
+ context,
202
+ logger,
203
+ undefined,
204
+ 'git add . && git commit -m "test commit"'
205
+ );
206
+ });
207
+
208
+ it('should handle failed operations', async () => {
209
+ executeShellSpy.mockResolvedValue({
210
+ stdout: '',
211
+ stderr: 'git error',
212
+ exitCode: 1,
213
+ });
214
+
215
+ const step: GitStep = {
216
+ id: 'git-fail',
217
+ type: 'git',
218
+ op: 'pull',
219
+ needs: [],
220
+ };
221
+
222
+ const result = await executeGitStep(step, context, logger);
223
+ expect(result.status).toBe('failed');
224
+ expect(result.error).toContain('Git pull failed with code 1: git error');
225
+ });
226
+
227
+ it('should throw error for missing url in clone', async () => {
228
+ const step: GitStep = {
229
+ id: 'git-clone-no-url',
230
+ type: 'git',
231
+ op: 'clone',
232
+ needs: [],
233
+ };
234
+
235
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
236
+ 'Git clone requires a "url"'
237
+ );
238
+ });
239
+
240
+ it('should throw error for missing path in worktree_add', async () => {
241
+ const step: GitStep = {
242
+ id: 'git-worktree-no-path',
243
+ type: 'git',
244
+ op: 'worktree_add',
245
+ needs: [],
246
+ };
247
+
248
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
249
+ 'Git worktree_add requires a "path"'
250
+ );
251
+ });
252
+
253
+ it('should throw error for missing branch in checkout', async () => {
254
+ const step: GitStep = {
255
+ id: 'git-checkout-no-branch',
256
+ type: 'git',
257
+ op: 'checkout',
258
+ needs: [],
259
+ };
260
+
261
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
262
+ 'Git checkout requires a "branch"'
263
+ );
264
+ });
265
+
266
+ it('should throw error for missing message in commit', async () => {
267
+ const step: GitStep = {
268
+ id: 'git-commit-no-msg',
269
+ type: 'git',
270
+ op: 'commit',
271
+ needs: [],
272
+ };
273
+
274
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
275
+ 'Git commit requires a "message"'
276
+ );
277
+ });
278
+ });
@@ -0,0 +1,100 @@
1
+ import { ExpressionEvaluator } from '../../expression/evaluator.ts';
2
+ import type { ExpressionContext } from '../../expression/evaluator.ts';
3
+ import type { GitStep } from '../../parser/schema.ts';
4
+ import { ConsoleLogger, type Logger } from '../../utils/logger.ts';
5
+ import { PathResolver } from '../../utils/paths.ts';
6
+ import { type ShellResult, executeShell } from './shell-executor.ts';
7
+ import type { StepResult } from './types.ts';
8
+
9
+ /**
10
+ * Git command executor
11
+ */
12
+ export async function executeGitStep(
13
+ step: GitStep,
14
+ context: ExpressionContext,
15
+ logger: Logger = new ConsoleLogger(),
16
+ abortSignal?: AbortSignal
17
+ ): Promise<StepResult> {
18
+ const op = step.op;
19
+ const cwd = step.cwd ? ExpressionEvaluator.evaluateString(step.cwd, context) : undefined;
20
+
21
+ if (cwd) {
22
+ PathResolver.assertWithinCwd(cwd, step.allowOutsideCwd, 'CWD');
23
+ }
24
+
25
+ // Pre-process common inputs
26
+ const path = step.path ? ExpressionEvaluator.evaluateString(step.path, context) : undefined;
27
+ const url = step.url ? ExpressionEvaluator.evaluateString(step.url, context) : undefined;
28
+ const branch = step.branch ? ExpressionEvaluator.evaluateString(step.branch, context) : undefined;
29
+ const message = step.message
30
+ ? ExpressionEvaluator.evaluateString(step.message, context)
31
+ : undefined;
32
+
33
+ let command = '';
34
+ switch (op) {
35
+ case 'clone':
36
+ if (!url) throw new Error('Git clone requires a "url"');
37
+ command = `git clone ${branch ? `-b ${branch} ` : ''}${url} ${path || ''}`;
38
+ break;
39
+ case 'worktree_add':
40
+ if (!path) throw new Error('Git worktree_add requires a "path"');
41
+ command = `git worktree add ${path} ${branch || ''}`;
42
+ break;
43
+ case 'worktree_remove':
44
+ if (!path) throw new Error('Git worktree_remove requires a "path"');
45
+ command = `git worktree remove ${path}`;
46
+ break;
47
+ case 'checkout':
48
+ if (!branch) throw new Error('Git checkout requires a "branch"');
49
+ command = `git checkout ${branch}`;
50
+ break;
51
+ case 'pull':
52
+ command = `git pull origin ${branch || ''}`;
53
+ break;
54
+ case 'push':
55
+ command = `git push origin ${branch || ''}`;
56
+ break;
57
+ case 'commit':
58
+ if (!message) throw new Error('Git commit requires a "message"');
59
+ command = `git add . && git commit -m "${message.replace(/"/g, '\\"')}"`;
60
+ break;
61
+ default:
62
+ throw new Error(`Unsupported git operation: ${op}`);
63
+ }
64
+
65
+ // Use executeShell to leverage its security checks and output handling
66
+ // We explicitly allow insecure if the user set it, but by default executeShell
67
+ // will check the command against the whitelist.
68
+ // Note: Git commands often contain spaces/slashes which are in the whitelist.
69
+ const result: ShellResult = await executeShell(
70
+ {
71
+ ...step,
72
+ type: 'shell',
73
+ run: command,
74
+ dir: cwd,
75
+ },
76
+ context,
77
+ logger,
78
+ abortSignal,
79
+ command
80
+ );
81
+
82
+ if (result.exitCode !== 0) {
83
+ return {
84
+ output: result,
85
+ status: 'failed',
86
+ error: `Git ${op} failed with code ${result.exitCode}: ${result.stderr}`,
87
+ };
88
+ }
89
+
90
+ // Provide some structured output based on the operation
91
+ const output: any = { ...result };
92
+ if (op === 'worktree_add') {
93
+ output.worktreePath = path;
94
+ }
95
+
96
+ return {
97
+ output,
98
+ status: 'success',
99
+ };
100
+ }
@@ -0,0 +1,69 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { AUTO_LOAD_SECRET_PREFIXES } from '../../utils/env-constants';
3
+ import { SecretManager } from '../services/secret-manager';
4
+ import { detectShellInjectionRisk } from './shell-executor';
5
+
6
+ describe('Security Fixes', () => {
7
+ describe('ShellExecutor Command Injection', () => {
8
+ test('detectShellInjectionRisk should block newlines', () => {
9
+ // Regular space should be allowed
10
+ expect(detectShellInjectionRisk('echo hello')).toBe(false);
11
+
12
+ // Newline characters should be detected as risk
13
+ expect(detectShellInjectionRisk('echo hello\n')).toBe(true);
14
+ expect(detectShellInjectionRisk('echo hello\r')).toBe(true);
15
+ expect(detectShellInjectionRisk('echo hello\nrm -rf /')).toBe(true);
16
+
17
+ // Standard allowed characters should still pass
18
+ expect(detectShellInjectionRisk('curl -X POST https://example.com/api')).toBe(false);
19
+ expect(detectShellInjectionRisk('file_name-v1.0+beta.txt')).toBe(false);
20
+ });
21
+
22
+ test('detectShellInjectionRisk should correctly handle quotes', () => {
23
+ // Content inside single quotes is considered safe (literal)
24
+ expect(detectShellInjectionRisk("echo 'safe string with ; and |'")).toBe(false);
25
+
26
+ // But unsafe chars outside quotes must be caught
27
+ expect(detectShellInjectionRisk("echo 'safe'; rm -rf /")).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe('SecretManager Auto-loading', () => {
32
+ // Save original env to restore later
33
+ const originalEnv = { ...Bun.env };
34
+
35
+ afterAll(() => {
36
+ // Restore env
37
+ for (const key of Object.keys(Bun.env)) {
38
+ delete Bun.env[key];
39
+ }
40
+ for (const [key, value] of Object.entries(originalEnv)) {
41
+ if (value) Bun.env[key] = value;
42
+ }
43
+ });
44
+
45
+ test('should only load secrets with allowed prefixes', () => {
46
+ // Setup test env vars
47
+ Bun.env.KEYSTONE_TEST_SECRET = 'secret-value-1';
48
+ Bun.env.GITHUB_TOKEN = 'gh-token-123';
49
+ Bun.env.MY_RANDOM_TOKEN = 'unsafe-token-should-not-load';
50
+ Bun.env.SOME_API_KEY = 'unsafe-api-key';
51
+
52
+ const secretManager = new SecretManager();
53
+ const secrets = secretManager.loadSecrets();
54
+
55
+ expect(secrets.KEYSTONE_TEST_SECRET).toBe('secret-value-1');
56
+ expect(secrets.GITHUB_TOKEN).toBe('gh-token-123');
57
+
58
+ // Should NOT load these even though they contain "TOKEN" or "KEY"
59
+ expect(secrets.MY_RANDOM_TOKEN).toBeUndefined();
60
+ expect(secrets.SOME_API_KEY).toBeUndefined();
61
+ });
62
+
63
+ test('env constants should match implementation', () => {
64
+ // Quick sanity check that our test logic matches the constants
65
+ expect(AUTO_LOAD_SECRET_PREFIXES).toContain('KEYSTONE_');
66
+ expect(AUTO_LOAD_SECRET_PREFIXES).toContain('GITHUB_');
67
+ });
68
+ });
69
+ });
@@ -43,6 +43,9 @@ export async function executeShellStep(
43
43
  abortSignal?: AbortSignal
44
44
  ): Promise<StepResult> {
45
45
  if (step.args) {
46
+ // args are inherently safe from shell injection as they skip the shell
47
+ // and pass the array directly to the OS via Bun.spawn.
48
+
46
49
  const command = step.args.map((a) => ExpressionEvaluator.evaluateString(a, context)).join(' ');
47
50
  if (dryRun) {
48
51
  logger.log(`[DRY RUN] Would execute: ${command}`);
@@ -200,13 +203,35 @@ async function readStreamWithLimit(
200
203
  }
201
204
 
202
205
  // Whitelist of allowed characters for secure shell command execution
203
- // Allows: Alphanumeric, whitespace, and common safe punctuation (_ . / : @ , + - = ' " !)
204
- // Blocks: Backslashes, pipes, redirects, subshells, variables ($), etc.
205
- const SAFE_SHELL_CHARS = /^[a-zA-Z0-9\s_./:@,+=~'"!-]+$/;
206
+ // Allows: Alphanumeric, space, and common safe punctuation (_ . / : @ , + - = ' " ! ~)
207
+ // Blocks: Newlines (\n, \r), Pipes, redirects, subshells, variables ($), etc.
208
+ const SAFE_SHELL_CHARS = /^[a-zA-Z0-9 _./:@,+=~'"!-]+$/;
206
209
 
207
210
  export function detectShellInjectionRisk(rawCommand: string): boolean {
208
- // If the command contains any character NOT in the whitelist, it's considered risky
209
- return !SAFE_SHELL_CHARS.test(rawCommand);
211
+ // We scan the command to handle single quotes correctly.
212
+ // Characters inside single quotes are considered escaped/literal and safe from shell injection.
213
+ let inSingleQuote = false;
214
+
215
+ for (let i = 0; i < rawCommand.length; i++) {
216
+ const char = rawCommand[i];
217
+
218
+ if (char === "'") {
219
+ inSingleQuote = !inSingleQuote;
220
+ continue;
221
+ }
222
+
223
+ // Outside single quotes, we enforce the strict whitelist
224
+ if (!inSingleQuote) {
225
+ if (!SAFE_SHELL_CHARS.test(char)) {
226
+ return true;
227
+ }
228
+ }
229
+ // Inside single quotes, everything is treated as a literal string by the shell,
230
+ // so we don't need to block special characters.
231
+ }
232
+
233
+ // If we ended with an unclosed single quote, it's a syntax risk
234
+ return inSingleQuote;
210
235
  }
211
236
 
212
237
  /**
@@ -0,0 +1,83 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import { MemoryDb } from '../db/memory-db';
4
+ import { WorkflowDb } from '../db/workflow-db';
5
+ import type { Workflow } from '../parser/schema';
6
+ import { container } from '../utils/container';
7
+ import { ConsoleLogger } from '../utils/logger';
8
+ import { WorkflowRunner } from './workflow-runner';
9
+
10
+ describe('Workflow Memoization Leak (Args Check)', () => {
11
+ const dbPath = 'test-memoization-leak.db';
12
+
13
+ container.register('logger', new ConsoleLogger());
14
+ container.register('db', new WorkflowDb(dbPath));
15
+ container.register('memoryDb', new MemoryDb());
16
+
17
+ afterEach(() => {
18
+ if (existsSync(dbPath)) {
19
+ rmSync(dbPath);
20
+ }
21
+ });
22
+
23
+ it('should NOT collide for shell steps with same command but different args', async () => {
24
+ const workflow: Workflow = {
25
+ name: 'memoize-args-wf',
26
+ inputs: {
27
+ arg: { type: 'string' },
28
+ },
29
+ steps: [
30
+ {
31
+ id: 's1',
32
+ type: 'shell',
33
+ args: ['echo', '${{ inputs.arg }}'],
34
+ allowInsecure: true,
35
+ memoize: true,
36
+ needs: [],
37
+ },
38
+ ],
39
+ outputs: {
40
+ out: '${{ steps.s1.output.stdout.trim() }}',
41
+ },
42
+ } as unknown as Workflow;
43
+
44
+ let executeCount = 0;
45
+ const trackedExecuteStep = async (step: any, context: any, logger: any, options: any) => {
46
+ if (step.id === 's1') executeCount++;
47
+ const { executeStep } = await import('./step-executor');
48
+ return executeStep(step, context, logger, options);
49
+ };
50
+
51
+ // Run 1: arg=A
52
+ const runner1 = new WorkflowRunner(workflow, {
53
+ dbPath,
54
+ inputs: { arg: 'A' },
55
+ executeStep: trackedExecuteStep,
56
+ });
57
+ const out1 = await runner1.run();
58
+ expect(out1.out).toBe('A');
59
+ expect(executeCount).toBe(1);
60
+
61
+ // Run 2: arg=A -> Cache Hit
62
+ const runner2 = new WorkflowRunner(workflow, {
63
+ dbPath,
64
+ inputs: { arg: 'A' },
65
+ executeStep: trackedExecuteStep,
66
+ });
67
+ executeCount = 0;
68
+ const out2 = await runner2.run();
69
+ expect(out2.out).toBe('A');
70
+ expect(executeCount).toBe(0); // Memoized
71
+
72
+ // Run 3: arg=B -> Execute (Must not collide with A)
73
+ const runner3 = new WorkflowRunner(workflow, {
74
+ dbPath,
75
+ inputs: { arg: 'B' },
76
+ executeStep: trackedExecuteStep,
77
+ });
78
+ executeCount = 0;
79
+ const out3 = await runner3.run();
80
+ expect(out3.out).toBe('B');
81
+ expect(executeCount).toBe(1); // Should execute because args are different!
82
+ });
83
+ });