@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execSync: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('node:fs/promises', () => ({
|
|
9
|
+
mkdir: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { resolveRepoPath, cloneRepo } from '../src/repo-resolver.js';
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
const mockExecSync = vi.mocked(execSync);
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.unstubAllEnvs();
|
|
19
|
+
mockExecSync.mockClear();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('resolveRepoPath', () => {
|
|
23
|
+
it('returns resolved repoPathOverride when provided', async () => {
|
|
24
|
+
const result = await resolveRepoPath({ repoPathOverride: 'my-project', pipelineName: 'p' });
|
|
25
|
+
expect(result).toMatch(/my-project$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns "." when no repoPathOverride and no repoUrl', async () => {
|
|
29
|
+
const result = await resolveRepoPath({ pipelineName: 'feature-builder' });
|
|
30
|
+
expect(result).toBe('.');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws when repoUrl is set but no rawProjectsDir and no env var', async () => {
|
|
34
|
+
vi.stubEnv('STUDIO_PROJECTS_DIR', '');
|
|
35
|
+
await expect(
|
|
36
|
+
resolveRepoPath({ repoUrl: 'https://github.com/user/repo', pipelineName: 'p' })
|
|
37
|
+
).rejects.toThrow('STUDIO_PROJECTS_DIR is not set');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('clones when repoUrl is provided with rawProjectsDir', async () => {
|
|
41
|
+
const result = await resolveRepoPath({
|
|
42
|
+
repoUrl: 'https://github.com/user/repo',
|
|
43
|
+
rawProjectsDir: '/tmp/projects',
|
|
44
|
+
pipelineName: 'my-pipeline',
|
|
45
|
+
});
|
|
46
|
+
expect(mockExecSync).toHaveBeenCalledOnce();
|
|
47
|
+
const cmd = mockExecSync.mock.calls[0]![0] as string;
|
|
48
|
+
expect(cmd).toContain('git clone');
|
|
49
|
+
expect(cmd).toContain('https://github.com/user/repo');
|
|
50
|
+
expect(result).toContain('/tmp/projects/my-pipeline-');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('clones when repoUrl is provided via STUDIO_PROJECTS_DIR env var', async () => {
|
|
54
|
+
vi.stubEnv('STUDIO_PROJECTS_DIR', '/tmp/envprojects');
|
|
55
|
+
const result = await resolveRepoPath({
|
|
56
|
+
repoUrl: 'https://github.com/user/repo',
|
|
57
|
+
pipelineName: 'test-pipe',
|
|
58
|
+
});
|
|
59
|
+
expect(mockExecSync).toHaveBeenCalledOnce();
|
|
60
|
+
expect(result).toContain('/tmp/envprojects/test-pipe-');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('expands ~ in rawProjectsDir', async () => {
|
|
64
|
+
const result = await resolveRepoPath({
|
|
65
|
+
repoUrl: 'https://github.com/user/repo',
|
|
66
|
+
rawProjectsDir: '~/projects',
|
|
67
|
+
pipelineName: 'p',
|
|
68
|
+
});
|
|
69
|
+
expect(result).toContain(homedir() + '/projects/p-');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('repoPathOverride takes precedence over repoUrl', async () => {
|
|
73
|
+
const result = await resolveRepoPath({
|
|
74
|
+
repoPathOverride: '/explicit/path',
|
|
75
|
+
repoUrl: 'https://github.com/user/repo',
|
|
76
|
+
rawProjectsDir: '/tmp/p',
|
|
77
|
+
pipelineName: 'p',
|
|
78
|
+
});
|
|
79
|
+
expect(result).toBe('/explicit/path');
|
|
80
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('passes branch arg when branch is set', async () => {
|
|
84
|
+
await resolveRepoPath({
|
|
85
|
+
repoUrl: 'https://github.com/user/repo',
|
|
86
|
+
rawProjectsDir: '/tmp/projects',
|
|
87
|
+
pipelineName: 'p',
|
|
88
|
+
branch: 'main',
|
|
89
|
+
});
|
|
90
|
+
const cmd = mockExecSync.mock.calls[0]![0] as string;
|
|
91
|
+
expect(cmd).toContain('--branch main');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('cloneRepo', () => {
|
|
96
|
+
it('throws a descriptive error when git clone fails', async () => {
|
|
97
|
+
mockExecSync.mockImplementationOnce(() => { throw new Error('repository not found'); });
|
|
98
|
+
await expect(
|
|
99
|
+
cloneRepo('https://github.com/bad/repo', '/tmp/p', 'pipeline')
|
|
100
|
+
).rejects.toThrow('Failed to clone https://github.com/bad/repo');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { InMemoryRunStore } from '../src/state/run-store.js';
|
|
3
|
+
import type { PipelineRun } from '@studio-foundation/contracts';
|
|
4
|
+
|
|
5
|
+
function makePipelineRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
|
|
6
|
+
return {
|
|
7
|
+
id: `run-${Math.random().toString(36).slice(2, 8)}`,
|
|
8
|
+
pipeline_name: 'test-pipeline',
|
|
9
|
+
status: 'success',
|
|
10
|
+
started_at: new Date().toISOString(),
|
|
11
|
+
completed_at: new Date().toISOString(),
|
|
12
|
+
stages: [],
|
|
13
|
+
...overrides,
|
|
14
|
+
} as PipelineRun;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('InMemoryRunStore', () => {
|
|
18
|
+
let store: InMemoryRunStore;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
store = new InMemoryRunStore();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('saves and retrieves a pipeline run', () => {
|
|
25
|
+
const run = makePipelineRun({ id: 'run-1' });
|
|
26
|
+
store.savePipelineRun(run);
|
|
27
|
+
|
|
28
|
+
const retrieved = store.getPipelineRun('run-1');
|
|
29
|
+
expect(retrieved).not.toBeNull();
|
|
30
|
+
expect(retrieved!.id).toBe('run-1');
|
|
31
|
+
expect(retrieved!.pipeline_name).toBe('test-pipeline');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns null for non-existent run', () => {
|
|
35
|
+
expect(store.getPipelineRun('nonexistent')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns a clone (not the original reference)', () => {
|
|
39
|
+
const run = makePipelineRun({ id: 'run-clone' });
|
|
40
|
+
store.savePipelineRun(run);
|
|
41
|
+
|
|
42
|
+
const retrieved = store.getPipelineRun('run-clone');
|
|
43
|
+
expect(retrieved).not.toBe(run);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('listPipelineRuns returns all runs', () => {
|
|
47
|
+
store.savePipelineRun(makePipelineRun({ id: 'r1', started_at: '2024-01-01T00:00:00Z' }));
|
|
48
|
+
store.savePipelineRun(makePipelineRun({ id: 'r2', started_at: '2024-01-02T00:00:00Z' }));
|
|
49
|
+
store.savePipelineRun(makePipelineRun({ id: 'r3', started_at: '2024-01-03T00:00:00Z' }));
|
|
50
|
+
|
|
51
|
+
const runs = store.listPipelineRuns();
|
|
52
|
+
expect(runs).toHaveLength(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('listPipelineRuns sorts by started_at descending', () => {
|
|
56
|
+
store.savePipelineRun(makePipelineRun({ id: 'r1', started_at: '2024-01-01T00:00:00Z' }));
|
|
57
|
+
store.savePipelineRun(makePipelineRun({ id: 'r3', started_at: '2024-01-03T00:00:00Z' }));
|
|
58
|
+
store.savePipelineRun(makePipelineRun({ id: 'r2', started_at: '2024-01-02T00:00:00Z' }));
|
|
59
|
+
|
|
60
|
+
const runs = store.listPipelineRuns();
|
|
61
|
+
expect(runs[0].id).toBe('r3');
|
|
62
|
+
expect(runs[1].id).toBe('r2');
|
|
63
|
+
expect(runs[2].id).toBe('r1');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('listPipelineRuns respects limit', () => {
|
|
67
|
+
store.savePipelineRun(makePipelineRun({ id: 'r1', started_at: '2024-01-01T00:00:00Z' }));
|
|
68
|
+
store.savePipelineRun(makePipelineRun({ id: 'r2', started_at: '2024-01-02T00:00:00Z' }));
|
|
69
|
+
store.savePipelineRun(makePipelineRun({ id: 'r3', started_at: '2024-01-03T00:00:00Z' }));
|
|
70
|
+
|
|
71
|
+
const runs = store.listPipelineRuns({ limit: 2 });
|
|
72
|
+
expect(runs).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('listPipelineRuns filters by status', () => {
|
|
76
|
+
store.savePipelineRun(makePipelineRun({ id: 'r1', status: 'success' }));
|
|
77
|
+
store.savePipelineRun(makePipelineRun({ id: 'r2', status: 'failed' }));
|
|
78
|
+
store.savePipelineRun(makePipelineRun({ id: 'r3', status: 'success' }));
|
|
79
|
+
|
|
80
|
+
const runs = store.listPipelineRuns({ status: 'failed' });
|
|
81
|
+
expect(runs).toHaveLength(1);
|
|
82
|
+
expect(runs[0].id).toBe('r2');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('getLatestRun returns the most recent run', () => {
|
|
86
|
+
store.savePipelineRun(makePipelineRun({ id: 'r1', started_at: '2024-01-01T00:00:00Z' }));
|
|
87
|
+
store.savePipelineRun(makePipelineRun({ id: 'r2', started_at: '2024-01-03T00:00:00Z' }));
|
|
88
|
+
store.savePipelineRun(makePipelineRun({ id: 'r3', started_at: '2024-01-02T00:00:00Z' }));
|
|
89
|
+
|
|
90
|
+
const latest = store.getLatestRun();
|
|
91
|
+
expect(latest).not.toBeNull();
|
|
92
|
+
expect(latest!.id).toBe('r2');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('getLatestRun filters by pipeline name', () => {
|
|
96
|
+
store.savePipelineRun(makePipelineRun({
|
|
97
|
+
id: 'r1',
|
|
98
|
+
pipeline_name: 'feature-builder',
|
|
99
|
+
started_at: '2024-01-01T00:00:00Z',
|
|
100
|
+
}));
|
|
101
|
+
store.savePipelineRun(makePipelineRun({
|
|
102
|
+
id: 'r2',
|
|
103
|
+
pipeline_name: 'other-pipeline',
|
|
104
|
+
started_at: '2024-01-03T00:00:00Z',
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
const latest = store.getLatestRun('feature-builder');
|
|
108
|
+
expect(latest).not.toBeNull();
|
|
109
|
+
expect(latest!.id).toBe('r1');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('getLatestRun returns null when empty', () => {
|
|
113
|
+
expect(store.getLatestRun()).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('overwrites existing run on save with same id', () => {
|
|
117
|
+
const run = makePipelineRun({ id: 'r1', status: 'running' });
|
|
118
|
+
store.savePipelineRun(run);
|
|
119
|
+
|
|
120
|
+
const updated = { ...run, status: 'success' as const, completed_at: new Date().toISOString() };
|
|
121
|
+
store.savePipelineRun(updated);
|
|
122
|
+
|
|
123
|
+
const retrieved = store.getPipelineRun('r1');
|
|
124
|
+
expect(retrieved!.status).toBe('success');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('log path', () => {
|
|
128
|
+
it('returns null for unknown run', () => {
|
|
129
|
+
expect(store.getLogPath('nonexistent')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('saves and retrieves a log path', () => {
|
|
133
|
+
store.saveLogPath('run-1', '/tmp/.studio/runs/log.jsonl');
|
|
134
|
+
expect(store.getLogPath('run-1')).toBe('/tmp/.studio/runs/log.jsonl');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('overwrites existing log path', () => {
|
|
138
|
+
store.saveLogPath('run-1', '/tmp/old.jsonl');
|
|
139
|
+
store.saveLogPath('run-1', '/tmp/new.jsonl');
|
|
140
|
+
expect(store.getLogPath('run-1')).toBe('/tmp/new.jsonl');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isValidTransition, transition } from '../src/state/state-machine.js';
|
|
3
|
+
|
|
4
|
+
describe('isValidTransition', () => {
|
|
5
|
+
it('pending → running is valid', () => {
|
|
6
|
+
expect(isValidTransition('pending', 'running')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('running → success is valid', () => {
|
|
10
|
+
expect(isValidTransition('running', 'success')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('running → failed is valid', () => {
|
|
14
|
+
expect(isValidTransition('running', 'failed')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('pending → skipped is valid', () => {
|
|
18
|
+
expect(isValidTransition('pending', 'skipped')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('pending → success is INVALID', () => {
|
|
22
|
+
expect(isValidTransition('pending', 'success')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('success → running is INVALID', () => {
|
|
26
|
+
expect(isValidTransition('success', 'running')).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('failed → running is INVALID', () => {
|
|
30
|
+
expect(isValidTransition('failed', 'running')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('success → failed is INVALID', () => {
|
|
34
|
+
expect(isValidTransition('success', 'failed')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('pending → failed is INVALID', () => {
|
|
38
|
+
expect(isValidTransition('pending', 'failed')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('running → rejected is valid', () => {
|
|
42
|
+
expect(isValidTransition('running', 'rejected')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('pending → rejected is INVALID', () => {
|
|
46
|
+
expect(isValidTransition('pending', 'rejected')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejected → running is INVALID', () => {
|
|
50
|
+
expect(isValidTransition('rejected', 'running')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('running → cancelled is valid', () => {
|
|
54
|
+
expect(isValidTransition('running', 'cancelled')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('pending → cancelled is INVALID', () => {
|
|
58
|
+
expect(isValidTransition('pending', 'cancelled')).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('cancelled → running is INVALID', () => {
|
|
62
|
+
expect(isValidTransition('cancelled', 'running')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('transition', () => {
|
|
67
|
+
it('pending + start → running', () => {
|
|
68
|
+
expect(transition('pending', 'start')).toBe('running');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('running + succeed → success', () => {
|
|
72
|
+
expect(transition('running', 'succeed')).toBe('success');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('running + fail → failed', () => {
|
|
76
|
+
expect(transition('running', 'fail')).toBe('failed');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('pending + skip → skipped', () => {
|
|
80
|
+
expect(transition('pending', 'skip')).toBe('skipped');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('pending + succeed throws', () => {
|
|
84
|
+
expect(() => transition('pending', 'succeed')).toThrow('Invalid state transition');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('success + start throws', () => {
|
|
88
|
+
expect(() => transition('success', 'start')).toThrow('Invalid state transition');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('failed + succeed throws', () => {
|
|
92
|
+
expect(() => transition('failed', 'succeed')).toThrow('Invalid state transition');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('running + reject → rejected', () => {
|
|
96
|
+
expect(transition('running', 'reject')).toBe('rejected');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('pending + reject throws', () => {
|
|
100
|
+
expect(() => transition('pending', 'reject')).toThrow('Invalid state transition');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('running + cancel → cancelled', () => {
|
|
104
|
+
expect(transition('running', 'cancel')).toBe('cancelled');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('pending + cancel throws', () => {
|
|
108
|
+
expect(() => transition('pending', 'cancel')).toThrow('Invalid state transition');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getContextForStage,
|
|
4
|
+
createInitialContext,
|
|
5
|
+
} from '../../src/pipeline/context-propagation.js';
|
|
6
|
+
import type { StageDefinition } from '@studio-foundation/contracts';
|
|
7
|
+
|
|
8
|
+
const makeStage = (name: string, includes: string[]): StageDefinition => ({
|
|
9
|
+
name,
|
|
10
|
+
kind: 'analysis',
|
|
11
|
+
agent: 'test-agent',
|
|
12
|
+
context: { include: includes },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('getContextForStage — stage_name', () => {
|
|
16
|
+
it('injects stage_name when included', () => {
|
|
17
|
+
const ctx = createInitialContext({ userId: 'u1' });
|
|
18
|
+
const stage = makeStage('recipe-1', ['stage_name']);
|
|
19
|
+
|
|
20
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
21
|
+
|
|
22
|
+
expect(agentCtx.stage_name).toBe('recipe-1');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not inject stage_name when not included', () => {
|
|
26
|
+
const ctx = createInitialContext({ userId: 'u1' });
|
|
27
|
+
const stage = makeStage('recipe-1', ['input']);
|
|
28
|
+
|
|
29
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
30
|
+
|
|
31
|
+
expect(agentCtx.stage_name).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('injects correct name for each stage', () => {
|
|
35
|
+
const ctx = createInitialContext('input data');
|
|
36
|
+
const stages = ['recipe-1', 'recipe-2', 'recipe-5'].map(n =>
|
|
37
|
+
makeStage(n, ['stage_name'])
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
for (const stage of stages) {
|
|
41
|
+
const agentCtx = getContextForStage(ctx, stage);
|
|
42
|
+
expect(agentCtx.stage_name).toBe(stage.name);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|