@owlmeans/agent-common 0.1.18-rc.7 → 0.1.18

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 (65) hide show
  1. package/README.md +174 -13
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/agent-common/SKILL.md +69 -14
  4. package/build/consts.d.ts +118 -5
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +204 -4
  7. package/build/consts.js.map +1 -1
  8. package/build/consts.local.d.ts +9 -0
  9. package/build/consts.local.d.ts.map +1 -0
  10. package/build/consts.local.js +9 -0
  11. package/build/consts.local.js.map +1 -0
  12. package/build/errors.d.ts +60 -0
  13. package/build/errors.d.ts.map +1 -1
  14. package/build/errors.js +84 -0
  15. package/build/errors.js.map +1 -1
  16. package/build/helpers/conversation.d.ts +0 -9
  17. package/build/helpers/conversation.d.ts.map +1 -1
  18. package/build/helpers/conversation.js +0 -28
  19. package/build/helpers/conversation.js.map +1 -1
  20. package/build/helpers/truncate.d.ts +10 -0
  21. package/build/helpers/truncate.d.ts.map +1 -0
  22. package/build/helpers/truncate.js +29 -0
  23. package/build/helpers/truncate.js.map +1 -0
  24. package/build/index.d.ts +6 -1
  25. package/build/index.d.ts.map +1 -1
  26. package/build/index.js +4 -1
  27. package/build/index.js.map +1 -1
  28. package/build/models/pipeline-spec/types.d.ts +57 -0
  29. package/build/models/pipeline-spec/types.d.ts.map +1 -0
  30. package/build/models/pipeline-spec/types.js +2 -0
  31. package/build/models/pipeline-spec/types.js.map +1 -0
  32. package/build/models/pipeline-spec.d.ts +4 -0
  33. package/build/models/pipeline-spec.d.ts.map +1 -0
  34. package/build/models/pipeline-spec.js +171 -0
  35. package/build/models/pipeline-spec.js.map +1 -0
  36. package/build/pipeline.d.ts +10 -0
  37. package/build/pipeline.d.ts.map +1 -0
  38. package/build/pipeline.js +10 -0
  39. package/build/pipeline.js.map +1 -0
  40. package/build/results/types.d.ts +211 -0
  41. package/build/results/types.d.ts.map +1 -0
  42. package/build/results/types.js +2 -0
  43. package/build/results/types.js.map +1 -0
  44. package/build/results.d.ts +4 -0
  45. package/build/results.d.ts.map +1 -0
  46. package/build/results.js +218 -0
  47. package/build/results.js.map +1 -0
  48. package/build/types.d.ts +146 -24
  49. package/build/types.d.ts.map +1 -1
  50. package/package.json +8 -8
  51. package/src/consts.local.ts +15 -0
  52. package/src/consts.ts +218 -4
  53. package/src/errors.ts +96 -0
  54. package/src/helpers/conversation.ts +0 -32
  55. package/src/helpers/truncate.ts +31 -0
  56. package/src/index.ts +6 -1
  57. package/src/models/pipeline-spec/types.ts +58 -0
  58. package/src/models/pipeline-spec.ts +202 -0
  59. package/src/pipeline.ts +16 -0
  60. package/src/results/types.ts +221 -0
  61. package/src/results.ts +270 -0
  62. package/src/types.ts +153 -25
  63. package/tests/pipeline.spec.ts +164 -0
  64. package/tests/results.spec.ts +148 -0
  65. package/src/flows.ts +0 -118
package/src/consts.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ShallowFlow } from '@owlmeans/flow'
2
+
1
3
  /**
2
4
  * The agent service alias.
3
5
  *
@@ -16,9 +18,54 @@ export const AGENTS_SERVICE = 'agents'
16
18
  * the same way `ExecutionService.checkpoint` does with no plugin registered.
17
19
  */
18
20
  export const AGENT_CONVERSATION_STORE = 'agent-conversation-store'
19
- export const AGENT_RUN_STATE_STORE = 'agent-run-state-store'
20
21
  export const AGENT_MEMORY_GRAPH_STORE = 'agent-memory-graph-store'
21
22
  export const AGENT_MEMORY_EVENTS_STORE = 'agent-memory-events-store'
