@studio-foundation/engine 0.3.0-beta.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 (90) hide show
  1. package/ARCHITECTURE.md +57 -0
  2. package/LICENSE +663 -0
  3. package/README.md +118 -0
  4. package/package.json +47 -0
  5. package/src/__tests__/__fixtures__/script-stage/contracts/book-context.contract.yaml +4 -0
  6. package/src/__tests__/engine.conditions-group.test.ts +244 -0
  7. package/src/__tests__/engine.conditions.test.ts +186 -0
  8. package/src/__tests__/engine.resume.test.ts +108 -0
  9. package/src/__tests__/engine.script-stage.test.ts +125 -0
  10. package/src/db/client.ts +5 -0
  11. package/src/engine.context-event.test.ts +175 -0
  12. package/src/engine.ts +491 -0
  13. package/src/events.ts +167 -0
  14. package/src/index.ts +64 -0
  15. package/src/pipeline/agent-loader.test.ts +151 -0
  16. package/src/pipeline/agent-loader.ts +39 -0
  17. package/src/pipeline/condition-evaluator.test.ts +129 -0
  18. package/src/pipeline/condition-evaluator.ts +121 -0
  19. package/src/pipeline/context-pack-loader.ts +63 -0
  20. package/src/pipeline/context-propagation.test.ts +237 -0
  21. package/src/pipeline/context-propagation.ts +235 -0
  22. package/src/pipeline/contract-loader.ts +41 -0
  23. package/src/pipeline/group-orchestrator.ts +483 -0
  24. package/src/pipeline/hook-executor.test.ts +121 -0
  25. package/src/pipeline/hook-executor.ts +87 -0
  26. package/src/pipeline/invariants-loader.test.ts +51 -0
  27. package/src/pipeline/invariants-loader.ts +14 -0
  28. package/src/pipeline/loader.test.ts +128 -0
  29. package/src/pipeline/loader.ts +149 -0
  30. package/src/pipeline/output-validator.test.ts +100 -0
  31. package/src/pipeline/output-validator.ts +40 -0
  32. package/src/pipeline/post-validator.ts +124 -0
  33. package/src/pipeline/skill-loader.test.ts +44 -0
  34. package/src/pipeline/skill-loader.ts +32 -0
  35. package/src/pipeline/stage-executor.ts +654 -0
  36. package/src/pipeline/stage-resolver.ts +8 -0
  37. package/src/pipeline/startup-executor.test.ts +37 -0
  38. package/src/pipeline/startup-executor.ts +32 -0
  39. package/src/pipeline/types.ts +42 -0
  40. package/src/repo-resolver.ts +61 -0
  41. package/src/spawners/direct-engine-spawner.ts +26 -0
  42. package/src/state/run-store.test.ts +157 -0
  43. package/src/state/run-store.ts +362 -0
  44. package/src/state/state-machine.ts +35 -0
  45. package/src/state/status-derivation.ts +36 -0
  46. package/tests/context-pack-loader.test.ts +113 -0
  47. package/tests/context-propagation.test.ts +267 -0
  48. package/tests/direct-engine-spawner.test.ts +102 -0
  49. package/tests/e2e/feature-v5.test.ts +57 -0
  50. package/tests/events.test.ts +56 -0
  51. package/tests/fixtures/agents/test-agent.agent.yaml +5 -0
  52. package/tests/fixtures/contracts/code-gen.contract.yaml +6 -0
  53. package/tests/fixtures/contracts/qa-gate.contract.yaml +15 -0
  54. package/tests/fixtures/contracts/test-contract.contract.yaml +6 -0
  55. package/tests/fixtures/pipelines/group-test.pipeline.yaml +40 -0
  56. package/tests/fixtures/pipelines/simple.pipeline.yaml +15 -0
  57. package/tests/fixtures/pipelines/two-stage.pipeline.yaml +24 -0
  58. package/tests/fixtures/software/pipelines/feature-builder.pipeline.yaml +75 -0
  59. package/tests/fixtures/test-project/agents/test-agent.agent.yaml +5 -0
  60. package/tests/fixtures/test-project/contracts/basic-result.contract.yaml +6 -0
  61. package/tests/fixtures/test-project/contracts/code-gen.contract.yaml +6 -0
  62. package/tests/fixtures/test-project/contracts/maximum-only.contract.yaml +8 -0
  63. package/tests/fixtures/test-project/contracts/qa-gate.contract.yaml +15 -0
  64. package/tests/fixtures/test-project/contracts/strict-result.contract.yaml +7 -0
  65. package/tests/fixtures/test-project/contracts/test-contract.contract.yaml +6 -0
  66. package/tests/fixtures/test-project/pipelines/group-simple.pipeline.yaml +26 -0
  67. package/tests/fixtures/test-project/pipelines/group-test.pipeline.yaml +40 -0
  68. package/tests/fixtures/test-project/pipelines/hook-output-template.pipeline.yaml +19 -0
  69. package/tests/fixtures/test-project/pipelines/hook-reject-on-failure.pipeline.yaml +19 -0
  70. package/tests/fixtures/test-project/pipelines/maximum-only.pipeline.yaml +15 -0
  71. package/tests/fixtures/test-project/pipelines/parallel-collect-all-test.pipeline.yaml +39 -0
  72. package/tests/fixtures/test-project/pipelines/parallel-context-isolation-test.pipeline.yaml +38 -0
  73. package/tests/fixtures/test-project/pipelines/parallel-fail-test.pipeline.yaml +39 -0
  74. package/tests/fixtures/test-project/pipelines/parallel-test.pipeline.yaml +39 -0
  75. package/tests/fixtures/test-project/pipelines/parallel-then-sequential-test.pipeline.yaml +38 -0
  76. package/tests/fixtures/test-project/pipelines/simple.pipeline.yaml +15 -0
  77. package/tests/fixtures/test-project/pipelines/two-stage.pipeline.yaml +24 -0
  78. package/tests/fixtures/test-project/pipelines/with-startup.pipeline.yaml +19 -0
  79. package/tests/loader.test.ts +385 -0
  80. package/tests/post-validator.test.ts +297 -0
  81. package/tests/repo-resolver.test.ts +102 -0
  82. package/tests/run-store.test.ts +143 -0
  83. package/tests/state-machine.test.ts +110 -0
  84. package/tests/unit/context-propagation.test.ts +45 -0
  85. package/tests/unit/engine.test.ts +770 -0
  86. package/tests/unit/group-loop.test.ts +389 -0
  87. package/tests/unit/group-parallel.test.ts +423 -0
  88. package/tests/unit/state/status-derivation.test.ts +88 -0
  89. package/tsconfig.json +24 -0
  90. package/vitest.config.ts +14 -0
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @studio-foundation/engine
2
+
3
+ Pipeline orchestration, state machine, persistence, hooks, and skills injection.
4
+
5
+ ## Role
6
+
7
+ engine is the conductor. It loads pipeline configs, sequences stages, delegates execution to ralph+runner, tracks state in SQLite, and emits events for observability. It knows about pipelines, stages, groups, hooks, and skills — but never about LLMs, files, or domain concepts.
8
+
9
+ ```
10
+ cli → engine.run(pipeline, input) → PipelineRun
11
+
12
+ [load pipeline] → [on_pipeline_start] → [for each stage: hooks + ralph(runner)] → [persist state] → [emit events]
13
+ ```
14
+
15
+ ## Key exports
16
+
17
+ ```typescript
18
+ import { PipelineEngine } from '@studio-foundation/engine';
19
+
20
+ const engine = new PipelineEngine({
21
+ configsDir: '.studio', // Root of .studio/ directory
22
+ repoPath: '/path/to/workspace', // Where tools operate (optional)
23
+ providerRegistry,
24
+ toolRegistry,
25
+ db: runStore,
26
+ pluginSkills: { // Skills from Claude Code plugins, keyed by plugin name
27
+ 'my-plugin': ['## Skill: commit-conventions\n...'],
28
+ },
29
+ });
30
+
31
+ const run = await engine.run({
32
+ pipeline: 'feature-builder',
33
+ input: { brief: 'Add dark mode' },
34
+ anonymize: true, // Enable PII anonymization for this run
35
+ });
36
+ ```
37
+
38
+ ## State machine
39
+
40
+ ```
41
+ pending → running → success
42
+ → failed (ralph exhausted all attempts, or on_stage_start hook with on_failure: fail)
43
+ → rejected (post_validation rejection, or hook with on_failure: reject)
44
+ → skipped
45
+ ```
46
+
47
+ `deriveStageStatus()` in `state/status-derivation.ts` is the critical mapping function: ralph result → stage status. One place, deterministic.
48
+
49
+ ## Lifecycle hooks
50
+
51
+ The engine executes stage hooks at four deterministic points:
52
+
53
+ | Hook | When | Template vars |
54
+ |------|------|---------------|
55
+ | `on_stage_start` | Before ralph loop | None |
56
+ | `on_stage_complete` | After stage succeeds | `{{output.field}}` |
57
+ | `pre_tool_use` | Before a tool call (matcher-gated) | `{{tool.argName}}` |
58
+ | `post_tool_use` | After a tool call (matcher-gated) | `{{tool.argName}}` |
59
+
60
+ Hook failure semantics via `on_failure`:
61
+ - `warn` (default) — log and continue
62
+ - `reject` — stage → `rejected` (can trigger group retry)
63
+ - `fail` — stage → `failed` (stops pipeline)
64
+
65
+ `pre_tool_use` hooks with any failure block the tool call. Hook commands run in `repoPath` (or `configsDir` as fallback). Implemented in `pipeline/hook-executor.ts`.
66
+
67
+ ## on_pipeline_start
68
+
69
+ Commands in `pipeline.on_pipeline_start` execute before any stage and inject their stdout into the pipeline context:
70
+
71
+ ```yaml
72
+ on_pipeline_start:
73
+ - command: "git status --short"
74
+ inject_as: git_status
75
+ ```
76
+
77
+ Implemented in `pipeline/startup-executor.ts`. Failures are non-fatal (logged, pipeline continues).
78
+
79
+ ## Skills injection
80
+
81
+ Agents that declare `skills: [name]` in their YAML get `.studio/skills/<name>.skill.md` auto-injected into their system prompt. Loaded by `pipeline/skill-loader.ts`. Missing skill files are non-fatal (warned, skipped).
82
+
83
+ Plugin skills (`pluginSkills` in `EngineConfig`) are injected for agents that declare `plugins: [plugin-name]`.
84
+
85
+ ## Events
86
+
87
+ The engine emits structured events at every lifecycle point. See `events.ts`:
88
+
89
+ | Event | When |
90
+ |-------|------|
91
+ | `onPipelineStart` / `onPipelineComplete` | Pipeline lifecycle |
92
+ | `onStageStart` / `onStageComplete` | Stage lifecycle |
93
+ | `onTaskRetry` | RALPH retry |
94
+ | `onGroupStart` / `onGroupIteration` / `onGroupFeedback` / `onGroupComplete` | Group lifecycle |
95
+ | `onToolCallStart` / `onToolCallComplete` | Tool call lifecycle |
96
+ | `onAgentThinking` / `onAgentProgress` / `onAgentToken` | Streaming events |
97
+
98
+ The CLI subscribes to these events to render progress output and stream tokens.
99
+
100
+ ## Groups
101
+
102
+ A group is a set of stages that iterate together. If the last stage rejects (via `post_validation.rejection_detection` in its contract), the group reruns from the first stage with accumulated feedback. `max_iterations` caps the loop.
103
+
104
+ ## PII Anonymization
105
+
106
+ ```typescript
107
+ engine.run({ pipeline, input, anonymize: true })
108
+ // Keymap persisted to .studio/runs/anonymization/<run-id>.keymap.json
109
+ ```
110
+
111
+ Per-agent anonymization also supported via `anonymize: true` in agent YAML.
112
+
113
+ ## Rules
114
+
115
+ - **engine is domain-agnostic.** No references to "code", "file", "git", "QA" in engine source. All domain knowledge is in YAML configs.
116
+ - **engine doesn't execute tools.** It passes tool configs to runner. The runner decides what `repo_manager-write_file` means.
117
+ - **engine doesn't build prompts.** That's runner's job.
118
+ - Persistence: `PgRunStore` (PostgreSQL) for production, `InMemoryRunStore` for tests. Both implement `AnyRunStore`.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@studio-foundation/engine",
3
+ "version": "0.3.0-beta.1",
4
+ "description": "Pipeline orchestration engine for Studio v7",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "keywords": [
15
+ "studio",
16
+ "engine",
17
+ "pipeline",
18
+ "orchestration"
19
+ ],
20
+ "author": "Ariane Guay",
21
+ "license": "AGPL-3.0-only",
22
+ "dependencies": {
23
+ "better-sqlite3": "^9.0.0",
24
+ "js-yaml": "^4.1.0",
25
+ "pg": "^8.19.0",
26
+ "@studio-foundation/contracts": "0.3.0-beta.1",
27
+ "@studio-foundation/ralph": "0.3.0-beta.1",
28
+ "@studio-foundation/runner": "0.3.0-beta.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/better-sqlite3": "^7.6.8",
32
+ "@types/js-yaml": "^4.0.9",
33
+ "@types/pg": "^8.18.0",
34
+ "@types/node": "^25.2.3",
35
+ "typescript": "^5.3.0",
36
+ "vitest": "^4.0.18"
37
+ },
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "dev": "tsc --watch",
41
+ "clean": "rm -rf dist",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "test:coverage": "vitest --coverage"
46
+ }
47
+ }
@@ -0,0 +1,4 @@
1
+ name: book-context
2
+ version: 1
3
+ schema:
4
+ required_fields: []
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { resolve } from 'node:path';
3
+ import { PipelineEngine } from '../engine.js';
4
+ import type { PipelineDefinition } from '@studio-foundation/contracts';
5
+
6
+ vi.mock('@studio-foundation/runner', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('@studio-foundation/runner')>();
8
+ return {
9
+ ...actual,
10
+ runScript: vi.fn(),
11
+ };
12
+ });
13
+
14
+ import { runScript } from '@studio-foundation/runner';
15
+
16
+ const FIXTURES_DIR = resolve(__dirname, '__fixtures__/script-stage');
17
+
18
+ function makeEngine() {
19
+ return new PipelineEngine({
20
+ configsDir: FIXTURES_DIR,
21
+ providerRegistry: {} as any,
22
+ });
23
+ }
24
+
25
+ describe('engine — conditions inside sequential groups', () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ it('skips a conditional stage inside a sequential group', async () => {
31
+ vi.mocked(runScript).mockResolvedValue({
32
+ output: { done: true },
33
+ tool_calls: [],
34
+ tool_calls_count: 0,
35
+ duration_ms: 10,
36
+ });
37
+
38
+ const pipeline: PipelineDefinition = {
39
+ name: 'test-group-conditions',
40
+ description: 'test',
41
+ version: 1,
42
+ stages: [
43
+ {
44
+ group: 'processing',
45
+ max_iterations: 1,
46
+ stages: [
47
+ {
48
+ name: 'always-runs',
49
+ executor: 'script',
50
+ script: 'scripts/run.py',
51
+ runtime: 'shell',
52
+ },
53
+ {
54
+ name: 'conditional-in-group',
55
+ executor: 'script',
56
+ script: 'scripts/optional.py',
57
+ runtime: 'shell',
58
+ condition: 'input.optional >= 1',
59
+ },
60
+ ],
61
+ },
62
+ ],
63
+ };
64
+
65
+ const engine = makeEngine();
66
+ const result = await engine.run({
67
+ pipelineDef: pipeline,
68
+ input: { optional: 0 },
69
+ });
70
+
71
+ expect(result.status).toBe('success');
72
+ expect(result.stages[0]?.status).toBe('success');
73
+ expect(result.stages[1]?.status).toBe('skipped');
74
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('marks group as skipped when all stages in sequential group are skipped', async () => {
78
+ vi.mocked(runScript).mockResolvedValue({
79
+ output: { done: true },
80
+ tool_calls: [],
81
+ tool_calls_count: 0,
82
+ duration_ms: 10,
83
+ });
84
+
85
+ // A stage after the group should still run
86
+ const pipeline: PipelineDefinition = {
87
+ name: 'test-all-skipped-group',
88
+ description: 'test',
89
+ version: 1,
90
+ stages: [
91
+ {
92
+ group: 'optional-processing',
93
+ max_iterations: 1,
94
+ stages: [
95
+ {
96
+ name: 'optional-a',
97
+ executor: 'script',
98
+ script: 'scripts/a.py',
99
+ runtime: 'shell',
100
+ condition: 'input.run_optional >= 1',
101
+ },
102
+ {
103
+ name: 'optional-b',
104
+ executor: 'script',
105
+ script: 'scripts/b.py',
106
+ runtime: 'shell',
107
+ condition: 'input.run_optional >= 1',
108
+ },
109
+ ],
110
+ },
111
+ {
112
+ name: 'post-group-stage',
113
+ executor: 'script',
114
+ script: 'scripts/final.py',
115
+ runtime: 'shell',
116
+ },
117
+ ],
118
+ };
119
+
120
+ const engine = makeEngine();
121
+ const result = await engine.run({
122
+ pipelineDef: pipeline,
123
+ input: { run_optional: 0 },
124
+ });
125
+
126
+ expect(result.status).toBe('success');
127
+ // Both group stages skipped
128
+ expect(result.stages[0]?.status).toBe('skipped');
129
+ expect(result.stages[1]?.status).toBe('skipped');
130
+ // Post-group stage ran
131
+ expect(result.stages[2]?.status).toBe('success');
132
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1); // only post-group-stage
133
+ });
134
+ });
135
+
136
+ describe('engine — conditions inside parallel groups', () => {
137
+ beforeEach(() => {
138
+ vi.clearAllMocks();
139
+ });
140
+
141
+ it('marks parallel group as skipped when all stages are skipped', async () => {
142
+ vi.mocked(runScript).mockResolvedValue({
143
+ output: { done: true },
144
+ tool_calls: [],
145
+ tool_calls_count: 0,
146
+ duration_ms: 10,
147
+ });
148
+
149
+ const pipeline: PipelineDefinition = {
150
+ name: 'test-parallel-all-skipped',
151
+ description: 'test',
152
+ version: 1,
153
+ stages: [
154
+ {
155
+ group: 'optional-parallel',
156
+ max_iterations: 1,
157
+ mode: 'parallel',
158
+ stages: [
159
+ {
160
+ name: 'parallel-a',
161
+ executor: 'script',
162
+ script: 'scripts/a.py',
163
+ runtime: 'shell',
164
+ condition: 'input.enabled >= 1',
165
+ },
166
+ {
167
+ name: 'parallel-b',
168
+ executor: 'script',
169
+ script: 'scripts/b.py',
170
+ runtime: 'shell',
171
+ condition: 'input.enabled >= 1',
172
+ },
173
+ ],
174
+ },
175
+ {
176
+ name: 'post-group',
177
+ executor: 'script',
178
+ script: 'scripts/final.py',
179
+ runtime: 'shell',
180
+ },
181
+ ],
182
+ };
183
+
184
+ const engine = makeEngine();
185
+ const result = await engine.run({
186
+ pipelineDef: pipeline,
187
+ input: { enabled: 0 },
188
+ });
189
+
190
+ expect(result.status).toBe('success');
191
+ expect(result.stages[0]?.status).toBe('skipped');
192
+ expect(result.stages[1]?.status).toBe('skipped');
193
+ expect(result.stages[2]?.status).toBe('success');
194
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1); // only post-group
195
+ });
196
+
197
+ it('runs parallel group normally when at least one stage is not skipped', async () => {
198
+ vi.mocked(runScript).mockResolvedValue({
199
+ output: { done: true },
200
+ tool_calls: [],
201
+ tool_calls_count: 0,
202
+ duration_ms: 10,
203
+ });
204
+
205
+ const pipeline: PipelineDefinition = {
206
+ name: 'test-parallel-partial-skip',
207
+ description: 'test',
208
+ version: 1,
209
+ stages: [
210
+ {
211
+ group: 'mixed-parallel',
212
+ max_iterations: 1,
213
+ mode: 'parallel',
214
+ stages: [
215
+ {
216
+ name: 'always-runs',
217
+ executor: 'script',
218
+ script: 'scripts/a.py',
219
+ runtime: 'shell',
220
+ },
221
+ {
222
+ name: 'conditional',
223
+ executor: 'script',
224
+ script: 'scripts/b.py',
225
+ runtime: 'shell',
226
+ condition: 'input.enabled >= 1',
227
+ },
228
+ ],
229
+ },
230
+ ],
231
+ };
232
+
233
+ const engine = makeEngine();
234
+ const result = await engine.run({
235
+ pipelineDef: pipeline,
236
+ input: { enabled: 0 },
237
+ });
238
+
239
+ expect(result.status).toBe('success');
240
+ expect(result.stages[0]?.status).toBe('success');
241
+ expect(result.stages[1]?.status).toBe('skipped');
242
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1); // only always-runs
243
+ });
244
+ });
@@ -0,0 +1,186 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { resolve } from 'node:path';
3
+ import { PipelineEngine } from '../engine.js';
4
+ import type { PipelineDefinition } from '@studio-foundation/contracts';
5
+
6
+ // Mock the runner so we can inspect which stages actually ran
7
+ vi.mock('@studio-foundation/runner', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('@studio-foundation/runner')>();
9
+ return {
10
+ ...actual,
11
+ runScript: vi.fn(),
12
+ };
13
+ });
14
+
15
+ import { runScript } from '@studio-foundation/runner';
16
+
17
+ const FIXTURES_DIR = resolve(__dirname, '__fixtures__/script-stage');
18
+
19
+ function makeEngine() {
20
+ return new PipelineEngine({
21
+ configsDir: FIXTURES_DIR,
22
+ providerRegistry: {} as any,
23
+ });
24
+ }
25
+
26
+ function mockScriptSuccess(output: Record<string, unknown> = { result: 'ok' }) {
27
+ vi.mocked(runScript).mockResolvedValue({
28
+ output,
29
+ tool_calls: [],
30
+ tool_calls_count: 0,
31
+ duration_ms: 10,
32
+ });
33
+ }
34
+
35
+ describe('engine — stage conditions', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it('skips a stage when input condition is false', async () => {
41
+ mockScriptSuccess();
42
+
43
+ const pipeline: PipelineDefinition = {
44
+ name: 'test-conditions',
45
+ description: 'test',
46
+ version: 1,
47
+ stages: [
48
+ {
49
+ name: 'always-runs',
50
+ executor: 'script',
51
+ script: 'scripts/parse.py',
52
+ runtime: 'shell',
53
+ },
54
+ {
55
+ name: 'conditional-stage',
56
+ executor: 'script',
57
+ script: 'scripts/parse.py',
58
+ runtime: 'shell',
59
+ condition: 'input.meals_count >= 6',
60
+ },
61
+ ],
62
+ };
63
+
64
+ const engine = makeEngine();
65
+ const result = await engine.run({
66
+ pipelineDef: pipeline,
67
+ input: { meals_count: 3 }, // condition is false — stage should skip
68
+ });
69
+
70
+ expect(result.status).toBe('success');
71
+ expect(result.stages).toHaveLength(2);
72
+ expect(result.stages[0]?.status).toBe('success');
73
+ expect(result.stages[1]?.status).toBe('skipped');
74
+ // Script was only called once (for always-runs, not conditional-stage)
75
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it('runs a stage when input condition is true', async () => {
79
+ mockScriptSuccess();
80
+
81
+ const pipeline: PipelineDefinition = {
82
+ name: 'test-conditions-true',
83
+ description: 'test',
84
+ version: 1,
85
+ stages: [
86
+ {
87
+ name: 'conditional-stage',
88
+ executor: 'script',
89
+ script: 'scripts/parse.py',
90
+ runtime: 'shell',
91
+ condition: 'input.meals_count >= 6',
92
+ },
93
+ ],
94
+ };
95
+
96
+ const engine = makeEngine();
97
+ const result = await engine.run({
98
+ pipelineDef: pipeline,
99
+ input: { meals_count: 7 }, // condition is true — stage should run
100
+ });
101
+
102
+ expect(result.status).toBe('success');
103
+ expect(result.stages[0]?.status).toBe('success');
104
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it('skips a stage based on previous stage output', async () => {
108
+ // First call returns extraction result, second call would be entity-resolution
109
+ vi.mocked(runScript).mockResolvedValueOnce({
110
+ output: { counts: { OTHER: 0, PERSON: 2 } },
111
+ tool_calls: [],
112
+ tool_calls_count: 0,
113
+ duration_ms: 10,
114
+ });
115
+
116
+ const pipeline: PipelineDefinition = {
117
+ name: 'test-conditions-stage-output',
118
+ description: 'test',
119
+ version: 1,
120
+ stages: [
121
+ {
122
+ name: 'entity-extraction',
123
+ executor: 'script',
124
+ script: 'scripts/extract.py',
125
+ runtime: 'shell',
126
+ context: { include: ['input'] },
127
+ },
128
+ {
129
+ name: 'entity-resolution-OTHER',
130
+ executor: 'script',
131
+ script: 'scripts/resolve.py',
132
+ runtime: 'shell',
133
+ condition: 'stages.entity-extraction.output.counts.OTHER > 0',
134
+ context: { include: ['all_stage_outputs'] },
135
+ },
136
+ ],
137
+ };
138
+
139
+ const engine = makeEngine();
140
+ const result = await engine.run({
141
+ pipelineDef: pipeline,
142
+ input: 'extract entities',
143
+ });
144
+
145
+ expect(result.status).toBe('success');
146
+ expect(result.stages[0]?.status).toBe('success');
147
+ expect(result.stages[1]?.status).toBe('skipped'); // counts.OTHER is 0
148
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1); // only extraction ran
149
+ });
150
+
151
+ it('pipeline continues after skipped stage', async () => {
152
+ mockScriptSuccess({ final: 'result' });
153
+
154
+ const pipeline: PipelineDefinition = {
155
+ name: 'test-skip-continues',
156
+ description: 'test',
157
+ version: 1,
158
+ stages: [
159
+ {
160
+ name: 'skipped-stage',
161
+ executor: 'script',
162
+ script: 'scripts/parse.py',
163
+ runtime: 'shell',
164
+ condition: 'input.run_optional >= 1',
165
+ },
166
+ {
167
+ name: 'final-stage',
168
+ executor: 'script',
169
+ script: 'scripts/finalize.py',
170
+ runtime: 'shell',
171
+ },
172
+ ],
173
+ };
174
+
175
+ const engine = makeEngine();
176
+ const result = await engine.run({
177
+ pipelineDef: pipeline,
178
+ input: { run_optional: 0 },
179
+ });
180
+
181
+ expect(result.status).toBe('success');
182
+ expect(result.stages[0]?.status).toBe('skipped');
183
+ expect(result.stages[1]?.status).toBe('success');
184
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1); // only final-stage
185
+ });
186
+ });