@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
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { PipelineEngine } from '../engine.js';
3
+ import type { PipelineDefinition } from '@studio-foundation/contracts';
4
+
5
+ vi.mock('@studio-foundation/runner', async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import('@studio-foundation/runner')>();
7
+ return {
8
+ ...actual,
9
+ runScript: vi.fn(),
10
+ };
11
+ });
12
+
13
+ import { runScript } from '@studio-foundation/runner';
14
+
15
+ function makeEngine() {
16
+ return new PipelineEngine({
17
+ configsDir: '/tmp',
18
+ providerRegistry: {} as any,
19
+ });
20
+ }
21
+
22
+ function mockScriptSuccess(output: Record<string, unknown> = { result: 'ok' }) {
23
+ vi.mocked(runScript).mockResolvedValue({
24
+ output,
25
+ tool_calls: [],
26
+ tool_calls_count: 0,
27
+ duration_ms: 10,
28
+ });
29
+ }
30
+
31
+ describe('engine — resume from stage', () => {
32
+ beforeEach(() => vi.clearAllMocks());
33
+
34
+ const pipeline: PipelineDefinition = {
35
+ name: 'test-resume',
36
+ description: 'test',
37
+ version: 1,
38
+ stages: [
39
+ { name: 'stage-a', executor: 'script', script: 'x.py', runtime: 'shell' },
40
+ { name: 'stage-b', executor: 'script', script: 'x.py', runtime: 'shell' },
41
+ { name: 'stage-c', executor: 'script', script: 'x.py', runtime: 'shell' },
42
+ ],
43
+ };
44
+
45
+ it('skips stages before resumeFromStage and marks them skipped', async () => {
46
+ mockScriptSuccess({ result: 'c-result' });
47
+
48
+ const engine = makeEngine();
49
+ const result = await engine.run({
50
+ pipelineDef: pipeline,
51
+ input: { x: 1 },
52
+ resumeFromStage: 'stage-c',
53
+ priorStageOutputs: new Map([
54
+ ['stage-a', { result: 'a-cached' }],
55
+ ['stage-b', { result: 'b-cached' }],
56
+ ]),
57
+ originalRunId: 'abc12345',
58
+ });
59
+
60
+ expect(result.status).toBe('success');
61
+ expect(result.stages).toHaveLength(3);
62
+ expect(result.stages[0]?.stage_name).toBe('stage-a');
63
+ expect(result.stages[0]?.status).toBe('skipped');
64
+ expect(result.stages[0]?.skipped_reason).toContain('abc12345');
65
+ expect(result.stages[1]?.stage_name).toBe('stage-b');
66
+ expect(result.stages[1]?.status).toBe('skipped');
67
+ expect(result.stages[2]?.stage_name).toBe('stage-c');
68
+ expect(result.stages[2]?.status).toBe('success');
69
+ // Only stage-c was actually executed
70
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it('pre-populates context so resumed stage can access prior outputs', async () => {
74
+ mockScriptSuccess({ result: 'ok' });
75
+
76
+ const engine = makeEngine();
77
+ const result = await engine.run({
78
+ pipelineDef: {
79
+ ...pipeline,
80
+ stages: [
81
+ { name: 'stage-a', executor: 'script', script: 'x.py', runtime: 'shell' },
82
+ { name: 'stage-b', executor: 'script', script: 'x.py', runtime: 'shell' },
83
+ ],
84
+ },
85
+ input: { x: 1 },
86
+ resumeFromStage: 'stage-b',
87
+ priorStageOutputs: new Map([['stage-a', { result: 'a-cached' }]]),
88
+ });
89
+
90
+ // stage-b ran successfully (prior context was pre-populated, no crash)
91
+ expect(result.status).toBe('success');
92
+ expect(result.stages).toHaveLength(2);
93
+ expect(result.stages[0]?.status).toBe('skipped');
94
+ expect(result.stages[1]?.status).toBe('success');
95
+ });
96
+
97
+ it('throws if resumeFromStage is not found in pipeline', async () => {
98
+ const engine = makeEngine();
99
+ await expect(
100
+ engine.run({
101
+ pipelineDef: pipeline,
102
+ input: {},
103
+ resumeFromStage: 'nonexistent-stage',
104
+ priorStageOutputs: new Map(),
105
+ })
106
+ ).rejects.toThrow(/not found/i);
107
+ });
108
+ });
@@ -0,0 +1,125 @@
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 runScript from runner to avoid real subprocess spawning
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
+ const SCRIPT_PIPELINE: PipelineDefinition = {
20
+ name: 'test-script-pipeline',
21
+ description: 'Test pipeline with script stage',
22
+ version: 1,
23
+ stages: [
24
+ {
25
+ name: 'epub-ingestion',
26
+ executor: 'script',
27
+ script: 'scripts/parse.py',
28
+ runtime: 'python',
29
+ contract: 'book-context',
30
+ },
31
+ ],
32
+ };
33
+
34
+ function makeEngine() {
35
+ return new PipelineEngine({
36
+ configsDir: FIXTURES_DIR,
37
+ providerRegistry: {} as any,
38
+ });
39
+ }
40
+
41
+ describe('engine — script stage execution', () => {
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ it('executes a script stage and returns success when output matches contract', async () => {
47
+ vi.mocked(runScript).mockResolvedValue({
48
+ output: { title: 'My Book', chapters: 3 },
49
+ tool_calls: [],
50
+ tool_calls_count: 0,
51
+ duration_ms: 50,
52
+ });
53
+
54
+ const engine = makeEngine();
55
+ const result = await engine.run({
56
+ pipelineDef: SCRIPT_PIPELINE,
57
+ userInput: 'parse book.epub',
58
+ });
59
+
60
+ expect(result.status).toBe('success');
61
+ expect(result.stages[0]?.status).toBe('success');
62
+ expect(result.stages[0]?.output).toEqual({ title: 'My Book', chapters: 3 });
63
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(1);
64
+ expect(vi.mocked(runScript)).toHaveBeenCalledWith(
65
+ expect.objectContaining({
66
+ scriptPath: 'scripts/parse.py',
67
+ runtime: 'python',
68
+ }),
69
+ );
70
+ });
71
+
72
+ it('retries on script error (runScript returns error field)', async () => {
73
+ const scriptError = {
74
+ output: null,
75
+ tool_calls: [],
76
+ tool_calls_count: 0,
77
+ duration_ms: 10,
78
+ error: 'Script exited with code 1: parse error',
79
+ };
80
+ vi.mocked(runScript)
81
+ .mockResolvedValueOnce(scriptError)
82
+ .mockResolvedValueOnce(scriptError)
83
+ .mockResolvedValue({
84
+ output: { title: 'My Book', chapters: 3 },
85
+ tool_calls: [],
86
+ tool_calls_count: 0,
87
+ duration_ms: 50,
88
+ });
89
+
90
+ const engine = makeEngine();
91
+ const result = await engine.run({
92
+ pipelineDef: {
93
+ ...SCRIPT_PIPELINE,
94
+ stages: [{ ...SCRIPT_PIPELINE.stages[0], ralph: { max_attempts: 3, retry_strategy: 'none' } }],
95
+ },
96
+ userInput: 'parse book.epub',
97
+ });
98
+
99
+ expect(result.status).toBe('success');
100
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(3);
101
+ });
102
+
103
+ it('fails stage after exhausting max_attempts', async () => {
104
+ vi.mocked(runScript).mockResolvedValue({
105
+ output: null,
106
+ tool_calls: [],
107
+ tool_calls_count: 0,
108
+ duration_ms: 10,
109
+ error: 'Script exited with code 1: always failing',
110
+ });
111
+
112
+ const engine = makeEngine();
113
+ const result = await engine.run({
114
+ pipelineDef: {
115
+ ...SCRIPT_PIPELINE,
116
+ stages: [{ ...SCRIPT_PIPELINE.stages[0], ralph: { max_attempts: 2, retry_strategy: 'none' } }],
117
+ },
118
+ userInput: 'parse book.epub',
119
+ });
120
+
121
+ expect(result.status).toBe('failed');
122
+ expect(result.stages[0]?.status).toBe('failed');
123
+ expect(vi.mocked(runScript)).toHaveBeenCalledTimes(2);
124
+ });
125
+ });
@@ -0,0 +1,5 @@
1
+ // Database client — re-exports from run-store
2
+ // Kept for backward compatibility with the barrel structure
3
+
4
+ export { SQLiteRunStore, InMemoryRunStore, PgRunStore } from '../state/run-store.js';
5
+ export type { RunStore, AsyncRunStore, AnyRunStore } from '../state/run-store.js';
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { PipelineEngine } from './engine.js';
6
+ import { createDefaultRegistry, ToolRegistry, MockProvider } from '@studio-foundation/runner';
7
+ import type { StageContextEvent } from './events.js';
8
+
9
+ async function makeTestDirs(): Promise<{ configsDir: string }> {
10
+ const base = await mkdtemp(join(tmpdir(), 'studio-ctx-test-'));
11
+ const configsDir = join(base, '.studio');
12
+ await mkdir(join(configsDir, 'pipelines'), { recursive: true });
13
+ await mkdir(join(configsDir, 'agents'), { recursive: true });
14
+ await mkdir(join(configsDir, 'contracts'), { recursive: true });
15
+
16
+ await writeFile(
17
+ join(configsDir, 'pipelines', 'test-pipe.pipeline.yaml'),
18
+ `
19
+ name: test-pipe
20
+ description: test
21
+ version: 1
22
+ stages:
23
+ - name: my-stage
24
+ kind: analysis
25
+ agent: analyst
26
+ contract: stage-contract
27
+ context:
28
+ include: [input]
29
+ `
30
+ );
31
+
32
+ await writeFile(
33
+ join(configsDir, 'agents', 'analyst.agent.yaml'),
34
+ `
35
+ name: analyst
36
+ provider: mock
37
+ model: mock
38
+ system_prompt: "You are an analyst."
39
+ `
40
+ );
41
+
42
+ await writeFile(
43
+ join(configsDir, 'contracts', 'stage-contract.contract.yaml'),
44
+ `
45
+ name: stage-contract
46
+ version: 1
47
+ schema:
48
+ required_fields:
49
+ - summary
50
+ `
51
+ );
52
+
53
+ return { configsDir };
54
+ }
55
+
56
+ // MockProvider keys on contract_name (= stage_contract). Use this in all tests.
57
+ const MOCK_STAGE_KEY = 'stage-contract';
58
+
59
+ describe('PipelineEngine — onStageContext event', () => {
60
+ it('emits onStageContext once per stage', async () => {
61
+ const { configsDir } = await makeTestDirs();
62
+
63
+ const mockStages = new Map([
64
+ [MOCK_STAGE_KEY, { output: { summary: 'done' }, tool_calls: [] }],
65
+ ]);
66
+ const mockProvider = new MockProvider(mockStages);
67
+ const providerRegistry = createDefaultRegistry({});
68
+ providerRegistry.register(mockProvider);
69
+ const toolRegistry = new ToolRegistry();
70
+
71
+ const received: StageContextEvent[] = [];
72
+
73
+ const engine = new PipelineEngine(
74
+ { configsDir, providerRegistry, toolRegistry },
75
+ { onStageContext: (e) => received.push(e) }
76
+ );
77
+
78
+ await engine.run({ pipeline: 'test-pipe', input: 'test input' });
79
+
80
+ expect(received).toHaveLength(1);
81
+ expect(received[0].stage).toBe('my-stage');
82
+ expect(received[0].run_id).toMatch(/^[0-9a-f-]{36}$/); // uuid
83
+ expect(received[0].context_keys.input).toBe('test input'.length);
84
+ });
85
+
86
+ it('includes no context_content by default (DEBUG unset)', async () => {
87
+ const { configsDir } = await makeTestDirs();
88
+ delete process.env.DEBUG;
89
+
90
+ const mockProvider = new MockProvider(
91
+ new Map([[MOCK_STAGE_KEY, { output: { summary: 'done' }, tool_calls: [] }]])
92
+ );
93
+ const providerRegistry = createDefaultRegistry({});
94
+ providerRegistry.register(mockProvider);
95
+
96
+ const received: StageContextEvent[] = [];
97
+ const engine = new PipelineEngine(
98
+ { configsDir, providerRegistry, toolRegistry: new ToolRegistry() },
99
+ { onStageContext: (e) => received.push(e) }
100
+ );
101
+
102
+ await engine.run({ pipeline: 'test-pipe', input: 'hello' });
103
+
104
+ expect(received[0].context_content).toBeUndefined();
105
+ expect(received[0].system_prompt).toBeUndefined();
106
+ });
107
+
108
+ it('includes context_content when DEBUG=studio:context', async () => {
109
+ const { configsDir } = await makeTestDirs();
110
+ process.env.DEBUG = 'studio:context';
111
+
112
+ const mockProvider = new MockProvider(
113
+ new Map([[MOCK_STAGE_KEY, { output: { summary: 'done' }, tool_calls: [] }]])
114
+ );
115
+ const providerRegistry = createDefaultRegistry({});
116
+ providerRegistry.register(mockProvider);
117
+
118
+ const received: StageContextEvent[] = [];
119
+ const engine = new PipelineEngine(
120
+ { configsDir, providerRegistry, toolRegistry: new ToolRegistry() },
121
+ { onStageContext: (e) => received.push(e) }
122
+ );
123
+
124
+ await engine.run({ pipeline: 'test-pipe', input: 'hello' });
125
+
126
+ expect(received[0].context_content).toBeDefined();
127
+ expect(received[0].context_content?.input).toBe('hello');
128
+ expect(received[0].system_prompt).toBeUndefined();
129
+
130
+ delete process.env.DEBUG;
131
+ });
132
+
133
+ it('includes system_prompt when DEBUG=studio:context:verbose', async () => {
134
+ const { configsDir } = await makeTestDirs();
135
+ process.env.DEBUG = 'studio:context:verbose';
136
+
137
+ const mockProvider = new MockProvider(
138
+ new Map([[MOCK_STAGE_KEY, { output: { summary: 'done' }, tool_calls: [] }]])
139
+ );
140
+ const providerRegistry = createDefaultRegistry({});
141
+ providerRegistry.register(mockProvider);
142
+
143
+ const received: StageContextEvent[] = [];
144
+ const engine = new PipelineEngine(
145
+ { configsDir, providerRegistry, toolRegistry: new ToolRegistry() },
146
+ { onStageContext: (e) => received.push(e) }
147
+ );
148
+
149
+ await engine.run({ pipeline: 'test-pipe', input: 'hello' });
150
+
151
+ expect(received[0].context_content).toBeDefined(); // verbose implies context
152
+ expect(received[0].system_prompt).toBe('You are an analyst.');
153
+
154
+ delete process.env.DEBUG;
155
+ });
156
+
157
+ it('does not call handler at all when onStageContext is not registered', async () => {
158
+ const { configsDir } = await makeTestDirs();
159
+
160
+ const mockProvider = new MockProvider(
161
+ new Map([[MOCK_STAGE_KEY, { output: { summary: 'done' }, tool_calls: [] }]])
162
+ );
163
+ const providerRegistry = createDefaultRegistry({});
164
+ providerRegistry.register(mockProvider);
165
+
166
+ // No onStageContext handler
167
+ const engine = new PipelineEngine(
168
+ { configsDir, providerRegistry, toolRegistry: new ToolRegistry() },
169
+ { onStageComplete: vi.fn() } // some other handler but not onStageContext
170
+ );
171
+
172
+ // Should not throw
173
+ await engine.run({ pipeline: 'test-pipe', input: 'hello' });
174
+ });
175
+ });