23
+ export const AGENT_PIPELINE_RUN_STORE = 'agent-pipeline-run-store'
24
+ export const AGENT_CHECKPOINT_STORE = 'agent-checkpoint-store'
25
+ export const AGENT_CUMULATIVE_RESULT_STORE = 'agent-cumulative-result-store'
26
+
27
+ /**
28
+ * Caps and defaults for cumulative pipeline results.
29
+ *
30
+ * Every one of them bounds what a later step is TOLD, never what a step may produce: a fact that
31
+ * does not fit is left out of one view, and it is still on its entry for the next view and for any
32
+ * code that queries it. `DEFAULT_RESULTS_WINDOW` is a distance in the step graph — a consumer that
33
+ * many edges or fewer after a producer gets the producer's full entry; anything further back gets
34
+ * its names only, unless the producer names the consumer outright.
35
+ */
36
+ export const DEFAULT_RESULTS_MAX_CHARS = 12_000
37
+ export const DEFAULT_RESULT_ENTRY_CHARS = 4_000
38
+ export const DEFAULT_RESULT_COMPACT_CHARS = 400
39
+ export const DEFAULT_RESULTS_WINDOW = 2
40
+ export const DEFAULT_RESULT_SUMMARY_CHARS = 600
41
+
42
+ /** The consumer wildcard: every later step of the pipeline — and of anything it is composed into. */
43
+ export const RESULTS_EVERY_STEP = '*'
44
+
45
+ /**
46
+ * Caps and defaults for a pipeline run.
47
+ *
48
+ * `DEFAULT_MAX_STATE_CHARS` is generous by the standard of what a state may legally hold — scalars
49
+ * and keys — and deliberately so: it is a TRIPWIRE, not a budget. A state approaching it is a state
50
+ * carrying an artifact, and the step fails so that fact is discovered on the first run rather than
51
+ * surviving as a row nobody can read back.
52
+ *
53
+ * `DEFAULT_STEP_ATTEMPTS` is 1 because retry budgets in this family multiply: an outer retry around
54
+ * a model's own inner retry is their product, not their sum.
55
+ */
56
+ export const DEFAULT_MAX_STATE_CHARS = 256_000
57
+ export const DEFAULT_STEP_ATTEMPTS = 1
58
+ export const DEFAULT_STEP_TIMEOUT = 1_800_000
59
+
60
+ /**
61
+ * Where a pipeline's answers live in its state.
62
+ *
63
+ * One key, named here rather than per pipeline, because an answer has to survive every hop it can
64
+ * take: a resume merges answers into it, a composing step forwards it to its child, and a reader
65
+ * looks a question up by id. Scalars and keys, like everything else a state holds — the answer's
66
+ * decision travels here and its prose belongs wherever that pipeline keeps its documents.
67
+ */
68
+ export const INQUIRY_ANSWERS_KEY = 'answers'
22
69
 
23
70
  /** The lifecycle flow every agent run is driven through. */
24
71
  export const AGENT_RUN_FLOW = 'agent-run'
@@ -29,8 +76,12 @@ export const AGENT_RUN_FLOW = 'agent-run'
29
76
  * These are recoverable LIFECYCLE stages, not conversational turns. A ReAct loop's turn count is
30
77
  * unbounded and its messages are not scalars, while `FlowPayload` holds flat scalars only — so the
31
78
  * loop lives inside `Working` and only its counter travels in the payload. What the steps buy is
32
- * the ability to say where a crashed run has to resume: at `Working` from the last checkpoint, or
33
- * at `Finalizing` when the loop finished but the compaction never committed.
79
+ * the ability to say how far a run got when it ended, which is what a plugin reads on `onFinish`.
80
+ *
81
+ * Nothing RESUMES one of these. A ReAct run is not a resumable unit: its state is an unbounded
82
+ * message list whose tool results are side effects already applied to the world, so re-entering it
83
+ * would re-apply them. Resumable work is a PIPELINE — see `pipeline.ts` — whose steps are named,
84
+ * whose state is scalars, and whose side effects are guarded per step.
34
85
  */
