@h-rig/core 0.0.6-alpha.132 → 0.0.6-alpha.134
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/dist/src/dependencyGraph.d.ts +43 -0
- package/dist/src/dependencyGraph.js +703 -0
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +811 -4
- package/dist/src/rigSelectors.d.ts +1 -1
- package/dist/src/rollups.d.ts +6 -0
- package/dist/src/rollups.js +377 -0
- package/dist/src/stageResolve.d.ts +84 -0
- package/dist/src/stageResolve.js +375 -0
- package/dist/src/taskGraph.d.ts +24 -1
- package/dist/src/taskGraph.js +159 -2
- package/dist/src/taskGraphLayout.d.ts +1 -0
- package/dist/src/taskGraphLayout.js +5 -1
- package/dist/src/taskScore.d.ts +17 -0
- package/dist/src/taskScore.js +49 -0
- package/package.json +22 -2
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/core/src/stageResolve.ts
|
|
3
|
+
class PipelineUnresolvableError extends Error {
|
|
4
|
+
cycles;
|
|
5
|
+
contributors;
|
|
6
|
+
constructor(message, cycles, contributors) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "PipelineUnresolvableError";
|
|
9
|
+
this.cycles = cycles;
|
|
10
|
+
this.contributors = contributors;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function uniqueSorted(values) {
|
|
14
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
|
|
15
|
+
}
|
|
16
|
+
function wrapperForMutation(mutation) {
|
|
17
|
+
return "wrapper" in mutation ? mutation.wrapper : mutation.around;
|
|
18
|
+
}
|
|
19
|
+
function contributorOf(mutation) {
|
|
20
|
+
if (mutation.contributedBy?.trim())
|
|
21
|
+
return mutation.contributedBy;
|
|
22
|
+
if (mutation.op === "wrap") {
|
|
23
|
+
const wrapper = wrapperForMutation(mutation);
|
|
24
|
+
if (wrapper.id?.trim())
|
|
25
|
+
return wrapper.id;
|
|
26
|
+
}
|
|
27
|
+
return "anonymous";
|
|
28
|
+
}
|
|
29
|
+
function stableMutationCompare(left, right) {
|
|
30
|
+
const leftTarget = left.op === "insert" ? left.stage.id : left.id;
|
|
31
|
+
const rightTarget = right.op === "insert" ? right.stage.id : right.id;
|
|
32
|
+
const targetDelta = leftTarget.localeCompare(rightTarget);
|
|
33
|
+
if (targetDelta !== 0)
|
|
34
|
+
return targetDelta;
|
|
35
|
+
return contributorOf(left).localeCompare(contributorOf(right));
|
|
36
|
+
}
|
|
37
|
+
function ensureUniqueStageIds(stages) {
|
|
38
|
+
const seen = new Set;
|
|
39
|
+
for (const stage of stages) {
|
|
40
|
+
if (seen.has(stage.id)) {
|
|
41
|
+
throw new PipelineUnresolvableError(`Duplicate stage id: ${stage.id}`, [], []);
|
|
42
|
+
}
|
|
43
|
+
seen.add(stage.id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function assertNoDuplicateMutationTargets(mutations, op) {
|
|
47
|
+
const seen = new Map;
|
|
48
|
+
for (const mutation of mutations.filter((entry) => entry.op === op)) {
|
|
49
|
+
const id = mutation.op === "insert" ? mutation.stage.id : mutation.id;
|
|
50
|
+
const previous = seen.get(id);
|
|
51
|
+
if (previous) {
|
|
52
|
+
throw new PipelineUnresolvableError(`Duplicate ${op} mutation for stage ${id}: ${previous}, ${contributorOf(mutation)}`, [], uniqueSorted([previous, contributorOf(mutation)]));
|
|
53
|
+
}
|
|
54
|
+
seen.set(id, contributorOf(mutation));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function hasProtectedGrant(grants, stageId, pluginId, op) {
|
|
58
|
+
return grants.some((grant) => grant.stageId === stageId && grant.pluginId === pluginId && (grant.op === undefined || grant.op === op));
|
|
59
|
+
}
|
|
60
|
+
function mergeAnchors(current, incoming) {
|
|
61
|
+
return uniqueSorted([...current, ...incoming ?? []]);
|
|
62
|
+
}
|
|
63
|
+
function stagePriority(stage) {
|
|
64
|
+
return typeof stage.priority === "number" && Number.isFinite(stage.priority) ? stage.priority : 0;
|
|
65
|
+
}
|
|
66
|
+
function readyCompare(states) {
|
|
67
|
+
return (left, right) => {
|
|
68
|
+
const leftState = states.get(left);
|
|
69
|
+
const rightState = states.get(right);
|
|
70
|
+
const priorityDelta = stagePriority(rightState?.stage ?? { id: right }) - stagePriority(leftState?.stage ?? { id: left });
|
|
71
|
+
if (priorityDelta !== 0)
|
|
72
|
+
return priorityDelta;
|
|
73
|
+
if (leftState?.baseIndex !== null && rightState?.baseIndex !== null && leftState?.baseIndex !== rightState?.baseIndex) {
|
|
74
|
+
return (leftState?.baseIndex ?? 0) - (rightState?.baseIndex ?? 0);
|
|
75
|
+
}
|
|
76
|
+
if (leftState?.baseIndex !== null && rightState?.baseIndex === null)
|
|
77
|
+
return -1;
|
|
78
|
+
if (leftState?.baseIndex === null && rightState?.baseIndex !== null)
|
|
79
|
+
return 1;
|
|
80
|
+
return left.localeCompare(right);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function findCycles(nodes, edges) {
|
|
84
|
+
const adjacency = new Map;
|
|
85
|
+
for (const node of nodes)
|
|
86
|
+
adjacency.set(node, []);
|
|
87
|
+
for (const edge of edges)
|
|
88
|
+
adjacency.get(edge.from)?.push(edge.to);
|
|
89
|
+
for (const targets of adjacency.values())
|
|
90
|
+
targets.sort((left, right) => left.localeCompare(right));
|
|
91
|
+
let nextIndex = 0;
|
|
92
|
+
const indexByNode = new Map;
|
|
93
|
+
const lowByNode = new Map;
|
|
94
|
+
const stack = [];
|
|
95
|
+
const onStack = new Set;
|
|
96
|
+
const cycles = [];
|
|
97
|
+
const visit = (node) => {
|
|
98
|
+
indexByNode.set(node, nextIndex);
|
|
99
|
+
lowByNode.set(node, nextIndex);
|
|
100
|
+
nextIndex += 1;
|
|
101
|
+
stack.push(node);
|
|
102
|
+
onStack.add(node);
|
|
103
|
+
for (const target of adjacency.get(node) ?? []) {
|
|
104
|
+
if (!indexByNode.has(target)) {
|
|
105
|
+
visit(target);
|
|
106
|
+
lowByNode.set(node, Math.min(lowByNode.get(node) ?? 0, lowByNode.get(target) ?? 0));
|
|
107
|
+
} else if (onStack.has(target)) {
|
|
108
|
+
lowByNode.set(node, Math.min(lowByNode.get(node) ?? 0, indexByNode.get(target) ?? 0));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (lowByNode.get(node) !== indexByNode.get(node))
|
|
112
|
+
return;
|
|
113
|
+
const component = [];
|
|
114
|
+
let current;
|
|
115
|
+
do {
|
|
116
|
+
current = stack.pop();
|
|
117
|
+
if (current) {
|
|
118
|
+
onStack.delete(current);
|
|
119
|
+
component.push(current);
|
|
120
|
+
}
|
|
121
|
+
} while (current && current !== node);
|
|
122
|
+
const hasSelfLoop = edges.some((edge) => edge.from === node && edge.to === node);
|
|
123
|
+
if (component.length > 1 || hasSelfLoop) {
|
|
124
|
+
cycles.push(component.sort((left, right) => left.localeCompare(right)));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
for (const node of [...nodes].sort((left, right) => left.localeCompare(right))) {
|
|
128
|
+
if (!indexByNode.has(node))
|
|
129
|
+
visit(node);
|
|
130
|
+
}
|
|
131
|
+
return cycles.sort((left, right) => left.join("\x00").localeCompare(right.join("\x00")));
|
|
132
|
+
}
|
|
133
|
+
function topologicalOrder(states, edges) {
|
|
134
|
+
const nodes = [...states.keys()];
|
|
135
|
+
const outgoing = new Map;
|
|
136
|
+
const indegree = new Map;
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
outgoing.set(node, []);
|
|
139
|
+
indegree.set(node, 0);
|
|
140
|
+
}
|
|
141
|
+
for (const edge of edges) {
|
|
142
|
+
outgoing.get(edge.from)?.push(edge.to);
|
|
143
|
+
indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
for (const targets of outgoing.values())
|
|
146
|
+
targets.sort((left, right) => left.localeCompare(right));
|
|
147
|
+
const compare = readyCompare(states);
|
|
148
|
+
const ready = nodes.filter((node) => (indegree.get(node) ?? 0) === 0).sort(compare);
|
|
149
|
+
const order = [];
|
|
150
|
+
while (ready.length > 0) {
|
|
151
|
+
const node = ready.shift();
|
|
152
|
+
if (!node)
|
|
153
|
+
break;
|
|
154
|
+
order.push(node);
|
|
155
|
+
for (const target of outgoing.get(node) ?? []) {
|
|
156
|
+
const next = (indegree.get(target) ?? 0) - 1;
|
|
157
|
+
indegree.set(target, next);
|
|
158
|
+
if (next === 0) {
|
|
159
|
+
ready.push(target);
|
|
160
|
+
ready.sort(compare);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (order.length === nodes.length)
|
|
165
|
+
return order;
|
|
166
|
+
const cycles = findCycles(nodes, edges);
|
|
167
|
+
const cycleMembers = new Set(cycles.flat());
|
|
168
|
+
const contributors = uniqueSorted(edges.filter((edge) => cycleMembers.has(edge.from) && cycleMembers.has(edge.to)).flatMap((edge) => edge.contributors));
|
|
169
|
+
throw new PipelineUnresolvableError(`Stage pipeline has unresolved cycle: ${cycles.map((cycle) => cycle.join(" -> ")).join("; ")}`, cycles, contributors);
|
|
170
|
+
}
|
|
171
|
+
function composeStageWrappers(state) {
|
|
172
|
+
const wrappers = state.wrappers.toSorted((left, right) => {
|
|
173
|
+
const priorityDelta = (right.wrapper.priority ?? 0) - (left.wrapper.priority ?? 0);
|
|
174
|
+
if (priorityDelta !== 0)
|
|
175
|
+
return priorityDelta;
|
|
176
|
+
return left.contributedBy.localeCompare(right.contributedBy);
|
|
177
|
+
});
|
|
178
|
+
let stage = state.stage;
|
|
179
|
+
for (const wrapper of wrappers.toReversed()) {
|
|
180
|
+
if (wrapper.wrapper.apply)
|
|
181
|
+
stage = wrapper.wrapper.apply(stage);
|
|
182
|
+
}
|
|
183
|
+
return stage;
|
|
184
|
+
}
|
|
185
|
+
function resolveStagePipeline(input) {
|
|
186
|
+
ensureUniqueStageIds(input.defaultStages);
|
|
187
|
+
const mutations = [...input.mutations ?? []];
|
|
188
|
+
assertNoDuplicateMutationTargets(mutations, "insert");
|
|
189
|
+
assertNoDuplicateMutationTargets(mutations, "replace");
|
|
190
|
+
const grants = input.protectedStageGrants ?? [];
|
|
191
|
+
const states = new Map;
|
|
192
|
+
const removedStates = new Map;
|
|
193
|
+
for (const [index, stage] of input.defaultStages.entries()) {
|
|
194
|
+
states.set(stage.id, {
|
|
195
|
+
stage,
|
|
196
|
+
before: [...stage.before ?? []],
|
|
197
|
+
after: [...stage.after ?? []],
|
|
198
|
+
baseIndex: index,
|
|
199
|
+
contributedBy: "default",
|
|
200
|
+
wrappers: [],
|
|
201
|
+
droppedAnchors: [],
|
|
202
|
+
isProtected: stage.protected === true
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
for (const mutation of mutations.filter((entry) => entry.op === "remove").sort(stableMutationCompare)) {
|
|
206
|
+
const state = states.get(mutation.id);
|
|
207
|
+
const contributor = contributorOf(mutation);
|
|
208
|
+
if (!state)
|
|
209
|
+
continue;
|
|
210
|
+
if (state.isProtected && !hasProtectedGrant(grants, mutation.id, contributor, "remove")) {
|
|
211
|
+
throw new PipelineUnresolvableError(`Protected stage ${mutation.id} cannot be removed by ${contributor} without an explicit grant`, [], [contributor]);
|
|
212
|
+
}
|
|
213
|
+
const removedState = {
|
|
214
|
+
...state,
|
|
215
|
+
removedBy: contributor,
|
|
216
|
+
...state.isProtected ? { grantUsedBy: contributor } : {}
|
|
217
|
+
};
|
|
218
|
+
removedStates.set(mutation.id, removedState);
|
|
219
|
+
states.delete(mutation.id);
|
|
220
|
+
}
|
|
221
|
+
for (const mutation of mutations.filter((entry) => entry.op === "replace").sort(stableMutationCompare)) {
|
|
222
|
+
const state = states.get(mutation.id);
|
|
223
|
+
const contributor = contributorOf(mutation);
|
|
224
|
+
if (!state)
|
|
225
|
+
continue;
|
|
226
|
+
if (state.isProtected && !hasProtectedGrant(grants, mutation.id, contributor, "replace")) {
|
|
227
|
+
throw new PipelineUnresolvableError(`Protected stage ${mutation.id} cannot be replaced by ${contributor} without an explicit grant`, [], [contributor]);
|
|
228
|
+
}
|
|
229
|
+
const replacement = { ...mutation.stage, id: mutation.id };
|
|
230
|
+
states.set(mutation.id, {
|
|
231
|
+
...state,
|
|
232
|
+
stage: replacement,
|
|
233
|
+
before: mutation.stage.before ? [...mutation.stage.before] : state.before,
|
|
234
|
+
after: mutation.stage.after ? [...mutation.stage.after] : state.after,
|
|
235
|
+
replacedBy: contributor,
|
|
236
|
+
contributedBy: state.contributedBy,
|
|
237
|
+
isProtected: mutation.stage.protected ?? state.isProtected,
|
|
238
|
+
...state.isProtected ? { grantUsedBy: contributor } : {}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
for (const mutation of mutations.filter((entry) => entry.op === "insert").sort(stableMutationCompare)) {
|
|
242
|
+
const contributor = contributorOf(mutation);
|
|
243
|
+
if (states.has(mutation.stage.id)) {
|
|
244
|
+
throw new PipelineUnresolvableError(`Inserted stage ${mutation.stage.id} conflicts with an existing stage`, [], [contributor]);
|
|
245
|
+
}
|
|
246
|
+
states.set(mutation.stage.id, {
|
|
247
|
+
stage: mutation.stage,
|
|
248
|
+
before: [...mutation.stage.before ?? []],
|
|
249
|
+
after: [...mutation.stage.after ?? []],
|
|
250
|
+
baseIndex: null,
|
|
251
|
+
contributedBy: contributor,
|
|
252
|
+
wrappers: [],
|
|
253
|
+
droppedAnchors: [],
|
|
254
|
+
isProtected: mutation.stage.protected === true
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
for (const mutation of mutations.filter((entry) => entry.op === "reorder").sort(stableMutationCompare)) {
|
|
258
|
+
const state = states.get(mutation.id);
|
|
259
|
+
if (!state)
|
|
260
|
+
continue;
|
|
261
|
+
states.set(mutation.id, {
|
|
262
|
+
...state,
|
|
263
|
+
before: mergeAnchors(state.before, mutation.before),
|
|
264
|
+
after: mergeAnchors(state.after, mutation.after)
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
for (const mutation of mutations.filter((entry) => entry.op === "wrap").sort(stableMutationCompare)) {
|
|
268
|
+
const state = states.get(mutation.id);
|
|
269
|
+
const contributor = contributorOf(mutation);
|
|
270
|
+
const wrapper = wrapperForMutation(mutation);
|
|
271
|
+
if (!state)
|
|
272
|
+
continue;
|
|
273
|
+
states.set(mutation.id, {
|
|
274
|
+
...state,
|
|
275
|
+
wrappers: [...state.wrappers, { contributedBy: contributor, wrapper }]
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const contributorsForAnchor = (stageId, direction, anchor, state) => {
|
|
279
|
+
const contributors = new Set;
|
|
280
|
+
if (state.replacedBy)
|
|
281
|
+
contributors.add(state.replacedBy);
|
|
282
|
+
for (const mutation of mutations) {
|
|
283
|
+
if (mutation.op === "reorder" && mutation.id === stageId && (direction === "before" ? mutation.before : mutation.after)?.includes(anchor)) {
|
|
284
|
+
contributors.add(contributorOf(mutation));
|
|
285
|
+
}
|
|
286
|
+
if (mutation.op === "insert" && mutation.stage.id === stageId && (direction === "before" ? mutation.stage.before : mutation.stage.after)?.includes(anchor)) {
|
|
287
|
+
contributors.add(contributorOf(mutation));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (contributors.size === 0)
|
|
291
|
+
contributors.add(state.contributedBy);
|
|
292
|
+
return uniqueSorted(contributors);
|
|
293
|
+
};
|
|
294
|
+
const edges = [];
|
|
295
|
+
for (const [stageId, state] of states) {
|
|
296
|
+
const before = [];
|
|
297
|
+
const after = [];
|
|
298
|
+
for (const anchor of state.before) {
|
|
299
|
+
if (states.has(anchor))
|
|
300
|
+
before.push(anchor);
|
|
301
|
+
else {
|
|
302
|
+
const removed = removedStates.get(anchor);
|
|
303
|
+
state.droppedAnchors.push({
|
|
304
|
+
stageId,
|
|
305
|
+
anchor,
|
|
306
|
+
direction: "before",
|
|
307
|
+
reason: removed ? "removed" : "missing",
|
|
308
|
+
...removed?.removedBy ? { removedBy: removed.removedBy } : {}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const anchor of state.after) {
|
|
313
|
+
if (states.has(anchor))
|
|
314
|
+
after.push(anchor);
|
|
315
|
+
else {
|
|
316
|
+
const removed = removedStates.get(anchor);
|
|
317
|
+
state.droppedAnchors.push({
|
|
318
|
+
stageId,
|
|
319
|
+
anchor,
|
|
320
|
+
direction: "after",
|
|
321
|
+
reason: removed ? "removed" : "missing",
|
|
322
|
+
...removed?.removedBy ? { removedBy: removed.removedBy } : {}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
state.before = uniqueSorted(before);
|
|
327
|
+
state.after = uniqueSorted(after);
|
|
328
|
+
for (const target of state.before)
|
|
329
|
+
edges.push({ from: stageId, to: target, contributors: contributorsForAnchor(stageId, "before", target, state) });
|
|
330
|
+
for (const source of state.after)
|
|
331
|
+
edges.push({ from: source, to: stageId, contributors: contributorsForAnchor(stageId, "after", source, state) });
|
|
332
|
+
}
|
|
333
|
+
const order = topologicalOrder(states, edges);
|
|
334
|
+
const stages = [];
|
|
335
|
+
const record = [];
|
|
336
|
+
for (const id of order) {
|
|
337
|
+
const state = states.get(id);
|
|
338
|
+
if (!state)
|
|
339
|
+
continue;
|
|
340
|
+
stages.push(composeStageWrappers(state));
|
|
341
|
+
const wrappedBy = state.wrappers.toSorted((left, right) => {
|
|
342
|
+
const priorityDelta = (right.wrapper.priority ?? 0) - (left.wrapper.priority ?? 0);
|
|
343
|
+
if (priorityDelta !== 0)
|
|
344
|
+
return priorityDelta;
|
|
345
|
+
return left.contributedBy.localeCompare(right.contributedBy);
|
|
346
|
+
}).map((wrapper) => wrapper.contributedBy);
|
|
347
|
+
record.push({
|
|
348
|
+
stageId: id,
|
|
349
|
+
contributedBy: state.contributedBy,
|
|
350
|
+
...state.replacedBy ? { replacedBy: state.replacedBy } : {},
|
|
351
|
+
...wrappedBy.length > 0 ? { wrappedBy } : {},
|
|
352
|
+
...state.droppedAnchors.length > 0 ? { droppedAnchors: state.droppedAnchors.toSorted((left, right) => left.anchor.localeCompare(right.anchor)) } : {},
|
|
353
|
+
isProtected: state.isProtected,
|
|
354
|
+
...state.grantUsedBy ? { grantUsedBy: state.grantUsedBy } : {}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
record.push(...[...removedStates.entries()].toSorted((left, right) => {
|
|
358
|
+
const leftIndex = left[1].baseIndex ?? Number.MAX_SAFE_INTEGER;
|
|
359
|
+
const rightIndex = right[1].baseIndex ?? Number.MAX_SAFE_INTEGER;
|
|
360
|
+
if (leftIndex !== rightIndex)
|
|
361
|
+
return leftIndex - rightIndex;
|
|
362
|
+
return left[0].localeCompare(right[0]);
|
|
363
|
+
}).map(([stageId, state]) => ({
|
|
364
|
+
stageId,
|
|
365
|
+
contributedBy: state.contributedBy,
|
|
366
|
+
...state.removedBy ? { removedBy: state.removedBy } : {},
|
|
367
|
+
isProtected: state.isProtected,
|
|
368
|
+
...state.grantUsedBy ? { grantUsedBy: state.grantUsedBy } : {}
|
|
369
|
+
})));
|
|
370
|
+
return { stages, order, record, cycles: [] };
|
|
371
|
+
}
|
|
372
|
+
export {
|
|
373
|
+
resolveStagePipeline,
|
|
374
|
+
PipelineUnresolvableError
|
|
375
|
+
};
|
package/dist/src/taskGraph.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskSummary } from "@rig/contracts";
|
|
2
|
-
export type TaskDependencyProjection = Pick<TaskSummary, "id" | "status" | "priority" | "metadata"> & Partial<Pick<TaskSummary, "externalId" | "sourceIssueId" | "dependencies" | "parentChildDeps" | "createdAt" | "updatedAt">> & {
|
|
2
|
+
export type TaskDependencyProjection = Pick<TaskSummary, "id" | "status" | "priority" | "metadata"> & Partial<Pick<TaskSummary, "externalId" | "sourceIssueId" | "dependencies" | "parentChildDeps" | "createdAt" | "updatedAt" | "role" | "scope" | "validationKeys">> & {
|
|
3
3
|
readonly title?: string | null;
|
|
4
4
|
};
|
|
5
5
|
export type TaskDependencyBadgeKind = "blocked" | "ready" | "dependency";
|
|
@@ -24,8 +24,12 @@ export interface TaskDependencyBadgeSummary {
|
|
|
24
24
|
readonly badges: readonly TaskDependencyBadge[];
|
|
25
25
|
}
|
|
26
26
|
export declare function readTaskMetadataStringList(task: TaskDependencyProjection, key: "dependencies" | "parentChildDeps" | "labels"): string[];
|
|
27
|
+
export declare function readTaskBlockingDependencyRefs(task: TaskDependencyProjection): string[];
|
|
27
28
|
export declare function readTaskDependencyRefs(task: TaskDependencyProjection): string[];
|
|
28
29
|
export declare function readTaskSourceIssueId(task: TaskDependencyProjection): string | null;
|
|
30
|
+
export declare function readTaskScope(task: TaskDependencyProjection): string[];
|
|
31
|
+
export type TaskScopeInput = readonly string[] | TaskDependencyProjection;
|
|
32
|
+
export declare function disjointScope(left: TaskScopeInput, right: TaskScopeInput): boolean;
|
|
29
33
|
export declare function resolveTaskReference(ref: string, tasksById: ReadonlyMap<string, TaskDependencyProjection>, taskIdByExternalRef: ReadonlyMap<string, string>, taskIdBySourceIssueId: ReadonlyMap<string, string>): string | null;
|
|
30
34
|
export declare function buildTaskReferenceIndex<T extends TaskDependencyProjection>(tasks: readonly T[]): {
|
|
31
35
|
readonly tasksById: Map<string, T>;
|
|
@@ -39,3 +43,22 @@ export declare function selectNextReadyTaskByPriority<T extends TaskDependencyPr
|
|
|
39
43
|
readonly excludeTaskIds?: Iterable<string>;
|
|
40
44
|
readonly filter?: (task: T) => boolean;
|
|
41
45
|
}): T | null;
|
|
46
|
+
export type ReadyTaskSelectionMode = "all-ready" | "blocking-only" | "max-unblock";
|
|
47
|
+
export interface RankedReadyTask<T extends TaskDependencyProjection> {
|
|
48
|
+
readonly task: T;
|
|
49
|
+
readonly score: number;
|
|
50
|
+
readonly priority: number;
|
|
51
|
+
readonly unblockCount: number;
|
|
52
|
+
readonly scope: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
export interface RankedReadyTaskOptions<T extends TaskDependencyProjection> {
|
|
55
|
+
readonly excludeTaskIds?: Iterable<string>;
|
|
56
|
+
readonly activeTaskIds?: Iterable<string>;
|
|
57
|
+
readonly filter?: (task: T) => boolean;
|
|
58
|
+
readonly selection?: ReadyTaskSelectionMode;
|
|
59
|
+
readonly requireDisjointScopes?: boolean;
|
|
60
|
+
readonly disjointWithScopes?: Iterable<string>;
|
|
61
|
+
readonly limit?: number;
|
|
62
|
+
}
|
|
63
|
+
export declare function rankReadyTasks<T extends TaskDependencyProjection>(tasks: readonly T[], options?: Omit<RankedReadyTaskOptions<T>, "limit" | "requireDisjointScopes" | "disjointWithScopes">): readonly RankedReadyTask<T>[];
|
|
64
|
+
export declare function selectRankedReadyTasks<T extends TaskDependencyProjection>(tasks: readonly T[], options?: RankedReadyTaskOptions<T>): readonly T[];
|
package/dist/src/taskGraph.js
CHANGED
|
@@ -1,4 +1,49 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
// packages/core/src/taskScore.ts
|
|
3
|
+
var ROLE_WEIGHT = {
|
|
4
|
+
architect: 25,
|
|
5
|
+
extractor: 10
|
|
6
|
+
};
|
|
7
|
+
var CRITICALITY_WEIGHT = {
|
|
8
|
+
core: 20,
|
|
9
|
+
high: 10,
|
|
10
|
+
normal: 0
|
|
11
|
+
};
|
|
12
|
+
function finiteNumber(value, fallback) {
|
|
13
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
14
|
+
}
|
|
15
|
+
function scoreTask(input) {
|
|
16
|
+
const priority = finiteNumber(input.priority, 3);
|
|
17
|
+
const unblockCount = Math.max(0, finiteNumber(input.unblockCount, 0));
|
|
18
|
+
const roleWeight = input.role ? ROLE_WEIGHT[input.role] ?? 0 : 0;
|
|
19
|
+
const criticalityWeight = input.criticality ? CRITICALITY_WEIGHT[input.criticality] ?? 0 : 0;
|
|
20
|
+
const validationWeight = (input.validation ?? []).some((entry) => entry.includes("test-contract") || entry.includes("test-boundary")) ? 8 : 0;
|
|
21
|
+
const queueWeight = finiteNumber(input.queueWeight, 0);
|
|
22
|
+
return unblockCount * 100 + (10 - priority) + roleWeight + criticalityWeight + validationWeight + queueWeight;
|
|
23
|
+
}
|
|
24
|
+
function rankTasks(tasks, scoreInput, idOf) {
|
|
25
|
+
return tasks.map((task) => {
|
|
26
|
+
const input = scoreInput(task);
|
|
27
|
+
return {
|
|
28
|
+
task,
|
|
29
|
+
score: scoreTask(input),
|
|
30
|
+
priority: finiteNumber(input.priority, 3),
|
|
31
|
+
unblockCount: Math.max(0, finiteNumber(input.unblockCount, 0))
|
|
32
|
+
};
|
|
33
|
+
}).toSorted((left, right) => {
|
|
34
|
+
const scoreDelta = right.score - left.score;
|
|
35
|
+
if (scoreDelta !== 0)
|
|
36
|
+
return scoreDelta;
|
|
37
|
+
const unblockDelta = right.unblockCount - left.unblockCount;
|
|
38
|
+
if (unblockDelta !== 0)
|
|
39
|
+
return unblockDelta;
|
|
40
|
+
const priorityDelta = left.priority - right.priority;
|
|
41
|
+
if (priorityDelta !== 0)
|
|
42
|
+
return priorityDelta;
|
|
43
|
+
return idOf(left.task).localeCompare(idOf(right.task));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
2
47
|
// packages/core/src/taskGraph.ts
|
|
3
48
|
function isObjectRecord(value) {
|
|
4
49
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -23,6 +68,9 @@ function readTaskMetadataStringList(task, key) {
|
|
|
23
68
|
}
|
|
24
69
|
return [];
|
|
25
70
|
}
|
|
71
|
+
function readTaskBlockingDependencyRefs(task) {
|
|
72
|
+
return readTaskMetadataStringList(task, "dependencies");
|
|
73
|
+
}
|
|
26
74
|
function readTaskDependencyRefs(task) {
|
|
27
75
|
return unique([
|
|
28
76
|
...readTaskMetadataStringList(task, "dependencies"),
|
|
@@ -40,6 +88,32 @@ function readTaskSourceIssueId(task) {
|
|
|
40
88
|
const rigMetadata = isObjectRecord(metadata?._rig) ? metadata._rig : null;
|
|
41
89
|
return typeof rigMetadata?.sourceIssueId === "string" && rigMetadata.sourceIssueId.length > 0 ? rigMetadata.sourceIssueId : null;
|
|
42
90
|
}
|
|
91
|
+
function readTaskScope(task) {
|
|
92
|
+
const taskRecord = task;
|
|
93
|
+
const topLevel = readStringList(taskRecord.scope);
|
|
94
|
+
if (topLevel.length > 0)
|
|
95
|
+
return unique(topLevel.map((entry) => entry.trim()).filter((entry) => entry.length > 0));
|
|
96
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
97
|
+
const metadataScope = readStringList(metadata?.scope);
|
|
98
|
+
if (metadataScope.length > 0)
|
|
99
|
+
return unique(metadataScope.map((entry) => entry.trim()).filter((entry) => entry.length > 0));
|
|
100
|
+
return unique([
|
|
101
|
+
...readStringList(metadata?.files),
|
|
102
|
+
...readStringList(metadata?.paths)
|
|
103
|
+
].map((entry) => entry.trim()).filter((entry) => entry.length > 0));
|
|
104
|
+
}
|
|
105
|
+
function isScopeList(input) {
|
|
106
|
+
return Array.isArray(input);
|
|
107
|
+
}
|
|
108
|
+
function normalizeScopeInput(input) {
|
|
109
|
+
return isScopeList(input) ? unique(input.map((entry) => entry.trim()).filter((entry) => entry.length > 0)) : readTaskScope(input);
|
|
110
|
+
}
|
|
111
|
+
function disjointScope(left, right) {
|
|
112
|
+
const leftScope = new Set(normalizeScopeInput(left));
|
|
113
|
+
if (leftScope.size === 0)
|
|
114
|
+
return true;
|
|
115
|
+
return normalizeScopeInput(right).every((entry) => !leftScope.has(entry));
|
|
116
|
+
}
|
|
43
117
|
function resolveTaskReference(ref, tasksById, taskIdByExternalRef, taskIdBySourceIssueId) {
|
|
44
118
|
if (tasksById.has(ref))
|
|
45
119
|
return ref;
|
|
@@ -68,7 +142,7 @@ function computeTaskBlockingDepths(tasks) {
|
|
|
68
142
|
if (!task)
|
|
69
143
|
return 0;
|
|
70
144
|
stack.add(taskId);
|
|
71
|
-
const blockers =
|
|
145
|
+
const blockers = readTaskBlockingDependencyRefs(task).map((ref) => resolveTaskReference(ref, tasksById, taskIdByExternalRef, taskIdBySourceIssueId)).filter((ref) => ref !== null && ref !== taskId);
|
|
72
146
|
const depth = blockers.length === 0 ? 0 : Math.max(...blockers.map((blockerId) => visit(blockerId, stack) + 1));
|
|
73
147
|
stack.delete(taskId);
|
|
74
148
|
memo.set(taskId, depth);
|
|
@@ -111,6 +185,39 @@ function isTaskRunnableStatus(status) {
|
|
|
111
185
|
function priorityValue(task) {
|
|
112
186
|
return typeof task.priority === "number" && Number.isFinite(task.priority) ? task.priority : Number.MAX_SAFE_INTEGER;
|
|
113
187
|
}
|
|
188
|
+
function readTaskRole(task) {
|
|
189
|
+
if (typeof task.role === "string" && task.role.trim())
|
|
190
|
+
return task.role.trim();
|
|
191
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
192
|
+
return typeof metadata?.role === "string" && metadata.role.trim() ? metadata.role.trim() : null;
|
|
193
|
+
}
|
|
194
|
+
function readTaskCriticality(task) {
|
|
195
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
196
|
+
return typeof metadata?.criticality === "string" && metadata.criticality.trim() ? metadata.criticality.trim() : null;
|
|
197
|
+
}
|
|
198
|
+
function readTaskValidationKeys(task) {
|
|
199
|
+
const taskRecord = task;
|
|
200
|
+
const topLevel = readStringList(taskRecord.validationKeys);
|
|
201
|
+
if (topLevel.length > 0)
|
|
202
|
+
return topLevel;
|
|
203
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
204
|
+
return readStringList(metadata?.validation);
|
|
205
|
+
}
|
|
206
|
+
function readTaskQueueWeight(task) {
|
|
207
|
+
const metadata = isObjectRecord(task.metadata) ? task.metadata : null;
|
|
208
|
+
const queueWeight = metadata?.queueWeight ?? metadata?.queue_weight;
|
|
209
|
+
return typeof queueWeight === "number" && Number.isFinite(queueWeight) ? queueWeight : 0;
|
|
210
|
+
}
|
|
211
|
+
function scoreInputForTask(task, unblockCount) {
|
|
212
|
+
return {
|
|
213
|
+
priority: task.priority,
|
|
214
|
+
unblockCount,
|
|
215
|
+
role: readTaskRole(task),
|
|
216
|
+
criticality: readTaskCriticality(task),
|
|
217
|
+
validation: readTaskValidationKeys(task),
|
|
218
|
+
queueWeight: readTaskQueueWeight(task)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
114
221
|
function computeTaskDependencyBadges(tasks) {
|
|
115
222
|
const index = buildTaskReferenceIndex(tasks);
|
|
116
223
|
const blockingDepths = computeTaskBlockingDepths(tasks);
|
|
@@ -120,7 +227,7 @@ function computeTaskDependencyBadges(tasks) {
|
|
|
120
227
|
for (const task of tasks) {
|
|
121
228
|
const dependencyIds = [];
|
|
122
229
|
const unresolvedRefs = [];
|
|
123
|
-
for (const ref of
|
|
230
|
+
for (const ref of readTaskBlockingDependencyRefs(task)) {
|
|
124
231
|
const dependencyId = resolveTaskReference(ref, index.tasksById, index.taskIdByExternalRef, index.taskIdBySourceIssueId);
|
|
125
232
|
if (dependencyId && dependencyId !== task.id) {
|
|
126
233
|
dependencyIds.push(dependencyId);
|
|
@@ -207,13 +314,63 @@ function selectNextReadyTaskByPriority(tasks, options = {}) {
|
|
|
207
314
|
});
|
|
208
315
|
return candidates[0] ?? null;
|
|
209
316
|
}
|
|
317
|
+
function rankReadyTasks(tasks, options = {}) {
|
|
318
|
+
const excluded = new Set(options.excludeTaskIds ?? []);
|
|
319
|
+
const activeTaskIds = new Set(options.activeTaskIds ?? []);
|
|
320
|
+
const badges = computeTaskDependencyBadges(tasks);
|
|
321
|
+
const tasksById = new Map(tasks.map((task) => [String(task.id), task]));
|
|
322
|
+
const openBlockCount = (task) => (badges.get(task.id)?.blocks ?? []).filter((blockedTaskId) => {
|
|
323
|
+
const blockedTask = tasksById.get(blockedTaskId);
|
|
324
|
+
return blockedTask ? !isTaskTerminalStatus(blockedTask.status) : false;
|
|
325
|
+
}).length;
|
|
326
|
+
const readyTasks = tasks.filter((task) => !excluded.has(task.id)).filter((task) => !activeTaskIds.has(task.id)).filter((task) => options.filter?.(task) ?? true).filter((task) => badges.get(task.id)?.ready === true);
|
|
327
|
+
const maxUnblockCount = Math.max(0, ...readyTasks.map(openBlockCount));
|
|
328
|
+
const selectedByMode = readyTasks.filter((task) => {
|
|
329
|
+
const unblockCount = openBlockCount(task);
|
|
330
|
+
if (options.selection === "blocking-only")
|
|
331
|
+
return unblockCount > 0;
|
|
332
|
+
if (options.selection === "max-unblock")
|
|
333
|
+
return maxUnblockCount > 0 && unblockCount === maxUnblockCount;
|
|
334
|
+
return true;
|
|
335
|
+
});
|
|
336
|
+
return rankTasks(selectedByMode, (task) => scoreInputForTask(task, openBlockCount(task)), (task) => task.id).map((entry) => ({
|
|
337
|
+
task: entry.task,
|
|
338
|
+
score: entry.score,
|
|
339
|
+
priority: entry.priority,
|
|
340
|
+
unblockCount: entry.unblockCount,
|
|
341
|
+
scope: readTaskScope(entry.task)
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
function selectRankedReadyTasks(tasks, options = {}) {
|
|
345
|
+
const ranked = rankReadyTasks(tasks, options);
|
|
346
|
+
if (options.requireDisjointScopes !== true && !options.disjointWithScopes) {
|
|
347
|
+
return ranked.slice(0, options.limit).map((entry) => entry.task);
|
|
348
|
+
}
|
|
349
|
+
const occupiedScopes = new Set(options.disjointWithScopes ?? []);
|
|
350
|
+
const selected = [];
|
|
351
|
+
for (const entry of ranked) {
|
|
352
|
+
if (entry.scope.some((scope) => occupiedScopes.has(scope)))
|
|
353
|
+
continue;
|
|
354
|
+
selected.push(entry.task);
|
|
355
|
+
for (const scope of entry.scope)
|
|
356
|
+
occupiedScopes.add(scope);
|
|
357
|
+
if (options.limit !== undefined && selected.length >= options.limit)
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
return selected;
|
|
361
|
+
}
|
|
210
362
|
export {
|
|
363
|
+
selectRankedReadyTasks,
|
|
211
364
|
selectNextReadyTaskByPriority,
|
|
212
365
|
resolveTaskReference,
|
|
213
366
|
readTaskSourceIssueId,
|
|
367
|
+
readTaskScope,
|
|
214
368
|
readTaskMetadataStringList,
|
|
215
369
|
readTaskDependencyRefs,
|
|
370
|
+
readTaskBlockingDependencyRefs,
|
|
371
|
+
rankReadyTasks,
|
|
216
372
|
isTaskTerminalStatus,
|
|
373
|
+
disjointScope,
|
|
217
374
|
computeTaskDependencyBadges,
|
|
218
375
|
computeTaskBlockingDepths,
|
|
219
376
|
buildTaskReferenceIndex
|
|
@@ -107,7 +107,10 @@ function isObjectRecord2(value) {
|
|
|
107
107
|
}
|
|
108
108
|
function readIssueType(task) {
|
|
109
109
|
const metadata = isObjectRecord2(task.metadata) ? task.metadata : null;
|
|
110
|
-
|
|
110
|
+
if (typeof metadata?.issueType === "string")
|
|
111
|
+
return metadata.issueType;
|
|
112
|
+
const raw = isObjectRecord2(metadata?.raw) ? metadata.raw : null;
|
|
113
|
+
return typeof raw?.issueType === "string" ? raw.issueType : null;
|
|
111
114
|
}
|
|
112
115
|
function isGraphTask(task) {
|
|
113
116
|
return readIssueType(task) !== "epic";
|
|
@@ -354,6 +357,7 @@ function buildTaskGraphLayout(snapshot, tasks, options) {
|
|
|
354
357
|
const artifactCount = (snapshot?.artifacts ?? []).filter((artifact) => runIds.has(artifact.runId)).length;
|
|
355
358
|
nodes.push({
|
|
356
359
|
id: task.id,
|
|
360
|
+
taskId: task.id,
|
|
357
361
|
task,
|
|
358
362
|
rowKey,
|
|
359
363
|
rowLabel: rowLabelByKey.get(rowKey) ?? rowKey,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TaskCriticality = "core" | "high" | "normal" | string;
|
|
2
|
+
export interface TaskScoreInput {
|
|
3
|
+
readonly priority?: number | null;
|
|
4
|
+
readonly unblockCount?: number | null;
|
|
5
|
+
readonly role?: string | null;
|
|
6
|
+
readonly criticality?: TaskCriticality | null;
|
|
7
|
+
readonly validation?: readonly string[] | null;
|
|
8
|
+
readonly queueWeight?: number | null;
|
|
9
|
+
}
|
|
10
|
+
export interface RankedTask<T> {
|
|
11
|
+
readonly task: T;
|
|
12
|
+
readonly score: number;
|
|
13
|
+
readonly priority: number;
|
|
14
|
+
readonly unblockCount: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function scoreTask(input: TaskScoreInput): number;
|
|
17
|
+
export declare function rankTasks<T>(tasks: readonly T[], scoreInput: (task: T) => TaskScoreInput, idOf: (task: T) => string): readonly RankedTask<T>[];
|