trekoon 0.4.1 → 0.4.2
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/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -1
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +45 -13
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +42 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +79 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
import { DEPENDENCY_GATED_STATUSES } from "./dependency-rules";
|
|
4
|
+
import {
|
|
5
|
+
type DependencyNodeKind,
|
|
6
|
+
type DependencyRecord,
|
|
7
|
+
DomainError,
|
|
8
|
+
type EpicTreeDetailed,
|
|
9
|
+
type StatusCascadeBlocker,
|
|
10
|
+
type StatusCascadeChange,
|
|
11
|
+
type StatusCascadePlan,
|
|
12
|
+
type StatusCascadeRootKind,
|
|
13
|
+
type StatusCascadeScopeNode,
|
|
14
|
+
type TaskTreeDetailed,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
const CASCADE_BLOCKER_SQLITE_MAX_VARIABLES = 999;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Row shape returned by {@link CascadePlannerReader.loadDependencyTargetStatuses}.
|
|
21
|
+
*
|
|
22
|
+
* Mirrors the columns produced by the chunked
|
|
23
|
+
* `dependencies LEFT JOIN tasks LEFT JOIN subtasks` query that previously
|
|
24
|
+
* lived inline inside `tracker-domain.ts`. The reader implementation is
|
|
25
|
+
* responsible for ordering rows by `(created_at ASC, id ASC)` so blocker
|
|
26
|
+
* sequencing remains stable across chunked fetches.
|
|
27
|
+
*/
|
|
28
|
+
export interface CascadeDependencyTargetStatusRow {
|
|
29
|
+
readonly sourceId: string;
|
|
30
|
+
readonly sourceKind: DependencyNodeKind;
|
|
31
|
+
readonly dependsOnId: string;
|
|
32
|
+
readonly dependsOnKind: DependencyNodeKind;
|
|
33
|
+
/** `null` when the referenced node has been deleted (orphaned edge). */
|
|
34
|
+
readonly dependsOnStatus: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read-only domain projection consumed by the cascade planner.
|
|
39
|
+
*
|
|
40
|
+
* The planner is pure: every database read is funnelled through this
|
|
41
|
+
* interface. The `tracker-domain.ts` adapter supplies the concrete
|
|
42
|
+
* implementation that calls back into existing domain methods / SQL.
|
|
43
|
+
*/
|
|
44
|
+
export interface CascadePlannerReader {
|
|
45
|
+
buildEpicTreeDetailed(epicId: string): EpicTreeDetailed;
|
|
46
|
+
buildTaskTreeDetailed(taskId: string): TaskTreeDetailed;
|
|
47
|
+
listDependenciesBySourceIds(sourceIds: readonly string[]): Map<string, readonly DependencyRecord[]>;
|
|
48
|
+
loadDependencyTargetStatuses(sourceIds: readonly string[]): readonly CascadeDependencyTargetStatusRow[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertNonEmpty(field: string, value: string | undefined | null): string {
|
|
52
|
+
const normalized: string = (value ?? "").trim();
|
|
53
|
+
if (!normalized) {
|
|
54
|
+
throw new DomainError({
|
|
55
|
+
code: "invalid_input",
|
|
56
|
+
message: `${field} must be a non-empty string`,
|
|
57
|
+
details: { field },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pure cascade planner. Given a reader projection of the domain and a
|
|
66
|
+
* cascade root + target status, computes the deterministic plan that
|
|
67
|
+
* `MutationService` consumes.
|
|
68
|
+
*
|
|
69
|
+
* Behaviour MUST remain byte-identical to the previous in-line
|
|
70
|
+
* implementation: scope ordering, change ordering (topological for the
|
|
71
|
+
* `done` target), blocker filtering, and counts feed directly into
|
|
72
|
+
* cascade event payloads.
|
|
73
|
+
*/
|
|
74
|
+
export function planStatusCascade(
|
|
75
|
+
reader: CascadePlannerReader,
|
|
76
|
+
rootKind: StatusCascadeRootKind,
|
|
77
|
+
rootId: string,
|
|
78
|
+
targetStatus: string,
|
|
79
|
+
): StatusCascadePlan {
|
|
80
|
+
const normalizedTargetStatus = assertNonEmpty("status", targetStatus);
|
|
81
|
+
const scope = collectStatusCascadeScope(reader, rootKind, rootId);
|
|
82
|
+
const scopeIdSet = new Set(scope.map((node) => node.id));
|
|
83
|
+
const orderedChanges = orderStatusCascadeChanges(reader, scope, normalizedTargetStatus);
|
|
84
|
+
const changedIds = orderedChanges.map((change) => change.id);
|
|
85
|
+
const changedIdSet = new Set(changedIds);
|
|
86
|
+
const unchangedIds = scope
|
|
87
|
+
.filter((node) => !changedIdSet.has(node.id))
|
|
88
|
+
.map((node) => node.id);
|
|
89
|
+
const blockers = collectStatusCascadeBlockers(
|
|
90
|
+
reader,
|
|
91
|
+
orderedChanges,
|
|
92
|
+
scopeIdSet,
|
|
93
|
+
changedIdSet,
|
|
94
|
+
normalizedTargetStatus,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
rootKind,
|
|
99
|
+
rootId,
|
|
100
|
+
targetStatus: normalizedTargetStatus,
|
|
101
|
+
atomic: true,
|
|
102
|
+
scope,
|
|
103
|
+
orderedChanges,
|
|
104
|
+
changedIds,
|
|
105
|
+
unchangedIds,
|
|
106
|
+
blockers,
|
|
107
|
+
counts: {
|
|
108
|
+
scope: scope.length,
|
|
109
|
+
changed: orderedChanges.length,
|
|
110
|
+
unchanged: unchangedIds.length,
|
|
111
|
+
blockers: blockers.length,
|
|
112
|
+
changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
|
|
113
|
+
changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
|
|
114
|
+
changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function collectStatusCascadeScope(
|
|
120
|
+
reader: CascadePlannerReader,
|
|
121
|
+
rootKind: StatusCascadeRootKind,
|
|
122
|
+
rootId: string,
|
|
123
|
+
): StatusCascadeScopeNode[] {
|
|
124
|
+
if (rootKind === "task") {
|
|
125
|
+
const tree = reader.buildTaskTreeDetailed(rootId);
|
|
126
|
+
return [
|
|
127
|
+
{
|
|
128
|
+
kind: "task",
|
|
129
|
+
id: tree.id,
|
|
130
|
+
parentId: tree.epicId,
|
|
131
|
+
status: tree.status,
|
|
132
|
+
},
|
|
133
|
+
...tree.subtasks.map((subtask) => ({
|
|
134
|
+
kind: "subtask" as const,
|
|
135
|
+
id: subtask.id,
|
|
136
|
+
parentId: subtask.taskId,
|
|
137
|
+
status: subtask.status,
|
|
138
|
+
})),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tree = reader.buildEpicTreeDetailed(rootId);
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
kind: "epic",
|
|
146
|
+
id: tree.id,
|
|
147
|
+
status: tree.status,
|
|
148
|
+
},
|
|
149
|
+
...tree.tasks.flatMap((task) => [
|
|
150
|
+
{
|
|
151
|
+
kind: "task" as const,
|
|
152
|
+
id: task.id,
|
|
153
|
+
parentId: task.epicId,
|
|
154
|
+
status: task.status,
|
|
155
|
+
},
|
|
156
|
+
...task.subtasks.map((subtask) => ({
|
|
157
|
+
kind: "subtask" as const,
|
|
158
|
+
id: subtask.id,
|
|
159
|
+
parentId: subtask.taskId,
|
|
160
|
+
status: subtask.status,
|
|
161
|
+
})),
|
|
162
|
+
]),
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function orderStatusCascadeChanges(
|
|
167
|
+
reader: CascadePlannerReader,
|
|
168
|
+
scope: readonly StatusCascadeScopeNode[],
|
|
169
|
+
targetStatus: string,
|
|
170
|
+
): StatusCascadeChange[] {
|
|
171
|
+
const changes = scope
|
|
172
|
+
.filter((node) => node.status !== targetStatus)
|
|
173
|
+
.map((node) => {
|
|
174
|
+
const change: StatusCascadeChange = {
|
|
175
|
+
kind: node.kind,
|
|
176
|
+
id: node.id,
|
|
177
|
+
previousStatus: node.status,
|
|
178
|
+
nextStatus: targetStatus,
|
|
179
|
+
...(node.parentId === undefined ? {} : { parentId: node.parentId }),
|
|
180
|
+
};
|
|
181
|
+
return change;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (targetStatus !== "done") {
|
|
185
|
+
return changes;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return topologicallyOrderDoneCascadeChanges(reader, changes);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function topologicallyOrderDoneCascadeChanges(
|
|
192
|
+
reader: CascadePlannerReader,
|
|
193
|
+
changes: readonly StatusCascadeChange[],
|
|
194
|
+
): StatusCascadeChange[] {
|
|
195
|
+
const indexById = new Map<string, number>();
|
|
196
|
+
const changeById = new Map<string, StatusCascadeChange>();
|
|
197
|
+
const dependencyTargetsBySource = new Map<string, Set<string>>();
|
|
198
|
+
const dependents = new Map<string, Set<string>>();
|
|
199
|
+
const indegree = new Map<string, number>();
|
|
200
|
+
const dependencyMap = reader.listDependenciesBySourceIds(
|
|
201
|
+
changes.filter((change) => change.kind === "task" || change.kind === "subtask").map((change) => change.id),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
changes.forEach((change, index) => {
|
|
205
|
+
indexById.set(change.id, index);
|
|
206
|
+
changeById.set(change.id, change);
|
|
207
|
+
indegree.set(change.id, 0);
|
|
208
|
+
|
|
209
|
+
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const dependencyTargets = new Set((dependencyMap.get(change.id) ?? []).map((dependency) => dependency.dependsOnId));
|
|
214
|
+
dependencyTargetsBySource.set(change.id, dependencyTargets);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const addEdge = (fromId: string, toId: string): void => {
|
|
218
|
+
if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const neighbors = dependents.get(fromId) ?? new Set<string>();
|
|
223
|
+
if (neighbors.has(toId)) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
neighbors.add(toId);
|
|
228
|
+
dependents.set(fromId, neighbors);
|
|
229
|
+
indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
for (const change of changes) {
|
|
233
|
+
const dependencyTargets = dependencyTargetsBySource.get(change.id);
|
|
234
|
+
|
|
235
|
+
if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
|
|
236
|
+
addEdge(change.id, change.parentId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
|
|
240
|
+
addEdge(change.id, change.parentId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const dependencyTargetId of dependencyTargets ?? []) {
|
|
248
|
+
addEdge(dependencyTargetId, change.id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ordered: StatusCascadeChange[] = [];
|
|
253
|
+
const ready = changes
|
|
254
|
+
.filter((change) => (indegree.get(change.id) ?? 0) === 0)
|
|
255
|
+
.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
|
|
256
|
+
|
|
257
|
+
// Kahn-style topo sort. We re-sort `ready` after every push so the
|
|
258
|
+
// iteration is deterministic on the original change index, but that
|
|
259
|
+
// turns the loop into O(n^2 log n) in the worst case (System Hardening
|
|
260
|
+
// 0.4.2, finding 34). Cascades currently fan out to at most a few dozen
|
|
261
|
+
// entities so this is a non-issue in practice; keep the simple
|
|
262
|
+
// array-based queue for readability.
|
|
263
|
+
// TODO(perf): swap `ready` for a min-heap keyed on `indexById` if a
|
|
264
|
+
// future benchmark shows cascades on large epics (>1k changes) hot in
|
|
265
|
+
// a profile.
|
|
266
|
+
while (ready.length > 0) {
|
|
267
|
+
const next = ready.shift();
|
|
268
|
+
if (next === undefined) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
ordered.push(next);
|
|
273
|
+
for (const dependentId of dependents.get(next.id) ?? []) {
|
|
274
|
+
const remaining = (indegree.get(dependentId) ?? 0) - 1;
|
|
275
|
+
indegree.set(dependentId, remaining);
|
|
276
|
+
if (remaining !== 0) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const dependent = changeById.get(dependentId);
|
|
281
|
+
if (dependent === undefined) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
ready.push(dependent);
|
|
286
|
+
ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (ordered.length !== changes.length) {
|
|
291
|
+
throw new DomainError({
|
|
292
|
+
code: "invalid_dependency",
|
|
293
|
+
message: "unable to determine dependency-safe cascade order",
|
|
294
|
+
details: {
|
|
295
|
+
changedIds: changes.map((change) => change.id),
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return ordered;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function collectStatusCascadeBlockers(
|
|
304
|
+
reader: CascadePlannerReader,
|
|
305
|
+
changes: readonly StatusCascadeChange[],
|
|
306
|
+
scopeIdSet: ReadonlySet<string>,
|
|
307
|
+
changedIdSet: ReadonlySet<string>,
|
|
308
|
+
targetStatus: string,
|
|
309
|
+
): StatusCascadeBlocker[] {
|
|
310
|
+
if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Collect all dependency-eligible change IDs upfront.
|
|
315
|
+
const eligibleIds: string[] = [];
|
|
316
|
+
for (const change of changes) {
|
|
317
|
+
if (change.kind === "task" || change.kind === "subtask") {
|
|
318
|
+
eligibleIds.push(change.id);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (eligibleIds.length === 0) {
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const rows = reader.loadDependencyTargetStatuses(eligibleIds);
|
|
327
|
+
const blockers: StatusCascadeBlocker[] = [];
|
|
328
|
+
|
|
329
|
+
for (const row of rows) {
|
|
330
|
+
// Skip orphaned dependency rows where the referenced node no longer exists.
|
|
331
|
+
if (row.dependsOnStatus === null) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const inScope = scopeIdSet.has(row.dependsOnId);
|
|
336
|
+
const willCascade = targetStatus === "done" && changedIdSet.has(row.dependsOnId);
|
|
337
|
+
if (row.dependsOnStatus === "done" || willCascade) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
blockers.push({
|
|
342
|
+
sourceId: row.sourceId,
|
|
343
|
+
sourceKind: row.sourceKind,
|
|
344
|
+
dependsOnId: row.dependsOnId,
|
|
345
|
+
dependsOnKind: row.dependsOnKind,
|
|
346
|
+
dependsOnStatus: row.dependsOnStatus,
|
|
347
|
+
inScope,
|
|
348
|
+
willCascade,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return blockers.sort(
|
|
353
|
+
(left, right) =>
|
|
354
|
+
left.sourceId.localeCompare(right.sourceId) ||
|
|
355
|
+
left.dependsOnId.localeCompare(right.dependsOnId) ||
|
|
356
|
+
left.dependsOnKind.localeCompare(right.dependsOnKind),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Default SQL implementation of {@link CascadePlannerReader.loadDependencyTargetStatuses}.
|
|
362
|
+
*
|
|
363
|
+
* Issues chunked `WHERE source_id IN (...)` joins so the `?` parameter count
|
|
364
|
+
* never exceeds SQLite's binding cap, preserving the deterministic
|
|
365
|
+
* `(created_at ASC, id ASC)` row order the blocker pass relies on.
|
|
366
|
+
*/
|
|
367
|
+
export function loadCascadeDependencyTargetStatuses(
|
|
368
|
+
db: Database,
|
|
369
|
+
sourceIds: readonly string[],
|
|
370
|
+
): readonly CascadeDependencyTargetStatusRow[] {
|
|
371
|
+
if (sourceIds.length === 0) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
type DepStatusRow = {
|
|
376
|
+
source_id: string;
|
|
377
|
+
source_kind: DependencyNodeKind;
|
|
378
|
+
depends_on_id: string;
|
|
379
|
+
depends_on_kind: DependencyNodeKind;
|
|
380
|
+
dep_status: string | null;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const collected: CascadeDependencyTargetStatusRow[] = [];
|
|
384
|
+
|
|
385
|
+
for (let offset = 0; offset < sourceIds.length; offset += CASCADE_BLOCKER_SQLITE_MAX_VARIABLES) {
|
|
386
|
+
const chunkIds = sourceIds.slice(offset, offset + CASCADE_BLOCKER_SQLITE_MAX_VARIABLES);
|
|
387
|
+
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
388
|
+
const rows = db
|
|
389
|
+
.query(
|
|
390
|
+
`SELECT d.source_id, d.source_kind, d.depends_on_id, d.depends_on_kind,
|
|
391
|
+
COALESCE(t.status, s.status) AS dep_status
|
|
392
|
+
FROM dependencies d
|
|
393
|
+
LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
|
|
394
|
+
LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
|
|
395
|
+
WHERE d.source_id IN (${inPlaceholders})
|
|
396
|
+
ORDER BY d.created_at ASC, d.id ASC;`,
|
|
397
|
+
)
|
|
398
|
+
.all(...chunkIds) as DepStatusRow[];
|
|
399
|
+
|
|
400
|
+
for (const row of rows) {
|
|
401
|
+
collected.push({
|
|
402
|
+
sourceId: row.source_id,
|
|
403
|
+
sourceKind: row.source_kind,
|
|
404
|
+
dependsOnId: row.depends_on_id,
|
|
405
|
+
dependsOnKind: row.depends_on_kind,
|
|
406
|
+
dependsOnStatus: row.dep_status,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return collected;
|
|
412
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statuses for which transitioning a node requires upstream dependencies to be
|
|
3
|
+
* resolved. Originally duplicated as a private const in both `cascade-planner`
|
|
4
|
+
* and `tracker-domain`; centralized here so a single edit updates every gating
|
|
5
|
+
* site at once.
|
|
6
|
+
*
|
|
7
|
+
* If you add a new gated status (e.g. another forward-progress state), add it
|
|
8
|
+
* here only — do NOT redeclare this Set in another module. The cr-expert audit
|
|
9
|
+
* called out the duplication; keeping a single source-of-truth prevents future
|
|
10
|
+
* drift between gating callsites.
|
|
11
|
+
*/
|
|
12
|
+
export const DEPENDENCY_GATED_STATUSES: ReadonlySet<string> = new Set<string>([
|
|
13
|
+
"in_progress",
|
|
14
|
+
"done",
|
|
15
|
+
]);
|