35
86
  export enum AgentRunStep {
36
87
  Received = 'received',
@@ -48,7 +99,6 @@ export enum AgentRunTransition {
48
99
  Finalize = 'finalize',
49
100
  Finish = 'finish',
50
101
  Fail = 'fail',
51
- Resume = 'resume',
52
102
  }
53
103
 
54
104
  /** How a run ended. Written on the conversation event so a reader can weigh the advice. */
@@ -71,3 +121,167 @@ export const DEFAULT_MEMORY_EVENTS_LIMIT = 50
71
121
 
72
122
  /** Separator between a dedication's kind and its target — `project:<id>`. */
73
123
  export const SCOPE_SEP = ':'
124
+
125
+ /** How a run ended, or that it has not. */
126
+ export enum PipelineRunStatus {
127
+ Running = 'running',
128
+ Done = 'done',
129
+ Failed = 'failed',
130
+ /** Stopped on purpose with work left — a budget expired, or the caller asked. Resumable. */
131
+ Aborted = 'aborted',
132
+ /**
133
+ * Stopped on purpose, waiting for an answer to {@link PipelineRun.inquiry}.
134
+ *
135
+ * Resumable, and NOT stale: a waiting run has no process behind it and its `heartbeatAt` will not
136
+ * move again until somebody answers. A reconciler that reads staleness alone would take this for
137
+ * a crashed run and repair what is merely waiting.
138
+ */
139
+ Waiting = 'waiting',
140
+ }
141
+
142
+ /** Where the facts of an entry came from. */
143
+ export enum CumulativeResultSource {
144
+ /** Read by code from what the step left behind, when the step finished. */
145
+ Extracted = 'extracted',
146
+ /**
147
+ * Read by code from the step's DURABLE inputs, after the fact — on a resume that found no stored
148
+ * entry, or for a step whose guard skipped it. The same facts an extraction would have found.
149
+ */
150
+ Rebuilt = 'rebuilt',
151
+ /** Supplied before any step of the run ran — the caller already knew them. */
152
+ Seeded = 'seeded',
153
+ /** No facts at all; only a model's summary, which nothing has checked. */
154
+ Summarized = 'summarized',
155
+ }
156
+
157
+ /** How one entry appears in a view cut for a given step. */
158
+ export enum ResultViewMode {
159
+ Full = 'full',
160
+ /** Names only, grouped by kind. */
161
+ Compact = 'compact',
162
+ /** Not in the prompt. Still answered by a programmatic facts query. */
163
+ Omitted = 'omitted',
164
+ }
165
+
166
+ /**
167
+ * Kinds an extractor is encouraged to use. `CumulativeResultFact.kind` is an open string — declare
168
+ * your own — but two extractors naming the same thing differently split one fact into two.
169
+ */
170
+ export enum CumulativeFactKind {
171
+ Type = 'type',
172
+ Symbol = 'symbol',
173
+ Endpoint = 'endpoint',
174
+ Resource = 'resource',
175
+ File = 'file',
176
+ }
177
+
178
+ /**
179
+ * The lifecycle of one agent run.
180
+ *
181
+ * Read it as "how far did this run get", not "what did it say". Everything conversational happens
182
+ * inside `Working`; the steps exist so a plugin reading `onFinish` can say where a run ended.
183
+ * `Fail` is reachable from every working step and is TERMINAL — see the note on that step.
184
+ *
185
+ * `service` is left as the flow name on every step. A flow step normally binds to a service or an
186
+ * entrypoint, but an agent run is driven by whoever holds the model — there is no second party to
187
+ * hand control to, and inventing one would put a name in the serialized state that nothing
188
+ * resolves.
189
+ *
190
+ * Each working step keeps exactly ONE non-explicit outgoing transition, so `FlowModel.next()`
191
+ * always has an unambiguous answer: that is what lets a driver advance the run without knowing the
192
+ * vocabulary. `Fail` is marked explicit precisely so it never becomes that automatic answer.
193
+ */
194
+ export const agentRunFlow: ShallowFlow = {
195
+ flow: AGENT_RUN_FLOW,
196
+ initialStep: AgentRunStep.Received,
197
+
198
+ steps: {
199
+ [AgentRunStep.Received]: {
200
+ index: 0,
201
+ step: AgentRunStep.Received,
202
+ service: AGENT_RUN_FLOW,
203
+ initial: true,
204
+ transitions: {
205
+ [AgentRunTransition.Prepare]: {
206
+ transition: AgentRunTransition.Prepare,
207
+ step: AgentRunStep.Prepared,
208
+ },
209
+ [AgentRunTransition.Fail]: {
210
+ transition: AgentRunTransition.Fail,
211
+ step: AgentRunStep.Failed,
212
+ explicit: true,
213
+ },
214
+ },
215
+ },
216
+
217
+ [AgentRunStep.Prepared]: {
218
+ index: 1,
219
+ step: AgentRunStep.Prepared,
220
+ service: AGENT_RUN_FLOW,
221
+ transitions: {
222
+ [AgentRunTransition.Work]: {
223
+ transition: AgentRunTransition.Work,
224
+ step: AgentRunStep.Working,
225
+ },
226
+ [AgentRunTransition.Fail]: {
227
+ transition: AgentRunTransition.Fail,
228
+ step: AgentRunStep.Failed,
229
+ explicit: true,
230
+ },
231
+ },
232
+ },
233
+
234
+ [AgentRunStep.Working]: {
235
+ index: 2,
236
+ step: AgentRunStep.Working,
237
+ service: AGENT_RUN_FLOW,
238
+ transitions: {
239
+ [AgentRunTransition.Finalize]: {
240
+ transition: AgentRunTransition.Finalize,
241
+ step: AgentRunStep.Finalizing,
242
+ },
243
+ [AgentRunTransition.Fail]: {
244
+ transition: AgentRunTransition.Fail,
245
+ step: AgentRunStep.Failed,
246
+ explicit: true,
247
+ },
248
+ },
249
+ },
250
+
251
+ [AgentRunStep.Finalizing]: {
252
+ index: 3,
253
+ step: AgentRunStep.Finalizing,
254
+ service: AGENT_RUN_FLOW,
255
+ transitions: {
256
+ [AgentRunTransition.Finish]: {
257
+ transition: AgentRunTransition.Finish,
258
+ step: AgentRunStep.Finished,
259
+ },
260
+ [AgentRunTransition.Fail]: {
261
+ transition: AgentRunTransition.Fail,
262
+ step: AgentRunStep.Failed,
263
+ explicit: true,
264
+ },
265
+ },
266
+ },
267
+
268
+ [AgentRunStep.Finished]: {
269
+ index: 4,
270
+ step: AgentRunStep.Finished,
271
+ service: AGENT_RUN_FLOW,
272
+ transitions: {},
273
+ },
274
+
275
+ [AgentRunStep.Failed]: {
276
+ index: 5,
277
+ step: AgentRunStep.Failed,
278
+ service: AGENT_RUN_FLOW,
279
+ // Terminal. Nothing resumes a ReAct run — its tool results are side effects already applied
280
+ // to the world, so re-entering it re-applies them. Resumable work is a pipeline.
281
+ transitions: {},
282
+ },
283
+ },
284
+ }
285
+
286
+ /** Every flow this package declares, for a provider to serve. */
287
+ export const agentFlows: ShallowFlow[] = [agentRunFlow]
package/src/errors.ts CHANGED
@@ -18,5 +18,101 @@ export class AgentRunStateError extends AgentCommonError {
18
18
  }
19
19
  }
