@boxxy/planning 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +45 -2
- package/dist/build-frame.d.ts +8 -0
- package/dist/compare-item-ids.d.ts +2 -0
- package/dist/concepts/defer.d.ts +2 -0
- package/dist/concepts/expedite.d.ts +2 -0
- package/dist/concepts/pool.d.ts +2 -0
- package/dist/concepts.d.ts +2 -0
- package/dist/decide.d.ts +9 -0
- package/dist/format-fault.d.ts +1 -0
- package/dist/generated/concept-pins.d.ts +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/propagate.d.ts +14 -0
- package/dist/test-utils/build-mock-loaded-rules.d.ts +9 -0
- package/package.json +42 -4
- package/src/build-frame.ts +276 -0
- package/src/compare-item-ids.ts +10 -0
- package/src/concepts/defer.ts +50 -0
- package/src/concepts/expedite.ts +24 -0
- package/src/concepts/pool.ts +23 -0
- package/src/concepts.ts +6 -0
- package/src/decide.ts +187 -0
- package/src/format-fault.ts +3 -0
- package/src/generated/concept-pins.ts +33 -0
- package/src/index.ts +4 -0
- package/src/propagate.ts +132 -0
- package/src/test-utils/build-mock-loaded-rules.ts +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the boxxy authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@boxxy/planning</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@boxxy/planning"><img src="https://img.shields.io/npm/v/@boxxy/planning" alt="npm version"></a>
|
|
6
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/design/boxxy/README.md">Documentation</a>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
boxxy's planning. `buildFrame` runs the plug-ins of the loaded rules over a snapshot of the open
|
|
15
|
+
items, and `decide` picks the wakes and the item phases that start, while each limit has a free
|
|
16
|
+
seat.
|
|
17
|
+
|
|
18
|
+
The package does no I/O: the caller passes in the snapshot, the loaded rules and the time.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Install the package from [npm](https://www.npmjs.com/package/@boxxy/planning) with Bun:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add @boxxy/planning
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- `buildFrame`, which returns the candidates, their holds, lifts, signal values and filter-field
|
|
33
|
+
values, and each plug-in fault
|
|
34
|
+
([planning: buildFrame and decide](../../docs/design/boxxy/slice-1.md#planning-buildframe-and-decide)).
|
|
35
|
+
- `decide`, which puts each wake first, then picks pool by pool while each limit that counts a pick
|
|
36
|
+
has a free seat, gives the reason for each item and wake it passes over, and never names a concept
|
|
37
|
+
([planning: limits and the decision](../../docs/design/boxxy/planning.md#limits-and-the-decision)).
|
|
38
|
+
- `concepts`, the `pool`, `defer` and `expedite` concepts
|
|
39
|
+
([the slice 1 concepts](../../docs/design/boxxy/slice-1.md#the-slice-1-concepts)).
|
|
40
|
+
- `conceptPins`, each concept's pin, which the build generates from the concept's source
|
|
41
|
+
([rules for slice 1](../../docs/design/boxxy/slice-1.md#rules-for-slice-1)).
|
|
42
|
+
|
|
43
|
+
## Documentation
|
|
44
|
+
|
|
45
|
+
The [planning design](../../docs/design/boxxy/planning.md) says why concepts are plug-ins, and
|
|
46
|
+
[`04-planning.ts`](../../docs/design/boxxy/sketches/04-planning.ts) gives the shapes.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Frame, Instant, LoadedRules, Snapshot } from '@boxxy/model';
|
|
2
|
+
interface BuildFrameInput {
|
|
3
|
+
readonly snapshot: Snapshot;
|
|
4
|
+
readonly rules: LoadedRules;
|
|
5
|
+
readonly now: Instant;
|
|
6
|
+
}
|
|
7
|
+
export declare function buildFrame(input: BuildFrameInput): Frame;
|
|
8
|
+
export {};
|
package/dist/decide.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Decision, Frame, Pool, PoolName, Snapshot } from '@boxxy/model';
|
|
2
|
+
interface DecideInput {
|
|
3
|
+
readonly frame: Frame;
|
|
4
|
+
readonly pools: readonly Pool[];
|
|
5
|
+
readonly snapshot: Pick<Snapshot, 'limits' | 'wakes'>;
|
|
6
|
+
readonly backoff: readonly PoolName[];
|
|
7
|
+
}
|
|
8
|
+
export declare function decide(input: DecideInput): Decision;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function formatFault(error: unknown): string;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Fault, ItemId, ItemView, PropagationLine, RelationRow, Resolved, SignalKey } from '@boxxy/model';
|
|
2
|
+
interface PropagateInput {
|
|
3
|
+
readonly lines: readonly PropagationLine[];
|
|
4
|
+
readonly items: readonly ItemView[];
|
|
5
|
+
readonly relations: readonly RelationRow[];
|
|
6
|
+
readonly signals: ReadonlyMap<SignalKey, Resolved<'signal'>>;
|
|
7
|
+
readonly values: ReadonlyMap<SignalKey, ReadonlyMap<ItemId, number>>;
|
|
8
|
+
}
|
|
9
|
+
interface Propagated {
|
|
10
|
+
readonly values: Map<SignalKey, Map<ItemId, number>>;
|
|
11
|
+
readonly faults: readonly Fault[];
|
|
12
|
+
}
|
|
13
|
+
export declare function propagate(input: PropagateInput): Propagated;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Concept, JsonValue, LoadedRules } from '@boxxy/model';
|
|
2
|
+
interface MockRulesInput {
|
|
3
|
+
readonly concepts?: readonly Concept[];
|
|
4
|
+
readonly pools?: readonly JsonValue[];
|
|
5
|
+
readonly lead?: readonly JsonValue[];
|
|
6
|
+
readonly propagation?: readonly JsonValue[];
|
|
7
|
+
}
|
|
8
|
+
export declare function buildMockLoadedRules(input?: MockRulesInput): LoadedRules;
|
|
9
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/planning",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "boxxy's planning: builds the frame from the plug-ins and picks the next work for each free slot",
|
|
5
|
+
"homepage": "https://github.com/zgeoff/boxxy/tree/main/modules/planning#readme",
|
|
6
|
+
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zgeoff/boxxy.git",
|
|
11
|
+
"directory": "modules/planning"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./src/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"./test-utils/*": "./src/test-utils/*.ts"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "bun ../../scripts/write-concept-pins.ts && rm -rf dist && tsrs -p tsconfig.build.json",
|
|
31
|
+
"typecheck": "tsrs --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@boxxy/content-hash": "0.1.3",
|
|
35
|
+
"@boxxy/model": "0.1.3",
|
|
36
|
+
"@boxxy/rules": "0.1.3",
|
|
37
|
+
"tiny-invariant": "1.3.3",
|
|
38
|
+
"zod": "4.6.7"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.3",
|
|
42
|
+
"node": ">=24"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Fault,
|
|
3
|
+
FilterField,
|
|
4
|
+
Frame,
|
|
5
|
+
FrameItem,
|
|
6
|
+
FrameValues,
|
|
7
|
+
Hold,
|
|
8
|
+
Instant,
|
|
9
|
+
ItemId,
|
|
10
|
+
ItemView,
|
|
11
|
+
Lift,
|
|
12
|
+
LoadedRules,
|
|
13
|
+
PlanContext,
|
|
14
|
+
Resolved,
|
|
15
|
+
SignalKey,
|
|
16
|
+
Snapshot,
|
|
17
|
+
Waiver,
|
|
18
|
+
} from '@boxxy/model';
|
|
19
|
+
import { signalKey } from '@boxxy/model';
|
|
20
|
+
import invariant from 'tiny-invariant';
|
|
21
|
+
import { formatFault } from './format-fault.ts';
|
|
22
|
+
import { propagate } from './propagate.ts';
|
|
23
|
+
|
|
24
|
+
interface BuildFrameInput {
|
|
25
|
+
readonly snapshot: Snapshot;
|
|
26
|
+
readonly rules: LoadedRules;
|
|
27
|
+
readonly now: Instant;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildFrame(input: BuildFrameInput): Frame {
|
|
31
|
+
const items = input.snapshot.items;
|
|
32
|
+
const candidates = collectCandidates(items);
|
|
33
|
+
|
|
34
|
+
const buildContext = (values: FrameValues): PlanContext => ({
|
|
35
|
+
now: input.now,
|
|
36
|
+
levers: input.snapshot.levers,
|
|
37
|
+
relations: input.snapshot.relations,
|
|
38
|
+
values,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const carriedKeys = new Set(input.rules.propagation.map((line) => signalKey(line.value, {})));
|
|
42
|
+
|
|
43
|
+
const carried = [...carriedKeys].map((key) =>
|
|
44
|
+
collectSignalValues(key, getSignal(input.rules, key), items, buildContext(new Map())),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const propagated = propagate({
|
|
48
|
+
lines: input.rules.propagation,
|
|
49
|
+
items,
|
|
50
|
+
relations: input.snapshot.relations,
|
|
51
|
+
signals: input.rules.signals,
|
|
52
|
+
values: new Map(carried.map((entry) => [entry.key, entry.values])),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const others = [...input.rules.signals]
|
|
56
|
+
.filter(([key]) => !carriedKeys.has(key))
|
|
57
|
+
.map(([key, signal]) =>
|
|
58
|
+
collectSignalValues(key, signal, candidates, buildContext(propagated.values)),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const values = new Map([
|
|
62
|
+
...propagated.values,
|
|
63
|
+
...others.map((entry): [SignalKey, Map<ItemId, number>] => [entry.key, entry.values]),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const context = buildContext(values);
|
|
67
|
+
const waivers = [...input.rules.registry.waiver.values()];
|
|
68
|
+
const gated = candidates.map((item) => collectHolds(item, input.rules.gates, waivers, context));
|
|
69
|
+
const filterFields = [...input.rules.registry['filter-field'].values()];
|
|
70
|
+
const fields = candidates.map((item) => collectFieldValues(item, filterFields));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
candidates,
|
|
74
|
+
fields: new Map(fields.map((entry) => [entry.item, entry.values])),
|
|
75
|
+
values,
|
|
76
|
+
holds: new Map(
|
|
77
|
+
gated.flatMap((entry) => (entry.holds.length > 0 ? [[entry.item, entry.holds]] : [])),
|
|
78
|
+
),
|
|
79
|
+
lifted: new Map(
|
|
80
|
+
gated.flatMap((entry) => (entry.lifted.length > 0 ? [[entry.item, entry.lifted]] : [])),
|
|
81
|
+
),
|
|
82
|
+
faults: [
|
|
83
|
+
...carried.flatMap((entry) => entry.faults),
|
|
84
|
+
...propagated.faults,
|
|
85
|
+
...others.flatMap((entry) => entry.faults),
|
|
86
|
+
...gated.flatMap((entry) => entry.faults),
|
|
87
|
+
...fields.flatMap((entry) => entry.faults),
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collectCandidates(items: readonly ItemView[]): FrameItem[] {
|
|
93
|
+
return items.flatMap((item) => {
|
|
94
|
+
if (item.derived.state !== 'ready') {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const position = item.position;
|
|
99
|
+
|
|
100
|
+
invariant(position.kind === 'phase', `ready item ${item.id} stands at no phase`);
|
|
101
|
+
|
|
102
|
+
return [{ ...item, phase: position.phase, round: position.round }];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface SignalValues {
|
|
107
|
+
readonly key: SignalKey;
|
|
108
|
+
readonly values: Map<ItemId, number>;
|
|
109
|
+
readonly faults: readonly Fault[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function collectSignalValues(
|
|
113
|
+
key: SignalKey,
|
|
114
|
+
signal: Resolved<'signal'>,
|
|
115
|
+
items: readonly ItemView[],
|
|
116
|
+
context: PlanContext,
|
|
117
|
+
): SignalValues {
|
|
118
|
+
const values = new Map<ItemId, number>();
|
|
119
|
+
|
|
120
|
+
const faults: Fault[] = [];
|
|
121
|
+
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
try {
|
|
124
|
+
const value = signal.open((plugin, params) => plugin.value(item, context, params));
|
|
125
|
+
|
|
126
|
+
if (value !== null) {
|
|
127
|
+
values.set(item.id, value);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
faults.push({
|
|
131
|
+
plugin: signal.name,
|
|
132
|
+
kind: 'signal',
|
|
133
|
+
item: item.id,
|
|
134
|
+
error: formatFault(error),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { key, values, faults };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getSignal(rules: LoadedRules, key: SignalKey): Resolved<'signal'> {
|
|
143
|
+
const signal = rules.signals.get(key);
|
|
144
|
+
|
|
145
|
+
invariant(signal, `the loader resolves the signal of each propagation line, and missed ${key}`);
|
|
146
|
+
|
|
147
|
+
return signal;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ItemHolds {
|
|
151
|
+
readonly item: ItemId;
|
|
152
|
+
readonly holds: Hold[];
|
|
153
|
+
readonly lifted: Lift[];
|
|
154
|
+
readonly faults: readonly Fault[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function collectHolds(
|
|
158
|
+
item: FrameItem,
|
|
159
|
+
gates: readonly Resolved<'gate'>[],
|
|
160
|
+
waivers: readonly Waiver[],
|
|
161
|
+
context: PlanContext,
|
|
162
|
+
): ItemHolds {
|
|
163
|
+
const outcomes = gates.map((gate) => pickGateOutcome(item, gate, context));
|
|
164
|
+
const faults = outcomes.flatMap((outcome) => outcome.faults);
|
|
165
|
+
const holds: Hold[] = [];
|
|
166
|
+
const lifted: Lift[] = [];
|
|
167
|
+
|
|
168
|
+
for (const outcome of outcomes) {
|
|
169
|
+
if (outcome.hold !== null) {
|
|
170
|
+
const waived = outcome.liftable
|
|
171
|
+
? pickLift(item, outcome.hold, waivers, context)
|
|
172
|
+
: { lift: null, faults: [] };
|
|
173
|
+
|
|
174
|
+
faults.push(...waived.faults);
|
|
175
|
+
|
|
176
|
+
if (waived.lift === null) {
|
|
177
|
+
holds.push(outcome.hold);
|
|
178
|
+
} else {
|
|
179
|
+
lifted.push(waived.lift);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { item: item.id, holds, lifted, faults };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface GateOutcome {
|
|
188
|
+
readonly hold: Hold | null;
|
|
189
|
+
readonly liftable: boolean;
|
|
190
|
+
readonly faults: readonly Fault[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function pickGateOutcome(
|
|
194
|
+
item: FrameItem,
|
|
195
|
+
gate: Resolved<'gate'>,
|
|
196
|
+
context: PlanContext,
|
|
197
|
+
): GateOutcome {
|
|
198
|
+
try {
|
|
199
|
+
return gate.open((plugin, params) => ({
|
|
200
|
+
hold: plugin.hold(item, context, params),
|
|
201
|
+
liftable: plugin.liftable,
|
|
202
|
+
faults: [],
|
|
203
|
+
}));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const message = formatFault(error);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
hold: { gate: gate.name, reason: message },
|
|
209
|
+
liftable: false,
|
|
210
|
+
faults: [{ plugin: gate.name, kind: 'gate', item: item.id, error: message }],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface Waived {
|
|
216
|
+
readonly lift: Lift | null;
|
|
217
|
+
readonly faults: readonly Fault[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pickLift(
|
|
221
|
+
item: FrameItem,
|
|
222
|
+
hold: Hold,
|
|
223
|
+
waivers: readonly Waiver[],
|
|
224
|
+
context: PlanContext,
|
|
225
|
+
): Waived {
|
|
226
|
+
const faults: Fault[] = [];
|
|
227
|
+
|
|
228
|
+
for (const waiver of waivers.filter((candidate) => candidate.lifts === hold.gate)) {
|
|
229
|
+
try {
|
|
230
|
+
const lift = waiver.lift(item, hold, context);
|
|
231
|
+
|
|
232
|
+
if (lift !== null) {
|
|
233
|
+
return { lift, faults };
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
faults.push({
|
|
237
|
+
plugin: waiver.name,
|
|
238
|
+
kind: 'waiver',
|
|
239
|
+
item: item.id,
|
|
240
|
+
error: formatFault(error),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { lift: null, faults };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface FieldValues {
|
|
249
|
+
readonly item: ItemId;
|
|
250
|
+
readonly values: Readonly<Record<string, readonly string[]>>;
|
|
251
|
+
readonly faults: readonly Fault[];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function collectFieldValues(item: FrameItem, fields: readonly FilterField[]): FieldValues {
|
|
255
|
+
const values: Record<string, readonly string[]> = {};
|
|
256
|
+
const faults: Fault[] = [];
|
|
257
|
+
|
|
258
|
+
for (const field of fields) {
|
|
259
|
+
try {
|
|
260
|
+
const value = field.value(item);
|
|
261
|
+
|
|
262
|
+
if (value !== null) {
|
|
263
|
+
values[field.name] = typeof value === 'string' ? [value] : value;
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
faults.push({
|
|
267
|
+
plugin: field.name,
|
|
268
|
+
kind: 'filter-field',
|
|
269
|
+
item: item.id,
|
|
270
|
+
error: formatFault(error),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { item: item.id, values, faults };
|
|
276
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ItemId } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
// compares by UTF-16 code units, so the order never depends on the locale
|
|
4
|
+
export function compareItemIDs(left: ItemId, right: ItemId): number {
|
|
5
|
+
if (left === right) {
|
|
6
|
+
return 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return left < right ? -1 : 1;
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Concept, LeverRow } from '@boxxy/model';
|
|
2
|
+
import { defineGate, signalKey } from '@boxxy/model';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const expediteKey = signalKey('expedite', {});
|
|
6
|
+
|
|
7
|
+
export const defer: Concept = {
|
|
8
|
+
name: 'defer',
|
|
9
|
+
version: 1,
|
|
10
|
+
gates: [
|
|
11
|
+
defineGate({
|
|
12
|
+
name: 'defer',
|
|
13
|
+
liftable: true,
|
|
14
|
+
params: z.object({}),
|
|
15
|
+
hold: (item, ctx) => {
|
|
16
|
+
const latest = findLatestDefer(ctx.levers.filter((lever) => lever.item === item.id));
|
|
17
|
+
|
|
18
|
+
if (latest === null || (latest.until !== null && latest.until <= ctx.now)) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const reason = latest.reason ?? 'deferred';
|
|
23
|
+
|
|
24
|
+
return latest.until === null
|
|
25
|
+
? { gate: 'defer', reason }
|
|
26
|
+
: { gate: 'defer', reason, until: latest.until };
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
],
|
|
30
|
+
waivers: [
|
|
31
|
+
{
|
|
32
|
+
name: 'expedite-lifts-defer',
|
|
33
|
+
lifts: 'defer',
|
|
34
|
+
lift: (item, hold, ctx) =>
|
|
35
|
+
ctx.values.get(expediteKey)?.has(item.id) === true
|
|
36
|
+
? { waiver: 'expedite-lifts-defer', gate: hold.gate, reason: 'expedited' }
|
|
37
|
+
: null,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
levers: ['plan.defer', 'plan.undefer'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function findLatestDefer(levers: readonly LeverRow[]): LeverRow | null {
|
|
44
|
+
return levers
|
|
45
|
+
.filter((lever) => lever.kind === 'plan.defer')
|
|
46
|
+
.reduce<LeverRow | null>(
|
|
47
|
+
(latest, lever) => (latest === null || lever.seq > latest.seq ? lever : latest),
|
|
48
|
+
null,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Concept } from '@boxxy/model';
|
|
2
|
+
import { defineSignal } from '@boxxy/model';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
export const expedite: Concept = {
|
|
6
|
+
name: 'expedite',
|
|
7
|
+
version: 1,
|
|
8
|
+
signals: [
|
|
9
|
+
defineSignal({
|
|
10
|
+
name: 'expedite',
|
|
11
|
+
params: z.object({}),
|
|
12
|
+
value: (item, ctx) => {
|
|
13
|
+
const position = ctx.levers
|
|
14
|
+
.filter((lever) => lever.kind === 'plan.expedite')
|
|
15
|
+
.toSorted((left, right) => left.seq - right.seq)
|
|
16
|
+
.findIndex((lever) => lever.item === item.id);
|
|
17
|
+
|
|
18
|
+
return position === -1 ? null : position;
|
|
19
|
+
},
|
|
20
|
+
combine: (left, right) => Math.min(left, right),
|
|
21
|
+
}),
|
|
22
|
+
],
|
|
23
|
+
levers: ['plan.expedite', 'plan.unexpedite'],
|
|
24
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Concept } from '@boxxy/model';
|
|
2
|
+
import { defineSignal } from '@boxxy/model';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
export const pool: Concept = {
|
|
6
|
+
name: 'pool',
|
|
7
|
+
version: 2,
|
|
8
|
+
signals: [
|
|
9
|
+
defineSignal({
|
|
10
|
+
name: 'oldest',
|
|
11
|
+
params: z.object({}),
|
|
12
|
+
value: (item) => item.filedAt,
|
|
13
|
+
}),
|
|
14
|
+
],
|
|
15
|
+
filterFields: [
|
|
16
|
+
{ name: 'phase', value: (item) => item.phase },
|
|
17
|
+
{ name: 'kind', value: (item) => item.kind },
|
|
18
|
+
{ name: 'repo', value: (item) => item.repo },
|
|
19
|
+
|
|
20
|
+
// an item that sets no design mode runs in agent mode
|
|
21
|
+
{ name: 'designMode', value: (item) => item.fields.designMode ?? 'agent' },
|
|
22
|
+
],
|
|
23
|
+
};
|
package/src/concepts.ts
ADDED
package/src/decide.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Decision,
|
|
3
|
+
FieldValues,
|
|
4
|
+
Filter,
|
|
5
|
+
Frame,
|
|
6
|
+
FrameItem,
|
|
7
|
+
LimitFill,
|
|
8
|
+
LimitName,
|
|
9
|
+
Pool,
|
|
10
|
+
PoolName,
|
|
11
|
+
Snapshot,
|
|
12
|
+
StartPick,
|
|
13
|
+
Wait,
|
|
14
|
+
WaitReason,
|
|
15
|
+
WakePick,
|
|
16
|
+
} from '@boxxy/model';
|
|
17
|
+
import { compareItemIDs } from './compare-item-ids.ts';
|
|
18
|
+
|
|
19
|
+
interface DecideInput {
|
|
20
|
+
readonly frame: Frame;
|
|
21
|
+
readonly pools: readonly Pool[];
|
|
22
|
+
readonly snapshot: Pick<Snapshot, 'limits' | 'wakes'>;
|
|
23
|
+
|
|
24
|
+
// the pools whose capacity or usage-limit backoff runs at the claim's time
|
|
25
|
+
readonly backoff: readonly PoolName[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decide(input: DecideInput): Decision {
|
|
29
|
+
const seats = buildSeats(input.snapshot.limits);
|
|
30
|
+
|
|
31
|
+
const backoff = new Set(input.backoff);
|
|
32
|
+
|
|
33
|
+
const picks: (StartPick | WakePick)[] = [];
|
|
34
|
+
const waits: Wait[] = [];
|
|
35
|
+
|
|
36
|
+
for (const wake of input.snapshot.wakes) {
|
|
37
|
+
const subject = { kind: 'wake', attempt: wake.attempt } as const;
|
|
38
|
+
const taken = backoff.has(wake.pool) ? backoffOf(wake.pool) : seats.take(wake.fields);
|
|
39
|
+
|
|
40
|
+
if (taken.kind === 'taken') {
|
|
41
|
+
picks.push({ kind: 'wake', attempt: wake.attempt, limits: taken.limits });
|
|
42
|
+
} else {
|
|
43
|
+
waits.push({ subject, reason: taken.reason });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const offered = collectOffers(input.frame, input.pools);
|
|
48
|
+
|
|
49
|
+
waits.push(...offered.unpooled);
|
|
50
|
+
|
|
51
|
+
for (const offer of offered.offers) {
|
|
52
|
+
const subject = { kind: 'item', item: offer.item.id } as const;
|
|
53
|
+
const taken = backoff.has(offer.pool) ? backoffOf(offer.pool) : seats.take(offer.fields);
|
|
54
|
+
|
|
55
|
+
if (taken.kind === 'taken') {
|
|
56
|
+
picks.push({
|
|
57
|
+
kind: 'start',
|
|
58
|
+
item: offer.item.id,
|
|
59
|
+
phase: offer.item.phase,
|
|
60
|
+
round: offer.item.round,
|
|
61
|
+
pool: offer.pool,
|
|
62
|
+
fields: offer.fields,
|
|
63
|
+
limits: taken.limits,
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
waits.push({ subject, reason: taken.reason });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { picks, waits };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type SeatTaking =
|
|
74
|
+
| { readonly kind: 'taken'; readonly limits: readonly LimitName[] }
|
|
75
|
+
| { readonly kind: 'refused'; readonly reason: WaitReason };
|
|
76
|
+
|
|
77
|
+
interface Seats {
|
|
78
|
+
take: (fields: FieldValues) => SeatTaking;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The free seats of each limit, counted down by each pick of this decision. A limit held over its
|
|
82
|
+
// max after a wake over the cap has fewer than none.
|
|
83
|
+
function buildSeats(limits: readonly LimitFill[]): Seats {
|
|
84
|
+
const free = new Map(limits.map((limit) => [limit.name, limit.max - limit.held]));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
take: (fields) => {
|
|
88
|
+
const counting = limits.filter((limit) => isMatch(limit.counts, fields));
|
|
89
|
+
const full = counting.find((limit) => (free.get(limit.name) ?? 0) <= 0);
|
|
90
|
+
|
|
91
|
+
if (full !== undefined) {
|
|
92
|
+
return { kind: 'refused', reason: { kind: 'limit-full', limit: full.name } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const limit of counting) {
|
|
96
|
+
free.set(limit.name, (free.get(limit.name) ?? 0) - 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { kind: 'taken', limits: counting.map((limit) => limit.name) };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// An item matches a filter when, for every field, one of its values is in the filter's list.
|
|
105
|
+
function isMatch(filter: Filter, fields: FieldValues): boolean {
|
|
106
|
+
return Object.entries(filter).every(([field, accepted]) =>
|
|
107
|
+
(fields[field] ?? []).some((value) => accepted.includes(value)),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function backoffOf(pool: PoolName): SeatTaking {
|
|
112
|
+
return { kind: 'refused', reason: { kind: 'backoff', pool } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Offer {
|
|
116
|
+
readonly item: FrameItem;
|
|
117
|
+
readonly pool: PoolName;
|
|
118
|
+
readonly fields: FieldValues;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Offers {
|
|
122
|
+
// in pool order, then by each pool's `order`
|
|
123
|
+
readonly offers: readonly Offer[];
|
|
124
|
+
readonly unpooled: readonly Wait[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Each candidate that no gate holds belongs to the first pool, in declared order, that takes it.
|
|
128
|
+
function collectOffers(frame: Frame, pools: readonly Pool[]): Offers {
|
|
129
|
+
const free = frame.candidates.filter((item) => (frame.holds.get(item.id) ?? []).length === 0);
|
|
130
|
+
|
|
131
|
+
const byPool = new Map<PoolName, FrameItem[]>(pools.map((pool) => [pool.name, []]));
|
|
132
|
+
|
|
133
|
+
const unpooled: Wait[] = [];
|
|
134
|
+
|
|
135
|
+
for (const item of free) {
|
|
136
|
+
const fields = frame.fields.get(item.id) ?? {};
|
|
137
|
+
const pool = pools.find((candidate) => isMatch(candidate.takes, fields));
|
|
138
|
+
|
|
139
|
+
if (pool === undefined) {
|
|
140
|
+
unpooled.push({ subject: { kind: 'item', item: item.id }, reason: { kind: 'no-pool' } });
|
|
141
|
+
} else {
|
|
142
|
+
byPool.get(pool.name)?.push(item);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const offers = pools.flatMap((pool) =>
|
|
147
|
+
(byPool.get(pool.name) ?? [])
|
|
148
|
+
.toSorted((left, right) => compareCandidates(left, right, pool, frame.values))
|
|
149
|
+
.map((item) => ({ item, pool: pool.name, fields: frame.fields.get(item.id) ?? {} })),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return { offers, unpooled };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function compareCandidates(
|
|
156
|
+
left: FrameItem,
|
|
157
|
+
right: FrameItem,
|
|
158
|
+
pool: Pool,
|
|
159
|
+
values: Frame['values'],
|
|
160
|
+
): number {
|
|
161
|
+
for (const key of pool.order) {
|
|
162
|
+
const byItem = values.get(key);
|
|
163
|
+
const order = compareValues(byItem?.get(left.id), byItem?.get(right.id));
|
|
164
|
+
|
|
165
|
+
if (order !== 0) {
|
|
166
|
+
return order;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return compareItemIDs(left.id, right.id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function compareValues(left: number | undefined, right: number | undefined): number {
|
|
174
|
+
if (left === right) {
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (left === undefined) {
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (right === undefined) {
|
|
183
|
+
return -1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return left < right ? -1 : 1;
|
|
187
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// scripts/write-concept-pins.ts writes this file; run `bun run build` in modules/planning
|
|
2
|
+
import { contentHashSchema } from '@boxxy/content-hash';
|
|
3
|
+
import type { ConceptName, ConceptPin } from '@boxxy/model';
|
|
4
|
+
|
|
5
|
+
export const conceptPins: ReadonlyMap<ConceptName, ConceptPin> = new Map([
|
|
6
|
+
[
|
|
7
|
+
'defer',
|
|
8
|
+
{
|
|
9
|
+
ref: 'defer@1',
|
|
10
|
+
sourceHash: contentHashSchema.parse(
|
|
11
|
+
'cbe173e108ca3b290f7f48ea3a4145b28f44611ae7f4a43d29430f08b88f46df',
|
|
12
|
+
),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
'expedite',
|
|
17
|
+
{
|
|
18
|
+
ref: 'expedite@1',
|
|
19
|
+
sourceHash: contentHashSchema.parse(
|
|
20
|
+
'f75e37b479e49da4e68aab6f3d8e9960b04608eb7a9fdd53d4349578e1c213d3',
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
'pool',
|
|
26
|
+
{
|
|
27
|
+
ref: 'pool@2',
|
|
28
|
+
sourceHash: contentHashSchema.parse(
|
|
29
|
+
'1631a22966bd50fa39a8acc79f242fb0ac6603ead15b8f4ca3907f5e2cdd1214',
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
]);
|
package/src/index.ts
ADDED
package/src/propagate.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Fault,
|
|
3
|
+
ItemId,
|
|
4
|
+
ItemView,
|
|
5
|
+
PropagationLine,
|
|
6
|
+
RelationRow,
|
|
7
|
+
Resolved,
|
|
8
|
+
SignalKey,
|
|
9
|
+
} from '@boxxy/model';
|
|
10
|
+
import { signalKey } from '@boxxy/model';
|
|
11
|
+
import invariant from 'tiny-invariant';
|
|
12
|
+
import { compareItemIDs } from './compare-item-ids.ts';
|
|
13
|
+
import { formatFault } from './format-fault.ts';
|
|
14
|
+
|
|
15
|
+
interface PropagateInput {
|
|
16
|
+
readonly lines: readonly PropagationLine[];
|
|
17
|
+
readonly items: readonly ItemView[];
|
|
18
|
+
readonly relations: readonly RelationRow[];
|
|
19
|
+
readonly signals: ReadonlyMap<SignalKey, Resolved<'signal'>>;
|
|
20
|
+
readonly values: ReadonlyMap<SignalKey, ReadonlyMap<ItemId, number>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Propagated {
|
|
24
|
+
readonly values: Map<SignalKey, Map<ItemId, number>>;
|
|
25
|
+
readonly faults: readonly Fault[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function propagate(input: PropagateInput): Propagated {
|
|
29
|
+
const open = new Set(input.items.map((item) => item.id));
|
|
30
|
+
|
|
31
|
+
const faults: Fault[] = [];
|
|
32
|
+
|
|
33
|
+
const values = new Map(
|
|
34
|
+
[...input.values].map(([key, byItem]): [SignalKey, Map<ItemId, number>] => [
|
|
35
|
+
key,
|
|
36
|
+
new Map(byItem),
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
for (const line of input.lines) {
|
|
41
|
+
const key = signalKey(line.value, {});
|
|
42
|
+
const combine = getCombine(input.signals, key);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const start = values.get(key) ?? new Map<ItemId, number>();
|
|
46
|
+
|
|
47
|
+
values.set(key, carryLine(line, combine, start, { open, relations: input.relations }));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
faults.push({
|
|
50
|
+
plugin: `propagation:${line.value}:${line.over}`,
|
|
51
|
+
kind: 'propagation',
|
|
52
|
+
item: null,
|
|
53
|
+
error: formatFault(error),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { values, faults };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Combine = (left: number, right: number) => number;
|
|
62
|
+
|
|
63
|
+
function getCombine(signals: PropagateInput['signals'], key: SignalKey): Combine {
|
|
64
|
+
const combine = signals.get(key)?.open((plugin) => plugin.combine);
|
|
65
|
+
|
|
66
|
+
invariant(combine, `the loader refuses a propagation line whose signal ${key} has no combine`);
|
|
67
|
+
|
|
68
|
+
return combine;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface LineGraph {
|
|
72
|
+
readonly open: ReadonlySet<ItemId>;
|
|
73
|
+
readonly relations: readonly RelationRow[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function carryLine(
|
|
77
|
+
line: PropagationLine,
|
|
78
|
+
combine: Combine,
|
|
79
|
+
start: ReadonlyMap<ItemId, number>,
|
|
80
|
+
graph: LineGraph,
|
|
81
|
+
): Map<ItemId, number> {
|
|
82
|
+
const next = collectNeighbours(line, graph.relations);
|
|
83
|
+
|
|
84
|
+
const carried = new Map(start);
|
|
85
|
+
|
|
86
|
+
const starts = [...start].toSorted(([left], [right]) => compareItemIDs(left, right));
|
|
87
|
+
|
|
88
|
+
for (const [origin, value] of starts) {
|
|
89
|
+
const visited = new Set([origin]);
|
|
90
|
+
|
|
91
|
+
const queue = [...(next.get(origin) ?? [])];
|
|
92
|
+
|
|
93
|
+
while (queue.length > 0) {
|
|
94
|
+
const reached = queue.shift();
|
|
95
|
+
|
|
96
|
+
if (reached === undefined || visited.has(reached) || !graph.open.has(reached)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
visited.add(reached);
|
|
101
|
+
|
|
102
|
+
const current = carried.get(reached);
|
|
103
|
+
const combined = current === undefined ? value : combine(current, value);
|
|
104
|
+
|
|
105
|
+
carried.set(reached, combined);
|
|
106
|
+
queue.push(...(next.get(reached) ?? []));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return carried;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collectNeighbours(
|
|
114
|
+
line: PropagationLine,
|
|
115
|
+
relations: readonly RelationRow[],
|
|
116
|
+
): Map<ItemId, ItemId[]> {
|
|
117
|
+
const next = new Map<ItemId, ItemId[]>();
|
|
118
|
+
|
|
119
|
+
for (const relation of relations.filter((candidate) => candidate.type === line.over)) {
|
|
120
|
+
const [from, to] =
|
|
121
|
+
line.toward === 'source'
|
|
122
|
+
? [relation.target, relation.source]
|
|
123
|
+
: [relation.source, relation.target];
|
|
124
|
+
|
|
125
|
+
const neighbours = next.get(from) ?? [];
|
|
126
|
+
|
|
127
|
+
neighbours.push(to);
|
|
128
|
+
next.set(from, neighbours);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { contentHash } from '@boxxy/content-hash';
|
|
2
|
+
import type { Concept, ConceptPin, JsonValue, LoadedRules } from '@boxxy/model';
|
|
3
|
+
import { buildMockPhaseKind } from '@boxxy/model/test-utils/build-mock-phase-kind';
|
|
4
|
+
import { loadRules } from '@boxxy/rules';
|
|
5
|
+
import invariant from 'tiny-invariant';
|
|
6
|
+
import { concepts as builtInConcepts } from '../concepts.ts';
|
|
7
|
+
|
|
8
|
+
interface MockRulesInput {
|
|
9
|
+
readonly concepts?: readonly Concept[];
|
|
10
|
+
readonly pools?: readonly JsonValue[];
|
|
11
|
+
readonly lead?: readonly JsonValue[];
|
|
12
|
+
readonly propagation?: readonly JsonValue[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
|
|
16
|
+
const concepts = input.concepts ?? builtInConcepts;
|
|
17
|
+
|
|
18
|
+
const file = {
|
|
19
|
+
version: 1,
|
|
20
|
+
concepts: concepts.map((concept) => `${concept.name}@${concept.version}`),
|
|
21
|
+
pipelines: {
|
|
22
|
+
standard: [{ phase: 'build', maxAttempts: 2, timeoutMinutes: 180, on: { built: 'next' } }],
|
|
23
|
+
},
|
|
24
|
+
kinds: { 'core.issue': { pipeline: 'standard', recipes: { build: 'build-v1' } } },
|
|
25
|
+
relationTypes: ['blocks'],
|
|
26
|
+
limits: [{ name: 'workers', max: 7, counts: {} }],
|
|
27
|
+
pools: input.pools ?? [
|
|
28
|
+
{ name: 'build', takes: { phase: 'build', kind: ['core.issue'] }, order: ['oldest'] },
|
|
29
|
+
],
|
|
30
|
+
lead: input.lead ?? ['expedite'],
|
|
31
|
+
propagation: input.propagation ?? [{ value: 'expedite', over: 'blocks', toward: 'source' }],
|
|
32
|
+
recipes: {
|
|
33
|
+
'build-v1': {
|
|
34
|
+
agent: 'claude',
|
|
35
|
+
model: 'example-model',
|
|
36
|
+
effort: 'high',
|
|
37
|
+
prompt: 'prompts/build-v1.md',
|
|
38
|
+
source: 'subscription',
|
|
39
|
+
skills: [],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
sleep: { afterIdleMinutes: 30, afterQuietMinutes: 60, afterUnattendedMinutes: 240 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const encoder = new TextEncoder();
|
|
46
|
+
|
|
47
|
+
// only modules/rules may import yaml, and JSON is YAML, so the loader reads this as it is
|
|
48
|
+
const rulesBytes = encoder.encode(JSON.stringify(file));
|
|
49
|
+
|
|
50
|
+
const pins = concepts.map((concept): [string, ConceptPin] => [
|
|
51
|
+
concept.name,
|
|
52
|
+
{
|
|
53
|
+
ref: `${concept.name}@${concept.version}`,
|
|
54
|
+
sourceHash: contentHash(`${concept.name} source`),
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const result = loadRules({
|
|
59
|
+
checkout: {
|
|
60
|
+
commit: '3f2a1c0',
|
|
61
|
+
files: new Map([
|
|
62
|
+
['rules.yaml', rulesBytes],
|
|
63
|
+
['prompts/build-v1.md', encoder.encode('Build {{title}}.\n')],
|
|
64
|
+
]),
|
|
65
|
+
},
|
|
66
|
+
concepts,
|
|
67
|
+
pins: new Map(pins),
|
|
68
|
+
phases: [buildMockPhaseKind({ name: 'build', outcomes: ['built'] })],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
invariant(
|
|
72
|
+
result.kind === 'loaded',
|
|
73
|
+
`the mock rules file failed to load: ${JSON.stringify(result)}`,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return result.rules;
|
|
77
|
+
}
|