@boxxy/planning 0.0.0-stage → 0.1.0
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 +42 -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 +8 -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 +274 -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 +20 -0
- package/src/concepts.ts +6 -0
- package/src/decide.ts +71 -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 +130 -0
- package/src/test-utils/build-mock-loaded-rules.ts +79 -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,43 @@
|
|
|
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 next item phase for each free pool slot.
|
|
16
|
+
|
|
17
|
+
The package does no I/O: the caller passes in the snapshot, the loaded rules and the time.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Install the package from [npm](https://www.npmjs.com/package/@boxxy/planning) with Bun:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun add @boxxy/planning
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- `buildFrame`, which returns the candidates, their holds, lifts, signal values and filter-field
|
|
32
|
+
values, and each plug-in fault
|
|
33
|
+
([planning: buildFrame and decide](../../docs/design/boxxy/slice-1.md#planning-buildframe-and-decide)).
|
|
34
|
+
- `decide`, which picks one candidate per free slot, pool by pool, and never names a concept.
|
|
35
|
+
- `concepts`, the `pool`, `defer` and `expedite` concepts
|
|
36
|
+
([the slice 1 concepts](../../docs/design/boxxy/slice-1.md#the-slice-1-concepts)).
|
|
37
|
+
- `conceptPins`, each concept's pin, which the build generates from the concept's source
|
|
38
|
+
([rules for slice 1](../../docs/design/boxxy/slice-1.md#rules-for-slice-1)).
|
|
39
|
+
|
|
40
|
+
## Documentation
|
|
41
|
+
|
|
42
|
+
The [planning design](../../docs/design/boxxy/planning.md) says why concepts are plug-ins, and
|
|
43
|
+
[`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,8 @@
|
|
|
1
|
+
import type { Decision, Frame, Pool, PoolName } from '@boxxy/model';
|
|
2
|
+
interface DecideInput {
|
|
3
|
+
readonly frame: Frame;
|
|
4
|
+
readonly pools: readonly Pool[];
|
|
5
|
+
readonly freeSlots: Readonly<Record<PoolName, number>>;
|
|
6
|
+
}
|
|
7
|
+
export declare function decide(input: DecideInput): Decision;
|
|
8
|
+
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, FrameItem, ItemId, LinkRow, PropagationLine, Resolved, SignalKey } from '@boxxy/model';
|
|
2
|
+
interface PropagateInput {
|
|
3
|
+
readonly lines: readonly PropagationLine[];
|
|
4
|
+
readonly items: readonly FrameItem[];
|
|
5
|
+
readonly links: readonly LinkRow[];
|
|
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.0",
|
|
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.0",
|
|
35
|
+
"@boxxy/model": "0.1.1",
|
|
36
|
+
"@boxxy/rules": "0.1.0",
|
|
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,274 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Fault,
|
|
3
|
+
FilterField,
|
|
4
|
+
Frame,
|
|
5
|
+
FrameItem,
|
|
6
|
+
FrameValues,
|
|
7
|
+
Hold,
|
|
8
|
+
Instant,
|
|
9
|
+
ItemId,
|
|
10
|
+
ItemKindName,
|
|
11
|
+
KindRules,
|
|
12
|
+
Lift,
|
|
13
|
+
LoadedRules,
|
|
14
|
+
PlanContext,
|
|
15
|
+
Resolved,
|
|
16
|
+
SignalKey,
|
|
17
|
+
Snapshot,
|
|
18
|
+
Waiver,
|
|
19
|
+
} from '@boxxy/model';
|
|
20
|
+
import { signalKey } from '@boxxy/model';
|
|
21
|
+
import invariant from 'tiny-invariant';
|
|
22
|
+
import { formatFault } from './format-fault.ts';
|
|
23
|
+
import { propagate } from './propagate.ts';
|
|
24
|
+
|
|
25
|
+
interface BuildFrameInput {
|
|
26
|
+
readonly snapshot: Snapshot;
|
|
27
|
+
readonly rules: LoadedRules;
|
|
28
|
+
readonly now: Instant;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildFrame(input: BuildFrameInput): Frame {
|
|
32
|
+
const items = collectFrameItems(input.snapshot, input.rules.kinds);
|
|
33
|
+
const candidates = items.filter((item) => item.derived.state === 'ready');
|
|
34
|
+
|
|
35
|
+
const buildContext = (values: FrameValues): PlanContext => ({
|
|
36
|
+
now: input.now,
|
|
37
|
+
levers: input.snapshot.levers,
|
|
38
|
+
links: input.snapshot.links,
|
|
39
|
+
values,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const carriedKeys = new Set(input.rules.propagation.map((line) => signalKey(line.value, {})));
|
|
43
|
+
|
|
44
|
+
const carried = [...carriedKeys].map((key) =>
|
|
45
|
+
collectSignalValues(key, getSignal(input.rules, key), items, buildContext(new Map())),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const propagated = propagate({
|
|
49
|
+
lines: input.rules.propagation,
|
|
50
|
+
items,
|
|
51
|
+
links: input.snapshot.links,
|
|
52
|
+
signals: input.rules.signals,
|
|
53
|
+
values: new Map(carried.map((entry) => [entry.key, entry.values])),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const others = [...input.rules.signals]
|
|
57
|
+
.filter(([key]) => !carriedKeys.has(key))
|
|
58
|
+
.map(([key, signal]) =>
|
|
59
|
+
collectSignalValues(key, signal, candidates, buildContext(propagated.values)),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const values = new Map([
|
|
63
|
+
...propagated.values,
|
|
64
|
+
...others.map((entry): [SignalKey, Map<ItemId, number>] => [entry.key, entry.values]),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const context = buildContext(values);
|
|
68
|
+
const waivers = [...input.rules.registry.waiver.values()];
|
|
69
|
+
const gated = candidates.map((item) => collectHolds(item, input.rules.gates, waivers, context));
|
|
70
|
+
const filterFields = [...input.rules.registry['filter-field'].values()];
|
|
71
|
+
const fields = candidates.map((item) => collectFieldValues(item, filterFields));
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
candidates,
|
|
75
|
+
fields: new Map(fields.map((entry) => [entry.item, entry.values])),
|
|
76
|
+
values,
|
|
77
|
+
holds: new Map(
|
|
78
|
+
gated.flatMap((entry) => (entry.holds.length > 0 ? [[entry.item, entry.holds]] : [])),
|
|
79
|
+
),
|
|
80
|
+
lifted: new Map(
|
|
81
|
+
gated.flatMap((entry) => (entry.lifted.length > 0 ? [[entry.item, entry.lifted]] : [])),
|
|
82
|
+
),
|
|
83
|
+
faults: [
|
|
84
|
+
...carried.flatMap((entry) => entry.faults),
|
|
85
|
+
...propagated.faults,
|
|
86
|
+
...others.flatMap((entry) => entry.faults),
|
|
87
|
+
...gated.flatMap((entry) => entry.faults),
|
|
88
|
+
...fields.flatMap((entry) => entry.faults),
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function collectFrameItems(
|
|
94
|
+
snapshot: Snapshot,
|
|
95
|
+
kinds: ReadonlyMap<ItemKindName, KindRules>,
|
|
96
|
+
): FrameItem[] {
|
|
97
|
+
return snapshot.items.flatMap((item) => {
|
|
98
|
+
const phase = kinds.get(item.kind)?.pipeline.steps[0]?.phase;
|
|
99
|
+
|
|
100
|
+
return phase === undefined ? [] : [{ ...item, phase }];
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SignalValues {
|
|
105
|
+
readonly key: SignalKey;
|
|
106
|
+
readonly values: Map<ItemId, number>;
|
|
107
|
+
readonly faults: readonly Fault[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function collectSignalValues(
|
|
111
|
+
key: SignalKey,
|
|
112
|
+
signal: Resolved<'signal'>,
|
|
113
|
+
items: readonly FrameItem[],
|
|
114
|
+
context: PlanContext,
|
|
115
|
+
): SignalValues {
|
|
116
|
+
const values = new Map<ItemId, number>();
|
|
117
|
+
|
|
118
|
+
const faults: Fault[] = [];
|
|
119
|
+
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
try {
|
|
122
|
+
const value = signal.open((plugin, params) => plugin.value(item, context, params));
|
|
123
|
+
|
|
124
|
+
if (value !== null) {
|
|
125
|
+
values.set(item.id, value);
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
faults.push({
|
|
129
|
+
plugin: signal.name,
|
|
130
|
+
kind: 'signal',
|
|
131
|
+
item: item.id,
|
|
132
|
+
error: formatFault(error),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { key, values, faults };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getSignal(rules: LoadedRules, key: SignalKey): Resolved<'signal'> {
|
|
141
|
+
const signal = rules.signals.get(key);
|
|
142
|
+
|
|
143
|
+
invariant(signal, `the loader resolves the signal of each propagation line, and missed ${key}`);
|
|
144
|
+
|
|
145
|
+
return signal;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface ItemHolds {
|
|
149
|
+
readonly item: ItemId;
|
|
150
|
+
readonly holds: Hold[];
|
|
151
|
+
readonly lifted: Lift[];
|
|
152
|
+
readonly faults: readonly Fault[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function collectHolds(
|
|
156
|
+
item: FrameItem,
|
|
157
|
+
gates: readonly Resolved<'gate'>[],
|
|
158
|
+
waivers: readonly Waiver[],
|
|
159
|
+
context: PlanContext,
|
|
160
|
+
): ItemHolds {
|
|
161
|
+
const outcomes = gates.map((gate) => pickGateOutcome(item, gate, context));
|
|
162
|
+
const faults = outcomes.flatMap((outcome) => outcome.faults);
|
|
163
|
+
const holds: Hold[] = [];
|
|
164
|
+
const lifted: Lift[] = [];
|
|
165
|
+
|
|
166
|
+
for (const outcome of outcomes) {
|
|
167
|
+
if (outcome.hold !== null) {
|
|
168
|
+
const waived = outcome.liftable
|
|
169
|
+
? pickLift(item, outcome.hold, waivers, context)
|
|
170
|
+
: { lift: null, faults: [] };
|
|
171
|
+
|
|
172
|
+
faults.push(...waived.faults);
|
|
173
|
+
|
|
174
|
+
if (waived.lift === null) {
|
|
175
|
+
holds.push(outcome.hold);
|
|
176
|
+
} else {
|
|
177
|
+
lifted.push(waived.lift);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { item: item.id, holds, lifted, faults };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface GateOutcome {
|
|
186
|
+
readonly hold: Hold | null;
|
|
187
|
+
readonly liftable: boolean;
|
|
188
|
+
readonly faults: readonly Fault[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function pickGateOutcome(
|
|
192
|
+
item: FrameItem,
|
|
193
|
+
gate: Resolved<'gate'>,
|
|
194
|
+
context: PlanContext,
|
|
195
|
+
): GateOutcome {
|
|
196
|
+
try {
|
|
197
|
+
return gate.open((plugin, params) => ({
|
|
198
|
+
hold: plugin.hold(item, context, params),
|
|
199
|
+
liftable: plugin.liftable,
|
|
200
|
+
faults: [],
|
|
201
|
+
}));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const message = formatFault(error);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
hold: { gate: gate.name, reason: message },
|
|
207
|
+
liftable: false,
|
|
208
|
+
faults: [{ plugin: gate.name, kind: 'gate', item: item.id, error: message }],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface Waived {
|
|
214
|
+
readonly lift: Lift | null;
|
|
215
|
+
readonly faults: readonly Fault[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickLift(
|
|
219
|
+
item: FrameItem,
|
|
220
|
+
hold: Hold,
|
|
221
|
+
waivers: readonly Waiver[],
|
|
222
|
+
context: PlanContext,
|
|
223
|
+
): Waived {
|
|
224
|
+
const faults: Fault[] = [];
|
|
225
|
+
|
|
226
|
+
for (const waiver of waivers.filter((candidate) => candidate.lifts === hold.gate)) {
|
|
227
|
+
try {
|
|
228
|
+
const lift = waiver.lift(item, hold, context);
|
|
229
|
+
|
|
230
|
+
if (lift !== null) {
|
|
231
|
+
return { lift, faults };
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
faults.push({
|
|
235
|
+
plugin: waiver.name,
|
|
236
|
+
kind: 'waiver',
|
|
237
|
+
item: item.id,
|
|
238
|
+
error: formatFault(error),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { lift: null, faults };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface FieldValues {
|
|
247
|
+
readonly item: ItemId;
|
|
248
|
+
readonly values: Readonly<Record<string, readonly string[]>>;
|
|
249
|
+
readonly faults: readonly Fault[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectFieldValues(item: FrameItem, fields: readonly FilterField[]): FieldValues {
|
|
253
|
+
const values: Record<string, readonly string[]> = {};
|
|
254
|
+
const faults: Fault[] = [];
|
|
255
|
+
|
|
256
|
+
for (const field of fields) {
|
|
257
|
+
try {
|
|
258
|
+
const value = field.value(item);
|
|
259
|
+
|
|
260
|
+
if (value !== null) {
|
|
261
|
+
values[field.name] = typeof value === 'string' ? [value] : value;
|
|
262
|
+
}
|
|
263
|
+
} catch (error) {
|
|
264
|
+
faults.push({
|
|
265
|
+
plugin: field.name,
|
|
266
|
+
kind: 'filter-field',
|
|
267
|
+
item: item.id,
|
|
268
|
+
error: formatFault(error),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { item: item.id, values, faults };
|
|
274
|
+
}
|
|
@@ -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 deferConcept: 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 expediteConcept: 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,20 @@
|
|
|
1
|
+
import type { Concept } from '@boxxy/model';
|
|
2
|
+
import { defineSignal } from '@boxxy/model';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
export const poolConcept: Concept = {
|
|
6
|
+
name: 'pool',
|
|
7
|
+
version: 1,
|
|
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
|
+
};
|
package/src/concepts.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Concept } from '@boxxy/model';
|
|
2
|
+
import { deferConcept } from './concepts/defer.ts';
|
|
3
|
+
import { expediteConcept } from './concepts/expedite.ts';
|
|
4
|
+
import { poolConcept } from './concepts/pool.ts';
|
|
5
|
+
|
|
6
|
+
export const concepts: readonly Concept[] = [poolConcept, deferConcept, expediteConcept];
|
package/src/decide.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Decision, Frame, FrameItem, ItemId, Pick, Pool, PoolName } from '@boxxy/model';
|
|
2
|
+
import { compareItemIDs } from './compare-item-ids.ts';
|
|
3
|
+
|
|
4
|
+
interface DecideInput {
|
|
5
|
+
readonly frame: Frame;
|
|
6
|
+
readonly pools: readonly Pool[];
|
|
7
|
+
readonly freeSlots: Readonly<Record<PoolName, number>>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function decide(input: DecideInput): Decision {
|
|
11
|
+
const frame = input.frame;
|
|
12
|
+
|
|
13
|
+
const picked = new Set<ItemId>();
|
|
14
|
+
|
|
15
|
+
const picks: Pick[] = [];
|
|
16
|
+
|
|
17
|
+
for (const pool of input.pools) {
|
|
18
|
+
const free = input.freeSlots[pool.name] ?? 0;
|
|
19
|
+
|
|
20
|
+
const ranked = frame.candidates
|
|
21
|
+
.filter(
|
|
22
|
+
(item) =>
|
|
23
|
+
!picked.has(item.id) &&
|
|
24
|
+
(frame.holds.get(item.id) ?? []).length === 0 &&
|
|
25
|
+
isTakenBy(pool, frame.fields.get(item.id) ?? {}),
|
|
26
|
+
)
|
|
27
|
+
.toSorted((left, right) => compareCandidates(left, right, pool, frame));
|
|
28
|
+
|
|
29
|
+
for (const item of ranked.slice(0, free)) {
|
|
30
|
+
picked.add(item.id);
|
|
31
|
+
picks.push({ item: item.id, phase: item.phase, pool: pool.name });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { picks };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTakenBy(pool: Pool, fields: Readonly<Record<string, readonly string[]>>): boolean {
|
|
39
|
+
return Object.entries(pool.takes).every(([field, accepted]) =>
|
|
40
|
+
(fields[field] ?? []).some((value) => accepted.includes(value)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compareCandidates(left: FrameItem, right: FrameItem, pool: Pool, frame: Frame): number {
|
|
45
|
+
for (const key of pool.order) {
|
|
46
|
+
const byItem = frame.values.get(key);
|
|
47
|
+
const order = compareValues(byItem?.get(left.id), byItem?.get(right.id));
|
|
48
|
+
|
|
49
|
+
if (order !== 0) {
|
|
50
|
+
return order;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return compareItemIDs(left.id, right.id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function compareValues(left: number | undefined, right: number | undefined): number {
|
|
58
|
+
if (left === right) {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (left === undefined) {
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (right === undefined) {
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return left < right ? -1 : 1;
|
|
71
|
+
}
|
|
@@ -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
|
+
'465c5e5376b2de6814f08888cd479249e494d1986802aa6293e2495b88e846d9',
|
|
12
|
+
),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
[
|
|
16
|
+
'expedite',
|
|
17
|
+
{
|
|
18
|
+
ref: 'expedite@1',
|
|
19
|
+
sourceHash: contentHashSchema.parse(
|
|
20
|
+
'672b566d996e4b8d1aeb6e9e768460726473d6f0181a0eedcf09494687679882',
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
'pool',
|
|
26
|
+
{
|
|
27
|
+
ref: 'pool@1',
|
|
28
|
+
sourceHash: contentHashSchema.parse(
|
|
29
|
+
'93bb6fefe3cf95fc8a15bbe9381733427ba34c2118626449c7828929f895b984',
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
]);
|
package/src/index.ts
ADDED
package/src/propagate.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Fault,
|
|
3
|
+
FrameItem,
|
|
4
|
+
ItemId,
|
|
5
|
+
LinkRow,
|
|
6
|
+
PropagationLine,
|
|
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 FrameItem[];
|
|
18
|
+
readonly links: readonly LinkRow[];
|
|
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, links: input.links }));
|
|
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 links: readonly LinkRow[];
|
|
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.links);
|
|
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
|
+
links: readonly LinkRow[],
|
|
116
|
+
): Map<ItemId, ItemId[]> {
|
|
117
|
+
const next = new Map<ItemId, ItemId[]>();
|
|
118
|
+
|
|
119
|
+
for (const link of links.filter((candidate) => candidate.type === line.over)) {
|
|
120
|
+
const [from, to] =
|
|
121
|
+
line.toward === 'source' ? [link.target, link.source] : [link.source, link.target];
|
|
122
|
+
|
|
123
|
+
const neighbours = next.get(from) ?? [];
|
|
124
|
+
|
|
125
|
+
neighbours.push(to);
|
|
126
|
+
next.set(from, neighbours);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { contentHash } from '@boxxy/content-hash';
|
|
2
|
+
import type { Concept, ConceptPin, JsonValue, LoadedRules } from '@boxxy/model';
|
|
3
|
+
import { loadRules } from '@boxxy/rules';
|
|
4
|
+
import invariant from 'tiny-invariant';
|
|
5
|
+
import { concepts as builtInConcepts } from '../concepts.ts';
|
|
6
|
+
|
|
7
|
+
interface MockRulesInput {
|
|
8
|
+
readonly concepts?: readonly Concept[];
|
|
9
|
+
readonly pools?: readonly JsonValue[];
|
|
10
|
+
readonly lead?: readonly JsonValue[];
|
|
11
|
+
readonly propagation?: readonly JsonValue[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildMockLoadedRules(input: MockRulesInput = {}): LoadedRules {
|
|
15
|
+
const concepts = input.concepts ?? builtInConcepts;
|
|
16
|
+
|
|
17
|
+
const file = {
|
|
18
|
+
version: 1,
|
|
19
|
+
concepts: concepts.map((concept) => `${concept.name}@${concept.version}`),
|
|
20
|
+
kinds: {
|
|
21
|
+
'core.issue': {
|
|
22
|
+
pipeline: ['build'],
|
|
23
|
+
maxAttempts: { build: 2 },
|
|
24
|
+
recipes: { build: 'build-v1' },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
pools: input.pools ?? [
|
|
28
|
+
{
|
|
29
|
+
name: 'build',
|
|
30
|
+
slots: 2,
|
|
31
|
+
takes: { phase: 'build', kind: ['core.issue'] },
|
|
32
|
+
order: ['oldest'],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
lead: input.lead ?? ['expedite'],
|
|
36
|
+
propagation: input.propagation ?? [{ value: 'expedite', over: 'blocks', toward: 'source' }],
|
|
37
|
+
recipes: {
|
|
38
|
+
'build-v1': {
|
|
39
|
+
agent: 'claude',
|
|
40
|
+
model: 'example-model',
|
|
41
|
+
effort: 'high',
|
|
42
|
+
prompt: 'prompts/build-v1.md',
|
|
43
|
+
source: 'subscription',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
// only modules/rules may import yaml, and JSON is YAML, so the loader reads this as it is
|
|
51
|
+
const rulesBytes = encoder.encode(JSON.stringify(file));
|
|
52
|
+
|
|
53
|
+
const pins = concepts.map((concept): [string, ConceptPin] => [
|
|
54
|
+
concept.name,
|
|
55
|
+
{
|
|
56
|
+
ref: `${concept.name}@${concept.version}`,
|
|
57
|
+
sourceHash: contentHash(`${concept.name} source`),
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const result = loadRules({
|
|
62
|
+
checkout: {
|
|
63
|
+
commit: '3f2a1c0',
|
|
64
|
+
files: new Map([
|
|
65
|
+
['rules.yaml', rulesBytes],
|
|
66
|
+
['prompts/build-v1.md', encoder.encode('Build {{title}}.\n')],
|
|
67
|
+
]),
|
|
68
|
+
},
|
|
69
|
+
concepts,
|
|
70
|
+
pins: new Map(pins),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
invariant(
|
|
74
|
+
result.kind === 'loaded',
|
|
75
|
+
`the mock rules file failed to load: ${JSON.stringify(result)}`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return result.rules;
|
|
79
|
+
}
|