@boxxy/rules 0.1.0 → 0.1.4
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 +8 -7
- package/dist/dry-run/dry-run-rules.d.ts +9 -0
- package/dist/index.d.ts +4 -1
- package/dist/loading/collect-filter-rejections.d.ts +3 -0
- package/dist/loading/collect-recipe-rejections.d.ts +3 -0
- package/dist/loading/collect-step-rejections.d.ts +3 -0
- package/dist/{load-rules.d.ts → loading/load-rules.d.ts} +2 -1
- package/dist/loading/to-filter.d.ts +3 -0
- package/dist/parse/rules-file-schema.d.ts +84 -0
- package/dist/{build-registry.d.ts → registry/build-registry.d.ts} +1 -1
- package/dist/{resolve-plugin.d.ts → registry/resolve-plugin.d.ts} +1 -1
- package/dist/{resolve-signal-ref.d.ts → registry/resolve-signal-ref.d.ts} +1 -1
- package/dist/test-utils/build-example-load-input.d.ts +11 -0
- package/dist/test-utils/load-mock-rules.d.ts +7 -0
- package/dist/types.d.ts +12 -1
- package/example/prompts/build-v1.md +4 -0
- package/example/prompts/design-attended-v1.md +4 -0
- package/example/prompts/design-v1.md +4 -0
- package/example/prompts/review-v1.md +4 -0
- package/example/prompts/triage-v1.md +5 -0
- package/example/rules.yaml +110 -0
- package/package.json +7 -4
- package/src/dry-run/dry-run-rules.ts +53 -0
- package/src/index.ts +4 -1
- package/src/{build-rejection.ts → loading/build-rejection.ts} +1 -1
- package/src/loading/collect-filter-rejections.ts +44 -0
- package/src/loading/collect-recipe-rejections.ts +60 -0
- package/src/loading/collect-step-rejections.ts +131 -0
- package/src/{load-rules.ts → loading/load-rules.ts} +112 -74
- package/src/loading/to-filter.ts +12 -0
- package/src/parse/rules-file-schema.ts +177 -0
- package/src/{build-registry.ts → registry/build-registry.ts} +2 -2
- package/src/{resolve-plugin.ts → registry/resolve-plugin.ts} +2 -2
- package/src/{resolve-signal-ref.ts → registry/resolve-signal-ref.ts} +2 -2
- package/src/test-utils/build-example-load-input.ts +90 -0
- package/src/test-utils/load-mock-rules.ts +84 -0
- package/src/types.ts +30 -1
- package/dist/rules-file-schema.d.ts +0 -49
- package/src/rules-file-schema.ts +0 -112
- /package/dist/{build-rejection.d.ts → loading/build-rejection.d.ts} +0 -0
- /package/dist/{parse-concept-entry.d.ts → parse/parse-concept-entry.d.ts} +0 -0
- /package/dist/{parse-rules-file.d.ts → parse/parse-rules-file.d.ts} +0 -0
- /package/src/{parse-concept-entry.ts → parse/parse-concept-entry.ts} +0 -0
- /package/src/{parse-rules-file.ts → parse/parse-rules-file.ts} +0 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { LoadRejection, PhaseKind, PhaseName } from '@boxxy/model';
|
|
2
|
+
import { engineHoldReasons, engineOutcomes } from '@boxxy/model';
|
|
3
|
+
import { P, match } from 'ts-pattern';
|
|
4
|
+
import type { ParsedRulesFile, ParsedStep } from '../parse/rules-file-schema.ts';
|
|
5
|
+
import { buildRejection } from './build-rejection.ts';
|
|
6
|
+
|
|
7
|
+
export function collectStepRejections(
|
|
8
|
+
file: ParsedRulesFile,
|
|
9
|
+
phases: ReadonlyMap<PhaseName, PhaseKind>,
|
|
10
|
+
): LoadRejection[] {
|
|
11
|
+
return Object.entries(file.pipelines).flatMap(([pipeline, steps]) =>
|
|
12
|
+
steps.flatMap((step, index) => {
|
|
13
|
+
const path = ['pipelines', pipeline, index];
|
|
14
|
+
const phase = phases.get(step.phase);
|
|
15
|
+
|
|
16
|
+
return [
|
|
17
|
+
...(phase === undefined
|
|
18
|
+
? [buildRejection([...path, 'phase'], { kind: 'unknown-phase', phase: step.phase })]
|
|
19
|
+
: collectOutcomeRejections(step, phase, path)),
|
|
20
|
+
...(step.timeoutMinutes === undefined
|
|
21
|
+
? [buildRejection(path, { kind: 'step-without-timeout', phase: step.phase })]
|
|
22
|
+
: []),
|
|
23
|
+
...Object.entries(step.on).flatMap(([outcome, edge]) =>
|
|
24
|
+
collectEdgeRejections(edge, {
|
|
25
|
+
steps,
|
|
26
|
+
index,
|
|
27
|
+
phase: step.phase,
|
|
28
|
+
outcome,
|
|
29
|
+
path: [...path, 'on', outcome],
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
];
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectOutcomeRejections(
|
|
38
|
+
step: ParsedStep,
|
|
39
|
+
phase: PhaseKind,
|
|
40
|
+
path: readonly PropertyKey[],
|
|
41
|
+
): LoadRejection[] {
|
|
42
|
+
const listed: readonly string[] = phase.outcomes;
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
...engineOutcomes
|
|
46
|
+
.filter((outcome) => listed.includes(outcome))
|
|
47
|
+
.map((outcome) =>
|
|
48
|
+
buildRejection([...path, 'phase'], {
|
|
49
|
+
kind: 'engine-outcome-listed',
|
|
50
|
+
phase: step.phase,
|
|
51
|
+
outcome,
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
...listed
|
|
55
|
+
.filter((outcome) => !Object.hasOwn(step.on, outcome))
|
|
56
|
+
.map((outcome) =>
|
|
57
|
+
buildRejection([...path, 'on'], { kind: 'unmapped-outcome', phase: step.phase, outcome }),
|
|
58
|
+
),
|
|
59
|
+
...Object.keys(step.on)
|
|
60
|
+
.filter((outcome) => !listed.includes(outcome))
|
|
61
|
+
.map((outcome) =>
|
|
62
|
+
buildRejection([...path, 'on', outcome], {
|
|
63
|
+
kind: 'unknown-outcome',
|
|
64
|
+
phase: step.phase,
|
|
65
|
+
outcome,
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type ParsedEdge = ParsedStep['on'][string];
|
|
72
|
+
|
|
73
|
+
interface EdgeContext {
|
|
74
|
+
readonly steps: readonly ParsedStep[];
|
|
75
|
+
readonly index: number;
|
|
76
|
+
readonly phase: PhaseName;
|
|
77
|
+
readonly outcome: string;
|
|
78
|
+
readonly path: readonly PropertyKey[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectEdgeRejections(edge: ParsedEdge, context: EdgeContext): LoadRejection[] {
|
|
82
|
+
const engineReasons: readonly string[] = engineHoldReasons;
|
|
83
|
+
|
|
84
|
+
return match(edge)
|
|
85
|
+
.with(P.string, { end: P.string }, () => [])
|
|
86
|
+
.with({ hold: P.string }, (held) =>
|
|
87
|
+
engineReasons.includes(held.hold)
|
|
88
|
+
? [
|
|
89
|
+
buildRejection([...context.path, 'hold'], {
|
|
90
|
+
kind: 'engine-hold-reason',
|
|
91
|
+
phase: context.phase,
|
|
92
|
+
outcome: context.outcome,
|
|
93
|
+
reason: held.hold,
|
|
94
|
+
}),
|
|
95
|
+
]
|
|
96
|
+
: [],
|
|
97
|
+
)
|
|
98
|
+
.with({ to: P.string }, (jump) => collectJumpRejections(jump, context))
|
|
99
|
+
.exhaustive();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function collectJumpRejections(
|
|
103
|
+
jump: Extract<ParsedEdge, { to: string }>,
|
|
104
|
+
context: EdgeContext,
|
|
105
|
+
): LoadRejection[] {
|
|
106
|
+
const target = context.steps.findIndex((step) => step.phase === jump.to);
|
|
107
|
+
const edge = { phase: context.phase, outcome: context.outcome };
|
|
108
|
+
|
|
109
|
+
if (target === -1) {
|
|
110
|
+
return [
|
|
111
|
+
buildRejection(context.path, {
|
|
112
|
+
kind: 'edge-to-unknown-phase',
|
|
113
|
+
phase: context.phase,
|
|
114
|
+
to: jump.to,
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// an edge to its own step loops, as a backward edge does
|
|
120
|
+
const isBack = target <= context.index;
|
|
121
|
+
|
|
122
|
+
if (isBack && jump.max === undefined) {
|
|
123
|
+
return [buildRejection(context.path, { kind: 'open-loop', ...edge })];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isBack && jump.resume === true) {
|
|
127
|
+
return [buildRejection(context.path, { kind: 'resume-on-forward-edge', ...edge })];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
@@ -6,25 +6,36 @@ import type {
|
|
|
6
6
|
ConceptPin,
|
|
7
7
|
ItemKindName,
|
|
8
8
|
KindRules,
|
|
9
|
+
Limit,
|
|
9
10
|
LoadRejection,
|
|
10
11
|
LoadResult,
|
|
11
|
-
|
|
12
|
+
PhaseKind,
|
|
13
|
+
PhaseName,
|
|
14
|
+
Pipeline,
|
|
15
|
+
PipelineStep,
|
|
12
16
|
Pool,
|
|
17
|
+
RecipeMaterial,
|
|
13
18
|
RecipeName,
|
|
19
|
+
RelationType,
|
|
20
|
+
RelationTypeName,
|
|
14
21
|
RulesVersion,
|
|
15
22
|
} from '@boxxy/model';
|
|
16
23
|
import { brandSchemas } from '@boxxy/model';
|
|
17
24
|
import invariant from 'tiny-invariant';
|
|
18
25
|
import { match } from 'ts-pattern';
|
|
19
|
-
import {
|
|
26
|
+
import { rulesFileName } from '../constants.ts';
|
|
27
|
+
import { parseConceptEntry } from '../parse/parse-concept-entry.ts';
|
|
28
|
+
import { parseRulesFile } from '../parse/parse-rules-file.ts';
|
|
29
|
+
import type { ParsedRulesFile, ParsedStep } from '../parse/rules-file-schema.ts';
|
|
30
|
+
import { buildRegistry } from '../registry/build-registry.ts';
|
|
31
|
+
import { resolvePlugin } from '../registry/resolve-plugin.ts';
|
|
32
|
+
import { resolveSignalRef } from '../registry/resolve-signal-ref.ts';
|
|
33
|
+
import type { Checked, PinnedConcept, ResolvedSignal } from '../types.ts';
|
|
20
34
|
import { buildRejection } from './build-rejection.ts';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { resolveSignalRef } from './resolve-signal-ref.ts';
|
|
26
|
-
import type { ParsedRulesFile } from './rules-file-schema.ts';
|
|
27
|
-
import type { Checked, PinnedConcept, ResolvedSignal } from './types.ts';
|
|
35
|
+
import { collectFilterRejections } from './collect-filter-rejections.ts';
|
|
36
|
+
import { collectRecipeRejections } from './collect-recipe-rejections.ts';
|
|
37
|
+
import { collectStepRejections } from './collect-step-rejections.ts';
|
|
38
|
+
import { toFilter } from './to-filter.ts';
|
|
28
39
|
|
|
29
40
|
interface LoadRulesInput {
|
|
30
41
|
readonly checkout: {
|
|
@@ -33,6 +44,9 @@ interface LoadRulesInput {
|
|
|
33
44
|
};
|
|
34
45
|
readonly concepts: readonly Concept[];
|
|
35
46
|
readonly pins: ReadonlyMap<ConceptName, ConceptPin>;
|
|
47
|
+
|
|
48
|
+
// the phase kinds the server registers, as it registers the concepts
|
|
49
|
+
readonly phases: readonly PhaseKind[];
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
export function loadRules(input: LoadRulesInput): LoadResult {
|
|
@@ -66,6 +80,7 @@ function buildLoadResult(
|
|
|
66
80
|
);
|
|
67
81
|
|
|
68
82
|
const registry = built.value;
|
|
83
|
+
const phases = buildPhases(input.phases);
|
|
69
84
|
const lead = file.lead.map((ref, index) => resolveSignalRef(ref, ['lead', index], registry));
|
|
70
85
|
|
|
71
86
|
const pools = file.pools.map((pool, poolIndex) => ({
|
|
@@ -93,7 +108,10 @@ function buildLoadResult(
|
|
|
93
108
|
...built.rejections,
|
|
94
109
|
...[...signals, ...gates].flatMap((checked) => checked.rejections),
|
|
95
110
|
...collectPropagationRejections(file, propagation),
|
|
96
|
-
...
|
|
111
|
+
...collectStepRejections(file, phases),
|
|
112
|
+
...collectRecipeRejections(file),
|
|
113
|
+
...collectFilterRejections(file, registry),
|
|
114
|
+
...collectSleepRejections(file),
|
|
97
115
|
...prompts.rejections,
|
|
98
116
|
];
|
|
99
117
|
|
|
@@ -101,9 +119,9 @@ function buildLoadResult(
|
|
|
101
119
|
return { kind: 'rejected', rejections };
|
|
102
120
|
}
|
|
103
121
|
|
|
104
|
-
const version = buildRulesVersion(
|
|
105
|
-
input.checkout.commit,
|
|
106
|
-
[
|
|
122
|
+
const version = buildRulesVersion({
|
|
123
|
+
commit: input.checkout.commit,
|
|
124
|
+
files: [
|
|
107
125
|
{ path: rulesFileName, bytes: rulesBytes, executable: false },
|
|
108
126
|
...prompts.value.map((prompt) => ({
|
|
109
127
|
path: prompt.recipe.prompt,
|
|
@@ -111,11 +129,11 @@ function buildLoadResult(
|
|
|
111
129
|
executable: false,
|
|
112
130
|
})),
|
|
113
131
|
],
|
|
114
|
-
registry.pins,
|
|
115
|
-
|
|
132
|
+
pins: registry.pins,
|
|
133
|
+
phases: input.phases,
|
|
134
|
+
});
|
|
116
135
|
|
|
117
136
|
const leadKeys = lead.map((checked) => getValue(checked).key);
|
|
118
|
-
const kinds = buildKinds(file);
|
|
119
137
|
|
|
120
138
|
return {
|
|
121
139
|
kind: 'loaded',
|
|
@@ -127,22 +145,21 @@ function buildLoadResult(
|
|
|
127
145
|
signals.map((checked) => [getValue(checked).key, getValue(checked).resolved]),
|
|
128
146
|
),
|
|
129
147
|
gates: gates.map((checked) => getValue(checked)),
|
|
130
|
-
kinds,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
),
|
|
148
|
+
kinds: buildKinds(file),
|
|
149
|
+
phases,
|
|
150
|
+
limits: file.limits.map((limit): Limit => ({
|
|
151
|
+
name: limit.name,
|
|
152
|
+
max: limit.max,
|
|
153
|
+
counts: toFilter(limit.counts),
|
|
154
|
+
})),
|
|
137
155
|
pools: pools.map((entry): Pool => ({
|
|
138
156
|
name: entry.pool.name,
|
|
139
|
-
|
|
140
|
-
takes: buildTakes(entry.pool.takes),
|
|
157
|
+
takes: toFilter(entry.pool.takes),
|
|
141
158
|
order: [...new Set([...leadKeys, ...entry.order.map((checked) => getValue(checked).key)])],
|
|
142
159
|
})),
|
|
143
160
|
propagation: file.propagation,
|
|
144
161
|
recipes: new Map(
|
|
145
|
-
prompts.value.map((prompt): [RecipeName,
|
|
162
|
+
prompts.value.map((prompt): [RecipeName, RecipeMaterial] => [
|
|
146
163
|
prompt.name,
|
|
147
164
|
{
|
|
148
165
|
name: prompt.name,
|
|
@@ -152,6 +169,11 @@ function buildLoadResult(
|
|
|
152
169
|
},
|
|
153
170
|
]),
|
|
154
171
|
),
|
|
172
|
+
sleep: file.sleep,
|
|
173
|
+
skills: file.skills ?? null,
|
|
174
|
+
relationTypes: new Map(
|
|
175
|
+
file.relationTypes.map((name): [RelationTypeName, RelationType] => [name, { name }]),
|
|
176
|
+
),
|
|
155
177
|
},
|
|
156
178
|
warnings: [],
|
|
157
179
|
};
|
|
@@ -192,6 +214,14 @@ function getPin(pins: ReadonlyMap<ConceptName, ConceptPin>, concept: Concept): C
|
|
|
192
214
|
return pin;
|
|
193
215
|
}
|
|
194
216
|
|
|
217
|
+
function buildPhases(phases: readonly PhaseKind[]): ReadonlyMap<PhaseName, PhaseKind> {
|
|
218
|
+
const byName = new Map(phases.map((phase) => [phase.name, phase]));
|
|
219
|
+
|
|
220
|
+
invariant(byName.size === phases.length, 'the server registers each phase kind once');
|
|
221
|
+
|
|
222
|
+
return byName;
|
|
223
|
+
}
|
|
224
|
+
|
|
195
225
|
interface RecipePrompt {
|
|
196
226
|
readonly name: RecipeName;
|
|
197
227
|
readonly recipe: ParsedRulesFile['recipes'][RecipeName];
|
|
@@ -223,21 +253,21 @@ function collectPrompts(
|
|
|
223
253
|
return { value: prompts, rejections };
|
|
224
254
|
}
|
|
225
255
|
|
|
226
|
-
const knownLinkTypes: ReadonlySet<string> = new Set(['blocks']);
|
|
227
|
-
|
|
228
256
|
function collectPropagationRejections(
|
|
229
257
|
file: ParsedRulesFile,
|
|
230
258
|
signals: readonly Checked<ResolvedSignal>[],
|
|
231
259
|
): LoadRejection[] {
|
|
260
|
+
const relationTypes: ReadonlySet<string> = new Set(file.relationTypes);
|
|
261
|
+
|
|
232
262
|
return file.propagation.flatMap((line, index) => {
|
|
233
263
|
const rejections: LoadRejection[] = [];
|
|
234
264
|
const signal = signals[index]?.value ?? null;
|
|
235
265
|
|
|
236
|
-
if (!
|
|
266
|
+
if (!relationTypes.has(line.over)) {
|
|
237
267
|
rejections.push(
|
|
238
268
|
buildRejection(['propagation', index, 'over'], {
|
|
239
|
-
kind: 'unknown-
|
|
240
|
-
|
|
269
|
+
kind: 'unknown-relation-type',
|
|
270
|
+
relation: line.over,
|
|
241
271
|
}),
|
|
242
272
|
);
|
|
243
273
|
}
|
|
@@ -255,34 +285,35 @@ function collectPropagationRejections(
|
|
|
255
285
|
});
|
|
256
286
|
}
|
|
257
287
|
|
|
258
|
-
function
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
288
|
+
function collectSleepRejections(file: ParsedRulesFile): LoadRejection[] {
|
|
289
|
+
const sleep = file.sleep;
|
|
290
|
+
|
|
291
|
+
return [
|
|
292
|
+
...(sleep.afterQuietMinutes < sleep.afterIdleMinutes
|
|
293
|
+
? [buildRejection(['sleep', 'afterQuietMinutes'], { kind: 'quiet-below-idle' })]
|
|
294
|
+
: []),
|
|
295
|
+
...(sleep.afterUnattendedMinutes < sleep.afterQuietMinutes
|
|
296
|
+
? [buildRejection(['sleep', 'afterUnattendedMinutes'], { kind: 'unattended-below-quiet' })]
|
|
297
|
+
: []),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface RulesVersionInput {
|
|
302
|
+
readonly commit: string;
|
|
303
|
+
readonly files: readonly TreeFile[];
|
|
304
|
+
readonly pins: readonly ConceptPin[];
|
|
305
|
+
readonly phases: readonly PhaseKind[];
|
|
274
306
|
}
|
|
275
307
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
pins: readonly ConceptPin[],
|
|
280
|
-
): RulesVersion {
|
|
308
|
+
// A replay needs the exact plug-in code, so each concept pin and each phase kind's version are
|
|
309
|
+
// part of the version.
|
|
310
|
+
function buildRulesVersion(input: RulesVersionInput): RulesVersion {
|
|
281
311
|
return brandSchemas.rulesVersion.parse(
|
|
282
312
|
contentHash({
|
|
283
|
-
commit,
|
|
284
|
-
files: hashTree(files),
|
|
285
|
-
pins: pins.map((pin) => ({ ref: pin.ref, sourceHash: pin.sourceHash })),
|
|
313
|
+
commit: input.commit,
|
|
314
|
+
files: hashTree(input.files),
|
|
315
|
+
pins: input.pins.map((pin) => ({ ref: pin.ref, sourceHash: pin.sourceHash })),
|
|
316
|
+
phases: input.phases.map((phase) => `${phase.name}@${phase.version}`).toSorted(),
|
|
286
317
|
}),
|
|
287
318
|
);
|
|
288
319
|
}
|
|
@@ -294,27 +325,34 @@ function getValue<T>(checked: Checked<T>): T {
|
|
|
294
325
|
}
|
|
295
326
|
|
|
296
327
|
function buildKinds(file: ParsedRulesFile): ReadonlyMap<ItemKindName, KindRules> {
|
|
297
|
-
|
|
298
|
-
Object.entries(file.
|
|
299
|
-
|
|
300
|
-
{
|
|
301
|
-
pipeline: {
|
|
302
|
-
name: brandSchemas.pipelineName.parse(name),
|
|
303
|
-
steps: kind.steps,
|
|
304
|
-
},
|
|
305
|
-
recipes: kind.recipes,
|
|
306
|
-
},
|
|
328
|
+
const pipelines = new Map(
|
|
329
|
+
Object.entries(file.pipelines).map(([name, steps]): [string, Pipeline] => [
|
|
330
|
+
name,
|
|
331
|
+
{ name: brandSchemas.pipelineName.parse(name), steps: steps.map((step) => buildStep(step)) },
|
|
307
332
|
]),
|
|
308
333
|
);
|
|
309
|
-
}
|
|
310
334
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
335
|
+
return new Map(
|
|
336
|
+
Object.entries(file.kinds).map(([name, kind]): [ItemKindName, KindRules] => {
|
|
337
|
+
const pipeline = pipelines.get(kind.pipeline);
|
|
338
|
+
|
|
339
|
+
invariant(
|
|
340
|
+
pipeline,
|
|
341
|
+
`the rules file schema refuses the kind ${name}, whose pipeline is missing`,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
return [brandSchemas.itemKindName.parse(name), { pipeline, recipes: kind.recipes }];
|
|
345
|
+
}),
|
|
319
346
|
);
|
|
320
347
|
}
|
|
348
|
+
|
|
349
|
+
function buildStep(step: ParsedStep): PipelineStep {
|
|
350
|
+
invariant(step.timeoutMinutes !== undefined, 'the loader refuses a step with no time limit');
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
phase: step.phase,
|
|
354
|
+
maxAttempts: step.maxAttempts,
|
|
355
|
+
timeoutMinutes: step.timeoutMinutes,
|
|
356
|
+
on: step.on,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Filter } from '@boxxy/model';
|
|
2
|
+
import type { ParsedRulesFile } from '../parse/rules-file-schema.ts';
|
|
3
|
+
|
|
4
|
+
// a filter in the rules file may give one value as a bare string; a loaded filter always holds a list
|
|
5
|
+
export function toFilter(filter: ParsedRulesFile['pools'][number]['takes']): Filter {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.entries(filter).map(([field, value]) => [
|
|
8
|
+
field,
|
|
9
|
+
typeof value === 'string' ? [value] : value,
|
|
10
|
+
]),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { Effort } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { parseConceptEntry } from './parse-concept-entry.ts';
|
|
5
|
+
|
|
6
|
+
const nameSchema = z.string().min(1);
|
|
7
|
+
|
|
8
|
+
const pluginRefSchema = z.union([
|
|
9
|
+
nameSchema,
|
|
10
|
+
z.record(nameSchema, z.json()).refine((ref) => Object.keys(ref).length === 1, {
|
|
11
|
+
message: 'a plug-in reference with parameters is a map with one key, the plug-in name',
|
|
12
|
+
}),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const edgeSchema = z.union([
|
|
16
|
+
z.literal('next'),
|
|
17
|
+
z.literal('again'),
|
|
18
|
+
z.strictObject({
|
|
19
|
+
to: brandSchemas.phaseName,
|
|
20
|
+
max: z.int().positive().exactOptional(),
|
|
21
|
+
resume: z.boolean().exactOptional(),
|
|
22
|
+
}),
|
|
23
|
+
z.strictObject({ end: nameSchema }),
|
|
24
|
+
z.strictObject({ hold: brandSchemas.rulesHoldReason }),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// `timeoutMinutes` is optional here so that the loader refuses a step without it by its own reason
|
|
28
|
+
const stepSchema = z.strictObject({
|
|
29
|
+
phase: brandSchemas.phaseName,
|
|
30
|
+
maxAttempts: z.int().positive(),
|
|
31
|
+
timeoutMinutes: z.int().positive().optional(),
|
|
32
|
+
on: z.record(nameSchema, edgeSchema),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const kindSchema = z.strictObject({
|
|
36
|
+
pipeline: brandSchemas.pipelineName,
|
|
37
|
+
|
|
38
|
+
// by phase, and by `<phase>/attended` for an attended session of the phase
|
|
39
|
+
recipes: z.record(nameSchema, brandSchemas.recipeName),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const filterSchema = z.record(nameSchema, z.union([nameSchema, z.array(nameSchema).min(1)]));
|
|
43
|
+
|
|
44
|
+
const limitSchema = z.strictObject({
|
|
45
|
+
name: brandSchemas.limitName,
|
|
46
|
+
max: z.int().positive(),
|
|
47
|
+
counts: filterSchema,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const poolSchema = z.strictObject({
|
|
51
|
+
name: brandSchemas.poolName,
|
|
52
|
+
slots: z.never({ error: 'a pool has no slot count; a limit caps its attempts' }).optional(),
|
|
53
|
+
takes: filterSchema,
|
|
54
|
+
order: z.array(pluginRefSchema),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const efforts = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies readonly Effort[];
|
|
58
|
+
|
|
59
|
+
const recipeSchema = z.strictObject({
|
|
60
|
+
agent: nameSchema,
|
|
61
|
+
model: nameSchema,
|
|
62
|
+
effort: z.enum(efforts),
|
|
63
|
+
prompt: nameSchema.refine(isCheckoutPath, {
|
|
64
|
+
message: 'a prompt path runs from the checkout root, with no empty, "." or ".." folder',
|
|
65
|
+
}),
|
|
66
|
+
source: brandSchemas.sourceName,
|
|
67
|
+
skills: z.array(nameSchema),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const skillsPinSchema = z.strictObject({
|
|
71
|
+
source: brandSchemas.repoName,
|
|
72
|
+
ref: brandSchemas.commitSha,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const conceptEntrySchema = z.templateLiteral([nameSchema, '@', z.int().positive()]);
|
|
76
|
+
|
|
77
|
+
const propagationLineSchema = z.strictObject({
|
|
78
|
+
value: nameSchema,
|
|
79
|
+
over: brandSchemas.relationTypeName,
|
|
80
|
+
toward: z.enum(['source', 'target']),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const minutesSchema = z.int().positive();
|
|
84
|
+
|
|
85
|
+
export const rulesFileSchema = z
|
|
86
|
+
.strictObject({
|
|
87
|
+
version: z.literal(1),
|
|
88
|
+
concepts: z.array(conceptEntrySchema),
|
|
89
|
+
pipelines: z.record(brandSchemas.pipelineName, z.array(stepSchema).min(1)),
|
|
90
|
+
kinds: z.record(brandSchemas.itemKindName, kindSchema),
|
|
91
|
+
skills: skillsPinSchema.optional(),
|
|
92
|
+
relationTypes: z.array(brandSchemas.relationTypeName),
|
|
93
|
+
recipes: z.record(brandSchemas.recipeName, recipeSchema),
|
|
94
|
+
limits: z.array(limitSchema),
|
|
95
|
+
pools: z.array(poolSchema),
|
|
96
|
+
lead: z.array(pluginRefSchema).default([]),
|
|
97
|
+
propagation: z.array(propagationLineSchema).default([]),
|
|
98
|
+
sleep: z.strictObject({
|
|
99
|
+
afterIdleMinutes: minutesSchema,
|
|
100
|
+
afterQuietMinutes: minutesSchema,
|
|
101
|
+
afterUnattendedMinutes: minutesSchema,
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
.superRefine((file, ctx) => {
|
|
105
|
+
for (const issue of [
|
|
106
|
+
...collectRepeats(
|
|
107
|
+
file.concepts.map((entry) => parseConceptEntry(entry).name),
|
|
108
|
+
['concepts'],
|
|
109
|
+
[],
|
|
110
|
+
(name) => `the concept ${name} is pinned twice`,
|
|
111
|
+
),
|
|
112
|
+
...collectRepeats(
|
|
113
|
+
file.pools.map((pool) => pool.name),
|
|
114
|
+
['pools'],
|
|
115
|
+
['name'],
|
|
116
|
+
(name) => `the pool name ${name} is used twice`,
|
|
117
|
+
),
|
|
118
|
+
...collectRepeats(
|
|
119
|
+
file.limits.map((limit) => limit.name),
|
|
120
|
+
['limits'],
|
|
121
|
+
['name'],
|
|
122
|
+
(name) => `the limit name ${name} is used twice`,
|
|
123
|
+
),
|
|
124
|
+
...collectRepeats(
|
|
125
|
+
file.relationTypes,
|
|
126
|
+
['relationTypes'],
|
|
127
|
+
[],
|
|
128
|
+
(name) => `the relation type ${name} is listed twice`,
|
|
129
|
+
),
|
|
130
|
+
...Object.entries(file.pipelines).flatMap(([pipeline, steps]) =>
|
|
131
|
+
collectRepeats(
|
|
132
|
+
steps.map((step) => step.phase),
|
|
133
|
+
['pipelines', pipeline],
|
|
134
|
+
['phase'],
|
|
135
|
+
(phase) => `the pipeline ${pipeline} names the phase ${phase} twice`,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
...Object.entries(file.kinds).flatMap(([kind, rules]) =>
|
|
139
|
+
Object.hasOwn(file.pipelines, rules.pipeline)
|
|
140
|
+
? []
|
|
141
|
+
: [
|
|
142
|
+
{
|
|
143
|
+
path: ['kinds', kind, 'pipeline'],
|
|
144
|
+
message: `the file has no pipeline ${rules.pipeline}`,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
),
|
|
148
|
+
]) {
|
|
149
|
+
ctx.addIssue({ code: 'custom', ...issue });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export type ParsedRulesFile = z.output<typeof rulesFileSchema>;
|
|
154
|
+
|
|
155
|
+
export type ParsedStep = z.output<typeof stepSchema>;
|
|
156
|
+
|
|
157
|
+
function isCheckoutPath(path: string): boolean {
|
|
158
|
+
return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface FileIssue {
|
|
162
|
+
readonly path: PropertyKey[];
|
|
163
|
+
readonly message: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function collectRepeats(
|
|
167
|
+
names: readonly string[],
|
|
168
|
+
listPath: readonly PropertyKey[],
|
|
169
|
+
itemPath: readonly PropertyKey[],
|
|
170
|
+
formatMessage: (name: string) => string,
|
|
171
|
+
): FileIssue[] {
|
|
172
|
+
return names.flatMap((name, index) =>
|
|
173
|
+
names.indexOf(name) === index
|
|
174
|
+
? []
|
|
175
|
+
: [{ path: [...listPath, index, ...itemPath], message: formatMessage(name) }],
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ConceptPin, LoadRejection, Registry } from '@boxxy/model';
|
|
2
|
-
import { buildRejection } from '
|
|
3
|
-
import type { PinnedConcept } from '
|
|
2
|
+
import { buildRejection } from '../loading/build-rejection.ts';
|
|
3
|
+
import type { PinnedConcept } from '../types.ts';
|
|
4
4
|
|
|
5
5
|
export function buildRegistry(
|
|
6
6
|
pinned: readonly PinnedConcept[],
|
|
@@ -2,8 +2,8 @@ import type { JsonValue } from '@boxxy/content-hash';
|
|
|
2
2
|
import type { ParamKindName, Registered, Resolved } from '@boxxy/model';
|
|
3
3
|
import { formatKeyPath } from '@boxxy/model';
|
|
4
4
|
import type { z } from 'zod';
|
|
5
|
-
import { buildRejection } from '
|
|
6
|
-
import type { Checked } from '
|
|
5
|
+
import { buildRejection } from '../loading/build-rejection.ts';
|
|
6
|
+
import type { Checked } from '../types.ts';
|
|
7
7
|
|
|
8
8
|
export function resolvePlugin<K extends ParamKindName>(
|
|
9
9
|
registered: Registered<K>,
|
|
@@ -2,9 +2,9 @@ import type { JsonValue } from '@boxxy/content-hash';
|
|
|
2
2
|
import type { Registry, SignalRef } from '@boxxy/model';
|
|
3
3
|
import { signalKey } from '@boxxy/model';
|
|
4
4
|
import invariant from 'tiny-invariant';
|
|
5
|
-
import { buildRejection } from '
|
|
5
|
+
import { buildRejection } from '../loading/build-rejection.ts';
|
|
6
|
+
import type { Checked, ResolvedSignal } from '../types.ts';
|
|
6
7
|
import { resolvePlugin } from './resolve-plugin.ts';
|
|
7
|
-
import type { Checked, ResolvedSignal } from './types.ts';
|
|
8
8
|
|
|
9
9
|
export function resolveSignalRef(
|
|
10
10
|
ref: SignalRef,
|