20
20
 
21
+ /**
22
+ * A pipeline declaration is not runnable.
23
+ *
24
+ * Thrown by {@link import('./models/pipeline-spec/types.js').PipelineSpecModel.validate} and by the runner's own build
25
+ * step, and it always names EVERY fault it found rather than the first: a spec is authored once and
26
+ * fixed once, and reporting faults one per build turns a five-minute edit into five builds.
27
+ */
28
+ export class PipelineSpecError extends AgentCommonError {
29
+ public static override typeName: string = `PipelineSpec${AgentCommonError.typeName}`
30
+
31
+ constructor(message: string = 'error') {
32
+ super(`pipeline-spec:${message}`)
33
+ this.type = PipelineSpecError.typeName
34
+ }
35
+ }
36
+
37
+ /**
38
+ * A resume was asked of a run started under a different version of its pipeline.
39
+ *
40
+ * Refused rather than adapted. `completed` names steps by string, so a spec that has since renamed,
41
+ * removed or reordered a step would have a resumed run skip work it never did — silently, because
42
+ * an unknown name in `completed` simply matches nothing.
43
+ */
44
+ export class PipelineVersionError extends AgentCommonError {
45
+ public static override typeName: string = `PipelineVersion${AgentCommonError.typeName}`
46
+
47
+ constructor(message: string = 'error') {
48
+ super(`pipeline-version:${message}`)
49
+ this.type = PipelineVersionError.typeName
50
+ }
51
+ }
52
+
53
+ /** A resume, a progress report or a skip named a step the spec does not declare. */
54
+ export class PipelineUnknownStepError extends AgentCommonError {
55
+ public static override typeName: string = `PipelineUnknownStep${AgentCommonError.typeName}`
56
+
57
+ constructor(message: string = 'error') {
58
+ super(`pipeline-step:${message}`)
59
+ this.type = PipelineUnknownStepError.typeName
60
+ }
61
+ }
62
+
63
+ /**
64
+ * A resume was asked to re-enter a completed step whose side effect cannot be repeated.
65
+ *
66
+ * The caller may override with `force`, which is why this is an error and not a silent skip: an
67
+ * operator re-running a wipe, a purge or a hostname claim has to say so.
68
+ */
69
+ export class PipelineNotIdempotentError extends AgentCommonError {
70
+ public static override typeName: string = `PipelineNotIdempotent${AgentCommonError.typeName}`
71
+
72
+ constructor(message: string = 'error') {
73
+ super(`pipeline-idempotency:${message}`)
74
+ this.type = PipelineNotIdempotentError.typeName
75
+ }
76
+ }
77
+
78
+ /**
79
+ * A step produced a state larger than the run row may carry.
80
+ *
81
+ * The step FAILS — the state is never truncated and the write is never skipped. A pipeline state is
82
+ * scalars and keys by contract; anything that overflows it is a large artifact that belongs in a
83
+ * store of its own, and silently dropping it would make a resume replay from a state that never
84
+ * existed.
85
+ */
86
+ export class PipelineStateTooLargeError extends AgentCommonError {
87
+ public static override typeName: string = `PipelineStateTooLarge${AgentCommonError.typeName}`
88
+
89
+ constructor(message: string = 'error') {
90
+ super(`pipeline-state:${message}`)
91
+ this.type = PipelineStateTooLargeError.typeName
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A run was asked to wait for an answer it has no way of being given.
97
+ *
98
+ * Thrown when a step asks a question, nobody is there to answer it, and the pipeline has no run
99
+ * store: parking would leave nothing behind to resume, so the step fails at once rather than
100
+ * stopping a run that could never be started again.
101
+ */
102
+ export class PipelineNotResumableError extends AgentCommonError {
103
+ public static override typeName: string = `PipelineNotResumable${AgentCommonError.typeName}`
104
+
105
+ constructor(message: string = 'error') {
106
+ super(`pipeline-not-resumable:${message}`)
107
+ this.type = PipelineNotResumableError.typeName
108
+ }
109
+ }
110
+
21
111
  ResilientError.registerErrorClass(AgentCommonError)
22
112
  ResilientError.registerErrorClass(AgentRunStateError)
113
+ ResilientError.registerErrorClass(PipelineSpecError)
114
+ ResilientError.registerErrorClass(PipelineVersionError)
115
+ ResilientError.registerErrorClass(PipelineUnknownStepError)
116
+ ResilientError.registerErrorClass(PipelineNotIdempotentError)
117
+ ResilientError.registerErrorClass(PipelineStateTooLargeError)
118
+ ResilientError.registerErrorClass(PipelineNotResumableError)
@@ -28,35 +28,3 @@ export const conversationFor = (
28
28
  scope: override?.scope ?? (target !== '' ? target : 'anonymous'),
29
29
  }
30
30
  }
31
-
32
- /**
33
- * Cut `text` to `max` characters on a boundary a reader will not trip over.
34
- *
35
- * Every cap in this package lands here, because a model asked for "at most N characters" answers
36
- * with N plus whatever it felt was needed. Truncating mid-word reads as corruption and truncating
37
- * mid-sentence reads as a bug report, so the cut prefers the last paragraph, then line, then
38
- * sentence, then word break inside the last quarter of the budget, and always marks itself.
39
- */
40
- export const truncateAt = (text: string, max: number): string => {
41
- const trimmed = text.trim()
42
- if (trimmed.length <= max) {
43
- return trimmed
44
- }
45
- if (max <= 1) {
46
- return trimmed.slice(0, Math.max(0, max))
47
- }
48
-
49
- const ellipsis = '…'
50
- const budget = max - ellipsis.length
51
- const head = trimmed.slice(0, budget)
52
- const floor = Math.floor(budget * 0.75)
53
-
54
- for (const boundary of ['\n\n', '\n', '. ', ' ']) {
55
- const at = head.lastIndexOf(boundary)
56
- if (at >= floor) {
57
- return head.slice(0, boundary === '. ' ? at + 1 : at).trimEnd() + ellipsis
58
- }
59
- }
60
-
61
- return head.trimEnd() + ellipsis
62
- }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Cut `text` to `max` characters on a boundary a reader will not trip over.
3
+ *
4
+ * Every cap in this package lands here, because a model asked for "at most N characters" answers
5
+ * with N plus whatever it felt was needed. Truncating mid-word reads as corruption and truncating
6
+ * mid-sentence reads as a bug report, so the cut prefers the last paragraph, then line, then
7
+ * sentence, then word break inside the last quarter of the budget, and always marks itself.
8
+ */
9
+ export const truncateAt = (text: string, max: number): string => {
10
+ const trimmed = text.trim()
11
+ if (trimmed.length <= max) {
12
+ return trimmed
13
+ }
14
+ if (max <= 1) {
15
+ return trimmed.slice(0, Math.max(0, max))
16
+ }
17
+
18
+ const ellipsis = '…'
19
+ const budget = max - ellipsis.length
20
+ const head = trimmed.slice(0, budget)
21
+ const floor = Math.floor(budget * 0.75)
22
+
23
+ for (const boundary of ['\n\n', '\n', '. ', ' ']) {
24
+ const at = head.lastIndexOf(boundary)
25
+ if (at >= floor) {
26
+ return head.slice(0, boundary === '. ' ? at + 1 : at).trimEnd() + ellipsis
27
+ }
28
+ }
29
+
30
+ return head.trimEnd() + ellipsis
31
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export * from './consts.js'
2
2
  export * from './errors.js'
3
3
  export type * from './types.js'
4
- export * from './flows.js'
4
+ export type * from './results/types.js'
5
+ export type * from './models/pipeline-spec/types.js'
6
+ export * from './models/pipeline-spec.js'
7
+ export * from './pipeline.js'
8
+ export * from './results.js'
5
9
  export * from './helpers/conversation.js'
10
+ export * from './helpers/truncate.js'
@@ -0,0 +1,58 @@
1
+ import type { PipelineSpec, PipelineStepSpec } from '../../types.js'
2
+
3
+ /** One step a step transitively waits on, with the fewest `after` edges between the two. */
4
+ export interface PipelineStepDepth {
5
+ step: string
6
+ depth: number
7
+ }
8
+
9
+ /** The rules of one pipeline declaration: its validity, its order and the graph of its steps. */
10
+ export interface PipelineSpecModel {
11
+ readonly record: PipelineSpec
12
+ /**
13
+ * Every fault in one message, or nothing.
14
+ *
15
+ * Total and IO-free, so a spec can be checked by a test in microseconds and by the runner at build
16
+ * time. It reports EVERY fault it finds rather than the first, because a spec is authored once and
17
+ * a build that names one fault per run turns a five-minute edit into five builds.
18
+ *
19
+ * @throws {PipelineSpecError}
20
+ */
21
+ validate: () => void
22
+ /**
23
+ * The steps in an order that respects every `after`, with declaration order as the tie-break.
24
+ *
25
+ * Deterministic on purpose: the order decides what a progress report calls "step 3 of 16" and what
26
+ * a resume considers already behind it, and an order that depends on a `Set`'s iteration would make
27
+ * both drift between processes.
28
+ *
29
+ * @throws {PipelineSpecError} on a cycle or an unknown `after`.
30
+ */
31
+ orderSteps: () => string[]
32
+ /**
33
+ * `step` and everything that transitively waits on it.
34
+ *
35
+ * What a resume has to re-run when an operator names a step: re-entering a step without re-entering
36
+ * what read its output leaves the run describing a state that no longer follows from itself.
37
+ *
38
+ * @throws {PipelineUnknownStepError} when the spec does not declare `step`.
39
+ */
40
+ descendants: (step: string) => string[]
41
+ /**
42
+ * Every step `step` transitively waits on, with its DEPTH — the fewest `after` edges between the
43
+ * two — in topological order.
44
+ *
45
+ * The mirror of {@link PipelineSpecModel.descendants}, with one deliberate asymmetry: the named step
46
+ * itself is NOT included. A re-run re-enters the step it names, so the descendants include it;
47
+ * nothing is its own predecessor, so the ancestors do not. The depth is what a consumer uses to
48
+ * decide how much of a predecessor's output it needs to be told — a direct predecessor is depth 1
49
+ * however far apart the two sit in the topological order.
50
+ *
51
+ * @throws {PipelineUnknownStepError} when the spec does not declare `step`.
52
+ */
53
+ ancestry: (step: string) => PipelineStepDepth[]
54
+ /** The names from {@link PipelineSpecModel.ancestry}: what must finish before `step` may start, in order. */
55
+ ancestors: (step: string) => string[]
56
+ /** The declaration of one step, by name. `null` when the spec does not declare it. */
57
+ stepOf: (step: string) => PipelineStepSpec | null
58
+ }
@@ -0,0 +1,202 @@
1
+ import { DEFAULT_STEP_ATTEMPTS } from '../consts.js'
2
+ import { PipelineSpecError, PipelineUnknownStepError } from '../errors.js'
3
+ import type { PipelineSpec, PipelineStepSpec } from '../types.js'
4
+ import type { PipelineSpecModel, PipelineStepDepth } from './pipeline-spec/types.js'
5
+
6
+ export const makePipelineSpecModel = (spec: PipelineSpec): PipelineSpecModel => {
7
+ const fault = (faults: string[], message: string): void => {
8
+ faults.push(message)
9
+ }
10
+
11
+ const validate = (): void => {
12
+ const faults: string[] = []
13
+
14
+ if (spec.alias == null || spec.alias.trim() === '') {
15
+ fault(faults, 'alias is empty')
16
+ }
17
+ if (!Number.isInteger(spec.version) || spec.version < 1) {
18
+ fault(faults, `version must be a positive integer, got ${String(spec.version)}`)
19
+ }
20
+ if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
21
+ fault(faults, 'no steps declared')
22
+ throw new PipelineSpecError(`${spec.alias ?? '?'}: ${faults.join('; ')}`)
23
+ }
24
+
25
+ const seen = new Set<string>()
26
+ for (const step of spec.steps) {
27
+ if (step.step == null || step.step.trim() === '') {
28
+ fault(faults, 'a step has an empty name')
29
+ continue
30
+ }
31
+ if (seen.has(step.step)) {
32
+ fault(faults, `duplicate step "${step.step}"`)
33
+ }
34
+ seen.add(step.step)
35
+ }
36
+
37
+ for (const step of spec.steps) {
38
+ for (const dependency of step.after ?? []) {
39
+ if (dependency === step.step) {
40
+ fault(faults, `step "${step.step}" declares itself in \`after\``)
41
+ } else if (!seen.has(dependency)) {
42
+ fault(faults, `step "${step.step}" waits on unknown step "${dependency}"`)
43
+ }
44
+ }
45
+ const attempts = step.attempts ?? DEFAULT_STEP_ATTEMPTS
46
+ if (!Number.isInteger(attempts) || attempts < 1) {
47
+ fault(faults, `step "${step.step}" declares attempts ${String(step.attempts)}`)
48
+ }
49
+ if (step.nonIdempotent === true && attempts > 1) {
50
+ fault(
51
+ faults,
52
+ `step "${step.step}" is nonIdempotent and declares attempts ${attempts} — an automatic `
53
+ + 'retry is exactly what that flag says must not happen',
54
+ )
55
+ }
56
+ if (step.timeout != null && (!Number.isFinite(step.timeout) || step.timeout <= 0)) {
57
+ fault(faults, `step "${step.step}" declares timeout ${String(step.timeout)}`)
58
+ }
59
+ }
60
+
61
+ // A cycle is reported here rather than left to the sort, so one message carries every fault.
62
+ if (faults.every(entry => !entry.includes('unknown step'))) {
63
+ const cycle = detectCycle()
64
+ if (cycle != null) {
65
+ fault(faults, `cycle: ${cycle.join(' → ')}`)
66
+ }
67
+ }
68
+
69
+ if (faults.length > 0) {
70
+ throw new PipelineSpecError(`${spec.alias}: ${faults.join('; ')}`)
71
+ }
72
+ }
73
+
74
+ const detectCycle = (): string[] | null => {
75
+ const after = new Map(spec.steps.map(step => [step.step, step.after ?? []]))
76
+ const state = new Map<string, 0 | 1 | 2>()
77
+ const path: string[] = []
78
+
79
+ const walk = (name: string): string[] | null => {
80
+ const mark = state.get(name)
81
+ if (mark === 2) return null
82
+ if (mark === 1) return [...path.slice(path.indexOf(name)), name]
83
+
84
+ state.set(name, 1)
85
+ path.push(name)
86
+ for (const dependency of after.get(name) ?? []) {
87
+ const found = walk(dependency)
88
+ if (found != null) return found
89
+ }
90
+ path.pop()
91
+ state.set(name, 2)
92
+
93
+ return null
94
+ }
95
+
96
+ for (const step of spec.steps) {
97
+ const found = walk(step.step)
98
+ if (found != null) return found.reverse()
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ const orderSteps = (): string[] => {
105
+ const names = spec.steps.map(step => step.step)
106
+ const known = new Set(names)
107
+ const pending = new Map<string, Set<string>>()
108
+ const dependents = new Map<string, string[]>()
109
+
110
+ for (const step of spec.steps) {
111
+ const dependencies = new Set<string>()
112
+ for (const dependency of step.after ?? []) {
113
+ if (!known.has(dependency)) {
114
+ throw new PipelineSpecError(
115
+ `${spec.alias}: step "${step.step}" waits on unknown step "${dependency}"`,
116
+ )
117
+ }
118
+ dependencies.add(dependency)
119
+ dependents.set(dependency, [...(dependents.get(dependency) ?? []), step.step])
120
+ }
121
+ pending.set(step.step, dependencies)
122
+ }
123
+
124
+ const ordered: string[] = []
125
+ const emitted = new Set<string>()
126
+
127
+ while (ordered.length < names.length) {
128
+ // Declaration order among everything currently ready — never a set's own order.
129
+ const ready = names.filter(name => !emitted.has(name) && (pending.get(name)?.size ?? 0) === 0)
130
+ if (ready.length === 0) {
131
+ const cycle = detectCycle()
132
+ throw new PipelineSpecError(
133
+ `${spec.alias}: cycle: ${(cycle ?? names.filter(name => !emitted.has(name))).join(' → ')}`,
134
+ )
135
+ }
136
+ for (const name of ready) {
137
+ ordered.push(name)
138
+ emitted.add(name)
139
+ for (const dependent of dependents.get(name) ?? []) {
140
+ pending.get(dependent)?.delete(name)
141
+ }
142
+ }
143
+ }
144
+
145
+ return ordered
146
+ }
147
+
148
+ const descendants = (step: string): string[] => {
149
+ if (!spec.steps.some(entry => entry.step === step)) {
150
+ throw new PipelineUnknownStepError(`${spec.alias}:${step}`)
151
+ }
152
+
153
+ const reached = new Set<string>([step])
154
+ let grew = true
155
+ while (grew) {
156
+ grew = false
157
+ for (const entry of spec.steps) {
158
+ if (reached.has(entry.step)) continue
159
+ if ((entry.after ?? []).some(dependency => reached.has(dependency))) {
160
+ reached.add(entry.step)
161
+ grew = true
162
+ }
163
+ }
164
+ }
165
+
166
+ return orderSteps().filter(name => reached.has(name))
167
+ }
168
+
169
+ const ancestry = (step: string): PipelineStepDepth[] => {
170
+ if (!spec.steps.some(entry => entry.step === step)) {
171
+ throw new PipelineUnknownStepError(`${spec.alias}:${step}`)
172
+ }
173
+
174
+ const after = new Map(spec.steps.map(entry => [entry.step, entry.after ?? []]))
175
+ const depths = new Map<string, number>()
176
+ let frontier = [step]
177
+ for (let depth = 1; frontier.length > 0; ++depth) {
178
+ const next: string[] = []
179
+ for (const name of frontier) {
180
+ for (const dependency of after.get(name) ?? []) {
181
+ if (dependency !== step && !depths.has(dependency)) {
182
+ depths.set(dependency, depth)
183
+ next.push(dependency)
184
+ }
185
+ }
186
+ }
187
+ frontier = next
188
+ }
189
+
190
+ return orderSteps()
191
+ .filter(name => depths.has(name))
192
+ .map(name => ({ step: name, depth: depths.get(name)! }))
193
+ }
194
+
195
+ const ancestors = (step: string): string[] =>
196
+ ancestry(step).map(entry => entry.step)
197
+
198
+ const stepOf = (step: string): PipelineStepSpec | null =>
199
+ spec.steps.find(entry => entry.step === step) ?? null
200
+
201
+ return { record: spec, validate, orderSteps, descendants, ancestry, ancestors, stepOf }
202
+ }