@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.
- package/ARCHITECTURE.md +57 -0
- package/LICENSE +663 -0
- package/README.md +118 -0
- package/package.json +47 -0
- package/src/__tests__/__fixtures__/script-stage/contracts/book-context.contract.yaml +4 -0
- package/src/__tests__/engine.conditions-group.test.ts +244 -0
- package/src/__tests__/engine.conditions.test.ts +186 -0
- package/src/__tests__/engine.resume.test.ts +108 -0
- package/src/__tests__/engine.script-stage.test.ts +125 -0
- package/src/db/client.ts +5 -0
- package/src/engine.context-event.test.ts +175 -0
- package/src/engine.ts +491 -0
- package/src/events.ts +167 -0
- package/src/index.ts +64 -0
- package/src/pipeline/agent-loader.test.ts +151 -0
- package/src/pipeline/agent-loader.ts +39 -0
- package/src/pipeline/condition-evaluator.test.ts +129 -0
- package/src/pipeline/condition-evaluator.ts +121 -0
- package/src/pipeline/context-pack-loader.ts +63 -0
- package/src/pipeline/context-propagation.test.ts +237 -0
- package/src/pipeline/context-propagation.ts +235 -0
- package/src/pipeline/contract-loader.ts +41 -0
- package/src/pipeline/group-orchestrator.ts +483 -0
- package/src/pipeline/hook-executor.test.ts +121 -0
- package/src/pipeline/hook-executor.ts +87 -0
- package/src/pipeline/invariants-loader.test.ts +51 -0
- package/src/pipeline/invariants-loader.ts +14 -0
- package/src/pipeline/loader.test.ts +128 -0
- package/src/pipeline/loader.ts +149 -0
- package/src/pipeline/output-validator.test.ts +100 -0
- package/src/pipeline/output-validator.ts +40 -0
- package/src/pipeline/post-validator.ts +124 -0
- package/src/pipeline/skill-loader.test.ts +44 -0
- package/src/pipeline/skill-loader.ts +32 -0
- package/src/pipeline/stage-executor.ts +654 -0
- package/src/pipeline/stage-resolver.ts +8 -0
- package/src/pipeline/startup-executor.test.ts +37 -0
- package/src/pipeline/startup-executor.ts +32 -0
- package/src/pipeline/types.ts +42 -0
- package/src/repo-resolver.ts +61 -0
- package/src/spawners/direct-engine-spawner.ts +26 -0
- package/src/state/run-store.test.ts +157 -0
- package/src/state/run-store.ts +362 -0
- package/src/state/state-machine.ts +35 -0
- package/src/state/status-derivation.ts +36 -0
- package/tests/context-pack-loader.test.ts +113 -0
- package/tests/context-propagation.test.ts +267 -0
- package/tests/direct-engine-spawner.test.ts +102 -0
- package/tests/e2e/feature-v5.test.ts +57 -0
- package/tests/events.test.ts +56 -0
- package/tests/fixtures/agents/test-agent.agent.yaml +5 -0
- package/tests/fixtures/contracts/code-gen.contract.yaml +6 -0
- package/tests/fixtures/contracts/qa-gate.contract.yaml +15 -0
- package/tests/fixtures/contracts/test-contract.contract.yaml +6 -0
- package/tests/fixtures/pipelines/group-test.pipeline.yaml +40 -0
- package/tests/fixtures/pipelines/simple.pipeline.yaml +15 -0
- package/tests/fixtures/pipelines/two-stage.pipeline.yaml +24 -0
- package/tests/fixtures/software/pipelines/feature-builder.pipeline.yaml +75 -0
- package/tests/fixtures/test-project/agents/test-agent.agent.yaml +5 -0
- package/tests/fixtures/test-project/contracts/basic-result.contract.yaml +6 -0
- package/tests/fixtures/test-project/contracts/code-gen.contract.yaml +6 -0
- package/tests/fixtures/test-project/contracts/maximum-only.contract.yaml +8 -0
- package/tests/fixtures/test-project/contracts/qa-gate.contract.yaml +15 -0
- package/tests/fixtures/test-project/contracts/strict-result.contract.yaml +7 -0
- package/tests/fixtures/test-project/contracts/test-contract.contract.yaml +6 -0
- package/tests/fixtures/test-project/pipelines/group-simple.pipeline.yaml +26 -0
- package/tests/fixtures/test-project/pipelines/group-test.pipeline.yaml +40 -0
- package/tests/fixtures/test-project/pipelines/hook-output-template.pipeline.yaml +19 -0
- package/tests/fixtures/test-project/pipelines/hook-reject-on-failure.pipeline.yaml +19 -0
- package/tests/fixtures/test-project/pipelines/maximum-only.pipeline.yaml +15 -0
- package/tests/fixtures/test-project/pipelines/parallel-collect-all-test.pipeline.yaml +39 -0
- package/tests/fixtures/test-project/pipelines/parallel-context-isolation-test.pipeline.yaml +38 -0
- package/tests/fixtures/test-project/pipelines/parallel-fail-test.pipeline.yaml +39 -0
- package/tests/fixtures/test-project/pipelines/parallel-test.pipeline.yaml +39 -0
- package/tests/fixtures/test-project/pipelines/parallel-then-sequential-test.pipeline.yaml +38 -0
- package/tests/fixtures/test-project/pipelines/simple.pipeline.yaml +15 -0
- package/tests/fixtures/test-project/pipelines/two-stage.pipeline.yaml +24 -0
- package/tests/fixtures/test-project/pipelines/with-startup.pipeline.yaml +19 -0
- package/tests/loader.test.ts +385 -0
- package/tests/post-validator.test.ts +297 -0
- package/tests/repo-resolver.test.ts +102 -0
- package/tests/run-store.test.ts +143 -0
- package/tests/state-machine.test.ts +110 -0
- package/tests/unit/context-propagation.test.ts +45 -0
- package/tests/unit/engine.test.ts +770 -0
- package/tests/unit/group-loop.test.ts +389 -0
- package/tests/unit/group-parallel.test.ts +423 -0
- package/tests/unit/state/status-derivation.test.ts +88 -0
- package/tsconfig.json +24 -0
- 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,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
|
+
});
|