@owlmeans/agent 0.1.18-rc.39 → 0.1.18-rc.41
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/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/agent/SKILL.md +96 -9
- package/build/model.d.ts.map +1 -1
- package/build/model.js +4 -1
- package/build/model.js.map +1 -1
- package/build/pipeline/index.d.ts +1 -0
- package/build/pipeline/index.d.ts.map +1 -1
- package/build/pipeline/index.js +1 -0
- package/build/pipeline/index.js.map +1 -1
- package/build/pipeline/results.d.ts +121 -0
- package/build/pipeline/results.d.ts.map +1 -0
- package/build/pipeline/results.js +572 -0
- package/build/pipeline/results.js.map +1 -0
- package/build/pipeline/runner.d.ts.map +1 -1
- package/build/pipeline/runner.js +170 -15
- package/build/pipeline/runner.js.map +1 -1
- package/build/pipeline/types.d.ts +140 -2
- package/build/pipeline/types.d.ts.map +1 -1
- package/build/stores/memory.d.ts +10 -1
- package/build/stores/memory.d.ts.map +1 -1
- package/build/stores/memory.js +0 -0
- package/build/stores/memory.js.map +1 -1
- package/build/stores/types.d.ts +23 -1
- package/build/stores/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/model.ts +4 -1
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/results.ts +827 -0
- package/src/pipeline/runner.ts +184 -15
- package/src/pipeline/types.ts +155 -2
- package/src/stores/memory.ts +0 -0
- package/src/stores/types.ts +24 -1
- package/tests/pipeline-compat.spec.ts +195 -0
- package/tests/pipeline-plugins.spec.ts +194 -0
- package/tests/pipeline-results.spec.ts +456 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { PipelineRunStatus } from '@owlmeans/agent-common'
|
|
3
|
+
import type { PipelineRun, PipelineSpec } from '@owlmeans/agent-common'
|
|
4
|
+
import { createMemoryPipelineRunStore, makePipeline } from '../src/index.js'
|
|
5
|
+
import type { PipelineRunContext, PipelineRunStore, PipelineStep } from '../src/index.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A pipeline that declares no plugin behaves exactly as the runner did before plugins existed.
|
|
9
|
+
*
|
|
10
|
+
* The expected values below are GOLDEN: they were recorded from the runner as it stood before the
|
|
11
|
+
* plugin seam was added, and every row the store is handed, every trace line and every progress
|
|
12
|
+
* event must still match them byte for byte. Timestamps are the only thing normalised — they are
|
|
13
|
+
* the one field that differs between two runs of the same code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface State extends Record<string, unknown> {
|
|
17
|
+
value?: number
|
|
18
|
+
cursor?: string
|
|
19
|
+
child?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Deps { log: string[] }
|
|
23
|
+
|
|
24
|
+
const normalise = (run: PipelineRun): string => JSON.stringify({
|
|
25
|
+
...run, startedAt: 'T', heartbeatAt: 'T', updatedAt: 'T',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const recording = (): { store: PipelineRunStore, saved: string[] } => {
|
|
29
|
+
const inner = createMemoryPipelineRunStore()
|
|
30
|
+
const saved: string[] = []
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
saved,
|
|
34
|
+
store: {
|
|
35
|
+
load: inner.load,
|
|
36
|
+
find: inner.find,
|
|
37
|
+
save: async run => {
|
|
38
|
+
saved.push(normalise(run))
|
|
39
|
+
await inner.save(run)
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const childSpec: PipelineSpec = {
|
|
46
|
+
alias: 'child', version: 1, steps: [{ step: 'c1' }, { step: 'c2', after: ['c1'] }],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parentSpec: PipelineSpec = {
|
|
50
|
+
alias: 'parent',
|
|
51
|
+
version: 1,
|
|
52
|
+
steps: [
|
|
53
|
+
{ step: 'a' },
|
|
54
|
+
{ step: 'left', after: ['a'] },
|
|
55
|
+
{ step: 'right', after: ['a'], optional: true },
|
|
56
|
+
{ step: 'skipped', after: ['left'] },
|
|
57
|
+
{ step: 'nested', after: ['left', 'right'] },
|
|
58
|
+
{ step: 'last', after: ['skipped', 'nested'] },
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const build = (store: PipelineRunStore, keys: { withPluginsKey: boolean }) => {
|
|
63
|
+
const contexts: PipelineRunContext<State, Deps>[] = []
|
|
64
|
+
const child = makePipeline<State, Deps>(childSpec, {
|
|
65
|
+
steps: [
|
|
66
|
+
{ step: 'c1', run: async state => ({ child: (state.child ?? 0) + 1 }) },
|
|
67
|
+
{ step: 'c2', run: async state => ({ child: (state.child ?? 0) + 10 }) },
|
|
68
|
+
],
|
|
69
|
+
runs: store,
|
|
70
|
+
...(keys.withPluginsKey ? { plugins: [] } : {}),
|
|
71
|
+
})
|
|
72
|
+
const steps: PipelineStep<State, Deps>[] = [
|
|
73
|
+
{
|
|
74
|
+
step: 'a',
|
|
75
|
+
run: async (_state, ctx) => {
|
|
76
|
+
contexts.push(ctx)
|
|
77
|
+
await ctx.mark({ cursor: 'half' })
|
|
78
|
+
ctx.report('halfway')
|
|
79
|
+
|
|
80
|
+
return { value: 1 }
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{ step: 'left', run: async (state, ctx) => { ctx.deps.log.push('left'); return { value: (state.value ?? 0) + 1 } } },
|
|
84
|
+
{ step: 'right', run: async () => { throw new Error('right refused') } },
|
|
85
|
+
{ step: 'skipped', skipWhen: () => true, run: async () => ({ value: 999 }) },
|
|
86
|
+
child.asStep<State>('nested', {
|
|
87
|
+
input: parent => ({ child: parent.value }),
|
|
88
|
+
output: result => ({ child: result.child }),
|
|
89
|
+
}),
|
|
90
|
+
{ step: 'last', run: async (state, ctx) => { contexts.push(ctx); return { value: (state.value ?? 0) + 100 } } },
|
|
91
|
+
]
|
|
92
|
+
const trace: string[] = []
|
|
93
|
+
const progress: string[] = []
|
|
94
|
+
const parent = makePipeline<State, Deps>(parentSpec, {
|
|
95
|
+
steps,
|
|
96
|
+
runs: store,
|
|
97
|
+
trace: line => { trace.push(line) },
|
|
98
|
+
onProgress: event => { progress.push(JSON.stringify(event)) },
|
|
99
|
+
...(keys.withPluginsKey ? { plugins: [] } : {}),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return { parent, trace, progress, contexts }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const GOLDEN_SAVED = [
|
|
106
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":[],"pending":["a","left","right","skipped","nested","last"],"state":"{\\"cursor\\":\\"half\\"}","stateChars":17,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T"}',
|
|
107
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":["a"],"pending":["left","right","skipped","nested","last"],"state":"{\\"cursor\\":\\"half\\",\\"value\\":1}","stateChars":27,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
108
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":["a","left"],"pending":["right","skipped","nested","last"],"state":"{\\"cursor\\":\\"half\\",\\"value\\":2}","stateChars":27,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
109
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":["a","left","right"],"pending":["skipped","nested","last"],"state":"{\\"cursor\\":\\"half\\",\\"value\\":2}","stateChars":27,"warnings":["right: right refused"],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
110
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":["a","left","right","skipped"],"pending":["nested","last"],"state":"{\\"cursor\\":\\"half\\",\\"value\\":2}","stateChars":27,"warnings":["right: right refused"],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
111
|
+
'{"runId":"r/nested","pipeline":"child","version":1,"scope":"s","status":"running","completed":["c1"],"pending":["c2"],"state":"{\\"child\\":3}","stateChars":11,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T"}',
|
|
112
|
+
'{"runId":"r/nested","pipeline":"child","version":1,"scope":"s","status":"running","completed":["c1","c2"],"pending":[],"state":"{\\"child\\":13}","stateChars":12,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T"}',
|
|
113
|
+
'{"runId":"r/nested","pipeline":"child","version":1,"scope":"s","status":"done","completed":["c1","c2"],"pending":[],"state":"{\\"child\\":13}","stateChars":12,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T"}',
|
|
114
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":["a","left","right","skipped","nested"],"pending":["last"],"state":"{\\"cursor\\":\\"half\\",\\"value\\":2,\\"child\\":13}","stateChars":38,"warnings":["right: right refused"],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
115
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"running","completed":["a","left","right","skipped","nested","last"],"pending":[],"state":"{\\"cursor\\":\\"half\\",\\"value\\":102,\\"child\\":13}","stateChars":40,"warnings":["right: right refused"],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
116
|
+
'{"runId":"r","pipeline":"parent","version":1,"scope":"s","status":"done","completed":["a","left","right","skipped","nested","last"],"pending":[],"state":"{\\"cursor\\":\\"half\\",\\"value\\":102,\\"child\\":13}","stateChars":40,"warnings":["right: right refused"],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","note":"halfway"}',
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
const GOLDEN_TRACE = [
|
|
120
|
+
'[pipe:parent:r] start · steps=6 inherited=0 attempt=0',
|
|
121
|
+
'[pipe:parent:r] step 1/6 a (run)',
|
|
122
|
+
'[pipe:parent:r] row saved · completed=0/6 chars=17',
|
|
123
|
+
'[pipe:parent:r] row saved · completed=1/6 chars=27',
|
|
124
|
+
'[pipe:parent:r] step 2/6 left (run)',
|
|
125
|
+
'[pipe:parent:r] step 3/6 right (run)',
|
|
126
|
+
'[pipe:parent:r] row saved · completed=3/6 chars=27',
|
|
127
|
+
'[pipe:parent:r] row saved · completed=3/6 chars=27',
|
|
128
|
+
'[pipe:parent:r] step 5/6 nested (run)',
|
|
129
|
+
'[pipe:parent:r] step 4/6 skipped (skip)',
|
|
130
|
+
'[pipe:parent:r] row saved · completed=4/6 chars=27',
|
|
131
|
+
'[pipe:parent:r] row saved · completed=5/6 chars=38',
|
|
132
|
+
'[pipe:parent:r] step 6/6 last (run)',
|
|
133
|
+
'[pipe:parent:r] row saved · completed=6/6 chars=40',
|
|
134
|
+
'[pipe:parent:r] row saved · completed=6/6 chars=40',
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
const GOLDEN_PROGRESS = [
|
|
138
|
+
'{"pipeline":"parent","runId":"r","step":"a","index":1,"total":6}',
|
|
139
|
+
'{"pipeline":"parent","runId":"r","step":"a","index":1,"total":6,"note":"halfway"}',
|
|
140
|
+
'{"pipeline":"parent","runId":"r","step":"left","index":2,"total":6}',
|
|
141
|
+
'{"pipeline":"parent","runId":"r","step":"right","index":3,"total":6}',
|
|
142
|
+
'{"pipeline":"parent","runId":"r","step":"nested","index":5,"total":6}',
|
|
143
|
+
'{"pipeline":"parent","runId":"r","step":"skipped","index":4,"total":6,"skipped":true}',
|
|
144
|
+
'{"pipeline":"parent","runId":"r","step":"last","index":6,"total":6}',
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
describe('agent — a pipeline without plugins is unchanged', () => {
|
|
148
|
+
for (const withPluginsKey of [false, true]) {
|
|
149
|
+
test(`rows, trace and progress match the pre-plugin runner (plugins key ${withPluginsKey ? 'empty' : 'absent'})`, async () => {
|
|
150
|
+
const { store, saved } = recording()
|
|
151
|
+
const { parent, trace, progress, contexts } = build(store, { withPluginsKey })
|
|
152
|
+
const log: string[] = []
|
|
153
|
+
|
|
154
|
+
const result = await parent.invoke({}, { runId: 'r', deps: { log }, scope: 's' })
|
|
155
|
+
|
|
156
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
157
|
+
expect(result.state).toEqual({ cursor: 'half', value: 102, child: 13 })
|
|
158
|
+
expect(result.warnings).toEqual(['right: right refused'])
|
|
159
|
+
expect(saved).toEqual(GOLDEN_SAVED)
|
|
160
|
+
expect(trace).toEqual(GOLDEN_TRACE)
|
|
161
|
+
expect(progress).toEqual(GOLDEN_PROGRESS)
|
|
162
|
+
// A step of a pipeline that never opted in has no results surface at all — not even the key.
|
|
163
|
+
for (const ctx of contexts) {
|
|
164
|
+
expect('results' in ctx).toBe(false)
|
|
165
|
+
expect(Object.keys(ctx).sort()).toEqual([
|
|
166
|
+
'ask', 'completed', 'deps', 'entityId', 'expired', 'mark', 'remainingMs', 'report', 'runId', 'scope',
|
|
167
|
+
'signal', 'spec', 'step',
|
|
168
|
+
])
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
test('a fatal error still writes the row Failed first and then escapes', async () => {
|
|
174
|
+
class OutOfTokens extends Error {}
|
|
175
|
+
const { store, saved } = recording()
|
|
176
|
+
const pipeline = makePipeline<State, Deps>(
|
|
177
|
+
{ alias: 'fatal', version: 1, steps: [{ step: 'a' }, { step: 'b', after: ['a'] }] },
|
|
178
|
+
{
|
|
179
|
+
steps: [
|
|
180
|
+
{ step: 'a', run: async () => ({ value: 1 }) },
|
|
181
|
+
{ step: 'b', run: async () => { throw new OutOfTokens('no balance') } },
|
|
182
|
+
],
|
|
183
|
+
runs: store,
|
|
184
|
+
fatal: e => e instanceof OutOfTokens,
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
await expect(pipeline.invoke({}, { runId: 'f', deps: { log: [] }, scope: 's' }))
|
|
189
|
+
.rejects.toThrow('no balance')
|
|
190
|
+
expect(saved).toEqual([
|
|
191
|
+
'{"runId":"f","pipeline":"fatal","version":1,"scope":"s","status":"running","completed":["a"],"pending":["b"],"state":"{\\"value\\":1}","stateChars":11,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T"}',
|
|
192
|
+
'{"runId":"f","pipeline":"fatal","version":1,"scope":"s","status":"failed","completed":["a"],"pending":["b"],"state":"{\\"value\\":1}","stateChars":11,"warnings":[],"attempts":0,"startedAt":"T","heartbeatAt":"T","updatedAt":"T","failedAt":"b","error":"no balance"}',
|
|
193
|
+
])
|
|
194
|
+
})
|
|
195
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { PipelineRunStatus } from '@owlmeans/agent-common'
|
|
3
|
+
import type { PipelineSpec } from '@owlmeans/agent-common'
|
|
4
|
+
import { createMemoryPipelineRunStore, makePipeline } from '../src/index.js'
|
|
5
|
+
import type {
|
|
6
|
+
PipelinePlugin, PipelineRunStore, PipelineStep, StepResults,
|
|
7
|
+
} from '../src/index.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The pipeline plugin seam: when each hook fires, what it is told, and the two containment rules —
|
|
11
|
+
* a plugin that throws never fails the run, and a plugin error the pipeline calls fatal takes the
|
|
12
|
+
* same road a fatal step error takes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface State extends Record<string, unknown> { value?: number }
|
|
16
|
+
interface Deps { log: string[] }
|
|
17
|
+
|
|
18
|
+
const spec: PipelineSpec = {
|
|
19
|
+
alias: 'seam',
|
|
20
|
+
version: 1,
|
|
21
|
+
steps: [
|
|
22
|
+
{ step: 'a' },
|
|
23
|
+
{ step: 'skipped', after: ['a'] },
|
|
24
|
+
{ step: 'soft', after: ['a'], optional: true },
|
|
25
|
+
{ step: 'z', after: ['skipped', 'soft'] },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const steps = (fail: string[] = []): PipelineStep<State, Deps>[] => [
|
|
30
|
+
{ step: 'a', run: async (_s, ctx) => { ctx.deps.log.push('run:a'); return { value: 1 } } },
|
|
31
|
+
{ step: 'skipped', skipWhen: () => true, run: async () => ({ value: 999 }) },
|
|
32
|
+
{ step: 'soft', run: async () => { throw new Error('soft refused') } },
|
|
33
|
+
{
|
|
34
|
+
step: 'z',
|
|
35
|
+
run: async (state, ctx) => {
|
|
36
|
+
ctx.deps.log.push('run:z')
|
|
37
|
+
if (fail.includes('z')) throw new Error('z refused')
|
|
38
|
+
return { value: (state.value ?? 0) + 1 }
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** Records every hook as one line, and checks the row at `afterStep` time. */
|
|
44
|
+
const recorder = (log: string[], runs: PipelineRunStore, patch: Partial<PipelinePlugin<State, Deps>> = {}) => ({
|
|
45
|
+
alias: 'recorder',
|
|
46
|
+
enter: event => { log.push(`enter:${event.mode}:${event.inherited.join(',')}`) },
|
|
47
|
+
beforeStep: ({ step }) => { log.push(`before:${step}`) },
|
|
48
|
+
afterStep: async ({ step, state, ctx }) => {
|
|
49
|
+
const row = await runs.load(ctx.runId)
|
|
50
|
+
// The patch is already merged, and the step is NOT yet durably complete.
|
|
51
|
+
log.push(`after:${step}:value=${state.value}:committed=${row?.completed.includes(step) === true}`)
|
|
52
|
+
},
|
|
53
|
+
passStep: ({ step, reason, error }) => { log.push(`pass:${step}:${reason}${error != null ? `:${error.message}` : ''}`) },
|
|
54
|
+
exit: ({ result }) => { log.push(`exit:${result.status}`) },
|
|
55
|
+
...patch,
|
|
56
|
+
}) as PipelinePlugin<State, Deps>
|
|
57
|
+
|
|
58
|
+
describe('agent — pipeline plugin hooks', () => {
|
|
59
|
+
test('fire in order: enter, before/after around each run, pass for a skip and an optional failure, exit', async () => {
|
|
60
|
+
const log: string[] = []
|
|
61
|
+
const runs = createMemoryPipelineRunStore()
|
|
62
|
+
const pipeline = makePipeline<State, Deps>(spec, { steps: steps(), runs, plugins: [recorder(log, runs)] })
|
|
63
|
+
|
|
64
|
+
const result = await pipeline.invoke({}, { runId: 'r', deps: { log }, scope: 's' })
|
|
65
|
+
|
|
66
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
67
|
+
// `skipped` and `soft` share a superstep; the guard's await lets `soft` start first.
|
|
68
|
+
expect(log).toEqual([
|
|
69
|
+
'enter:fresh:',
|
|
70
|
+
'before:a', 'run:a', 'after:a:value=1:committed=false',
|
|
71
|
+
'before:soft', 'pass:skipped:skipped', 'pass:soft:failed:soft refused',
|
|
72
|
+
'before:z', 'run:z', 'after:z:value=2:committed=false',
|
|
73
|
+
'exit:done',
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('enter says how the run came in, and which steps it inherits', async () => {
|
|
78
|
+
const log: string[] = []
|
|
79
|
+
const runs = createMemoryPipelineRunStore()
|
|
80
|
+
const build = (fail: string[]) =>
|
|
81
|
+
makePipeline<State, Deps>(spec, { steps: steps(fail), runs, plugins: [recorder(log, runs)] })
|
|
82
|
+
|
|
83
|
+
await build(['z']).invoke({}, { runId: 'r', deps: { log: [] }, scope: 's' })
|
|
84
|
+
await build([]).invoke({}, { runId: 'r', deps: { log: [] }, scope: 's' })
|
|
85
|
+
await build(['z']).invoke({}, { runId: 'r2', deps: { log: [] }, scope: 's' })
|
|
86
|
+
await build([]).resume('r2', { deps: { log: [] } })
|
|
87
|
+
await build([]).invoke({}, { runId: 'r2', deps: { log: [] }, scope: 's', restart: true })
|
|
88
|
+
|
|
89
|
+
expect(log.filter(line => line.startsWith('enter:'))).toEqual([
|
|
90
|
+
'enter:fresh:',
|
|
91
|
+
'enter:continue:a,skipped,soft',
|
|
92
|
+
'enter:fresh:',
|
|
93
|
+
'enter:continue:a,skipped,soft',
|
|
94
|
+
'enter:restart:',
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('are seated by alias and run by order', async () => {
|
|
99
|
+
const log: string[] = []
|
|
100
|
+
const named = (alias: string, order: number, tag: string): PipelinePlugin<State, Deps> =>
|
|
101
|
+
({ alias, order, enter: () => { log.push(tag) } })
|
|
102
|
+
const pipeline = makePipeline<State, Deps>(
|
|
103
|
+
{ alias: 'order', version: 1, steps: [{ step: 'a' }] },
|
|
104
|
+
{
|
|
105
|
+
steps: [{ step: 'a', run: async () => undefined }],
|
|
106
|
+
plugins: [named('late', 90, 'late'), named('early', 10, 'first-early'), named('early', 10, 'early')],
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
await pipeline.invoke({}, { runId: 'r', deps: { log: [] }, scope: 's' })
|
|
111
|
+
|
|
112
|
+
expect(log).toEqual(['early', 'late'])
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('the first plugin that offers results owns ctx.results', async () => {
|
|
116
|
+
const seen: Array<string | undefined> = []
|
|
117
|
+
const offer = (alias: string, ledger: string): PipelinePlugin<State, Deps> => ({
|
|
118
|
+
alias, beforeStep: () => ({ results: { ledger } as StepResults }),
|
|
119
|
+
})
|
|
120
|
+
const pipeline = makePipeline<State, Deps>(
|
|
121
|
+
{ alias: 'offer', version: 1, steps: [{ step: 'a' }] },
|
|
122
|
+
{
|
|
123
|
+
steps: [{ step: 'a', run: async (_s, ctx) => { seen.push(ctx.results?.ledger) } }],
|
|
124
|
+
plugins: [offer('one', 'first'), offer('two', 'second')],
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
await pipeline.invoke({}, { runId: 'r', deps: { log: [] }, scope: 's' })
|
|
129
|
+
|
|
130
|
+
expect(seen).toEqual(['first'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a composed run is told which step of which run it runs under', async () => {
|
|
134
|
+
const parents: unknown[] = []
|
|
135
|
+
const child = makePipeline<State, Deps>(
|
|
136
|
+
{ alias: 'child', version: 1, steps: [{ step: 'c' }] },
|
|
137
|
+
{
|
|
138
|
+
steps: [{ step: 'c', run: async () => ({ value: 5 }) }],
|
|
139
|
+
plugins: [{ alias: 'watch', enter: event => { parents.push(event.parent) } }],
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
const parent = makePipeline<State, Deps>(
|
|
143
|
+
{ alias: 'parent', version: 1, steps: [{ step: 'nested' }] },
|
|
144
|
+
{ steps: [child.asStep<State>('nested', { input: () => ({}), output: out => ({ value: out.value }) })] },
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
await parent.invoke({}, { runId: 'p', deps: { log: [] }, scope: 's' })
|
|
148
|
+
|
|
149
|
+
expect(parents).toEqual([{ pipeline: 'parent', runId: 'p', step: 'nested' }])
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('agent — pipeline plugin containment', () => {
|
|
154
|
+
test('a plugin that throws on every hook costs nothing but a warning', async () => {
|
|
155
|
+
const log: string[] = []
|
|
156
|
+
const boom = () => { throw new Error('plugin down') }
|
|
157
|
+
const pipeline = makePipeline<State, Deps>(spec, {
|
|
158
|
+
steps: steps(),
|
|
159
|
+
runs: createMemoryPipelineRunStore(),
|
|
160
|
+
plugins: [{ alias: 'broken', enter: boom, beforeStep: boom, afterStep: boom, passStep: boom, exit: boom }],
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const result = await pipeline.invoke({}, { runId: 'r', deps: { log }, scope: 's' })
|
|
164
|
+
|
|
165
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
166
|
+
expect(result.state.value).toBe(2)
|
|
167
|
+
expect(log).toEqual(['run:a', 'run:z'])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
class OutOfTokens extends Error {}
|
|
171
|
+
const fatal = (e: unknown): boolean => e instanceof OutOfTokens
|
|
172
|
+
|
|
173
|
+
for (const hook of ['enter', 'afterStep', 'passStep', 'exit'] as const) {
|
|
174
|
+
test(`a fatal error on ${hook} writes the row Failed first, then escapes`, async () => {
|
|
175
|
+
const runs = createMemoryPipelineRunStore()
|
|
176
|
+
const pipeline = makePipeline<State, Deps>(spec, {
|
|
177
|
+
steps: steps(),
|
|
178
|
+
runs,
|
|
179
|
+
fatal,
|
|
180
|
+
plugins: [{ alias: 'fatal', [hook]: () => { throw new OutOfTokens(`spent on ${hook}`) } }],
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
await expect(pipeline.invoke({}, { runId: 'r', deps: { log: [] }, scope: 's' }))
|
|
184
|
+
.rejects.toThrow(`spent on ${hook}`)
|
|
185
|
+
|
|
186
|
+
const row = await runs.load('r')
|
|
187
|
+
expect(row?.status).toBe(PipelineRunStatus.Failed)
|
|
188
|
+
expect(row?.error).toBe(`spent on ${hook}`)
|
|
189
|
+
// Both passing steps share a superstep and both hit the fatal plugin; either may be named.
|
|
190
|
+
const expected = { enter: ['a'], afterStep: ['a'], passStep: ['skipped', 'soft'], exit: ['z'] }[hook]
|
|
191
|
+
expect(expected).toContain(row?.failedAt ?? '')
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
})
|