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
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
type CompactEpicExpandResult,
|
|
7
7
|
type CompactDependencyBatchAddResult,
|
|
8
8
|
type CompactDependencySpec,
|
|
9
|
-
type CompactEntityRef,
|
|
10
9
|
type CompactSubtaskBatchCreateResult,
|
|
11
10
|
type CompactSubtaskSpec,
|
|
12
11
|
type CompactTaskBatchCreateResult,
|
|
@@ -21,14 +20,10 @@ import {
|
|
|
21
20
|
type ReverseDependencyNode,
|
|
22
21
|
type SearchEntityMatch,
|
|
23
22
|
type SearchField,
|
|
24
|
-
type SearchFieldMatch,
|
|
25
23
|
type SearchNode,
|
|
26
24
|
type SearchSummary,
|
|
27
|
-
type StatusCascadeBlocker,
|
|
28
|
-
type StatusCascadeChange,
|
|
29
25
|
type StatusCascadePlan,
|
|
30
26
|
type StatusCascadeRootKind,
|
|
31
|
-
type StatusCascadeScopeNode,
|
|
32
27
|
type SubtaskRecord,
|
|
33
28
|
type TaskTreeDetailed,
|
|
34
29
|
type TaskRecord,
|
|
@@ -36,9 +31,22 @@ import {
|
|
|
36
31
|
VALID_TRANSITIONS,
|
|
37
32
|
type ValidStatus,
|
|
38
33
|
} from "./types";
|
|
34
|
+
import { buildMatchSnippet, collectSearchMatches, countMatches, summarizeMatches } from "./search";
|
|
35
|
+
import { loadCascadeDependencyTargetStatuses, planStatusCascade as planStatusCascadeImpl } from "./cascade-planner";
|
|
36
|
+
import { DEPENDENCY_GATED_STATUSES } from "./dependency-rules";
|
|
37
|
+
import {
|
|
38
|
+
type BatchValidationReader,
|
|
39
|
+
type DependencyBatchResolution,
|
|
40
|
+
type DependencyBatchValidationIssue,
|
|
41
|
+
type ResolvedDependencyBatchSpec,
|
|
42
|
+
buildDependencyAdjacency as buildDependencyAdjacencyFn,
|
|
43
|
+
collectDependencyBatchIssues as collectDependencyBatchIssuesFn,
|
|
44
|
+
resolveDependencyBatchSpec as resolveDependencyBatchSpecFn,
|
|
45
|
+
resolveEpicExpandDependencySpecs as resolveEpicExpandDependencySpecsFn,
|
|
46
|
+
resolveEpicExpandSubtaskSpecs as resolveEpicExpandSubtaskSpecsFn,
|
|
47
|
+
} from "./batch-validation";
|
|
39
48
|
|
|
40
49
|
const DEFAULT_STATUS = "todo";
|
|
41
|
-
const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
|
|
42
50
|
const SQLITE_MAX_VARIABLES = 999;
|
|
43
51
|
|
|
44
52
|
interface EpicRow {
|
|
@@ -97,32 +105,6 @@ interface ValidatedSubtaskBatchSpec {
|
|
|
97
105
|
readonly status: string;
|
|
98
106
|
}
|
|
99
107
|
|
|
100
|
-
interface ResolvedDependencyBatchSpec {
|
|
101
|
-
readonly index: number;
|
|
102
|
-
readonly sourceId: string;
|
|
103
|
-
readonly sourceKind: "task" | "subtask";
|
|
104
|
-
readonly dependsOnId: string;
|
|
105
|
-
readonly dependsOnKind: "task" | "subtask";
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
interface DependencyBatchValidationIssue {
|
|
109
|
-
readonly index: number;
|
|
110
|
-
readonly type: "missing_id" | "duplicate" | "cycle";
|
|
111
|
-
readonly sourceId: string;
|
|
112
|
-
readonly dependsOnId: string;
|
|
113
|
-
readonly details: Record<string, unknown>;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
interface DependencyBatchResolution {
|
|
117
|
-
readonly spec?: ResolvedDependencyBatchSpec;
|
|
118
|
-
readonly issues: readonly DependencyBatchValidationIssue[];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface ResolvedCompactEntity {
|
|
122
|
-
readonly id: string;
|
|
123
|
-
readonly kind: "task" | "subtask";
|
|
124
|
-
}
|
|
125
|
-
|
|
126
108
|
interface TaskDeletionPlan {
|
|
127
109
|
readonly subtaskIds: readonly string[];
|
|
128
110
|
readonly touchingDependencies: readonly DependencyRecord[];
|
|
@@ -267,55 +249,6 @@ function mapDependency(row: DependencyRow): DependencyRecord {
|
|
|
267
249
|
};
|
|
268
250
|
}
|
|
269
251
|
|
|
270
|
-
function countMatches(value: string, searchText: string): number {
|
|
271
|
-
if (searchText.length === 0) {
|
|
272
|
-
return 0;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
let count = 0;
|
|
276
|
-
let offset = 0;
|
|
277
|
-
while (offset <= value.length - searchText.length) {
|
|
278
|
-
const nextIndex = value.indexOf(searchText, offset);
|
|
279
|
-
if (nextIndex === -1) {
|
|
280
|
-
return count;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
count += 1;
|
|
284
|
-
offset = nextIndex + searchText.length;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return count;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
|
|
291
|
-
if (searchText.length === 0) {
|
|
292
|
-
return "";
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const matchIndex = value.indexOf(searchText);
|
|
296
|
-
if (matchIndex === -1) {
|
|
297
|
-
return "";
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const start = Math.max(0, matchIndex - contextSize);
|
|
301
|
-
const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
|
|
302
|
-
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
303
|
-
const prefix = start > 0 ? "…" : "";
|
|
304
|
-
const suffix = end < value.length ? "…" : "";
|
|
305
|
-
return `${prefix}${rawSnippet}${suffix}`;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
|
|
309
|
-
return {
|
|
310
|
-
matchedEntities: matches.length,
|
|
311
|
-
matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
|
|
312
|
-
totalMatches: matches.reduce(
|
|
313
|
-
(total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
|
|
314
|
-
0,
|
|
315
|
-
),
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
252
|
export class TrackerDomain {
|
|
320
253
|
readonly #db: Database;
|
|
321
254
|
|
|
@@ -323,7 +256,32 @@ export class TrackerDomain {
|
|
|
323
256
|
this.#db = db;
|
|
324
257
|
}
|
|
325
258
|
|
|
259
|
+
#assertInTransaction(opName: string): void {
|
|
260
|
+
if (!this.#db.inTransaction) {
|
|
261
|
+
throw new DomainError({
|
|
262
|
+
code: "invalid_state",
|
|
263
|
+
message: `${opName} must be called inside a writeTransaction`,
|
|
264
|
+
details: { op: opName },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#makeBatchValidationReader(): BatchValidationReader {
|
|
270
|
+
return {
|
|
271
|
+
getTask: (id) => this.getTask(id),
|
|
272
|
+
getSubtask: (id) => this.getSubtask(id),
|
|
273
|
+
getDependencyByEdge: (sourceId, dependsOnId) => this.#getDependencyByEdge(sourceId, dependsOnId),
|
|
274
|
+
buildDependencyAdjacency: () => {
|
|
275
|
+
const rows = this.#db
|
|
276
|
+
.query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;")
|
|
277
|
+
.all() as Array<{ source_id: string; depends_on_id: string }>;
|
|
278
|
+
return buildDependencyAdjacencyFn(rows);
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
326
283
|
createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
|
|
284
|
+
this.#assertInTransaction("createEpic");
|
|
327
285
|
const now: number = Date.now();
|
|
328
286
|
const id: string = randomUUID();
|
|
329
287
|
const title: string = assertNonEmpty("title", input.title);
|
|
@@ -346,6 +304,53 @@ export class TrackerDomain {
|
|
|
346
304
|
return rows.map(mapEpic);
|
|
347
305
|
}
|
|
348
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Returns the count of all epics without fetching rows.
|
|
309
|
+
*/
|
|
310
|
+
countEpics(): number {
|
|
311
|
+
const row = this.#db.query("SELECT COUNT(*) AS n FROM epics;").get() as { n: number };
|
|
312
|
+
return row.n;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Returns the single "active" epic without fetching all rows:
|
|
317
|
+
* 1. First in_progress epic (any order — there should be at most one).
|
|
318
|
+
* 2. Fallback: most-recently-updated todo epic.
|
|
319
|
+
* 3. Fallback: oldest epic by created_at / id (matches epics[0] from listEpics).
|
|
320
|
+
* 4. null when the table is empty.
|
|
321
|
+
*
|
|
322
|
+
* Note: no index on (status, updated_at) exists on the epics table as of this
|
|
323
|
+
* writing, so the query uses a table scan. For typical epic counts this is
|
|
324
|
+
* negligible; add idx_epics_status_updated_at if it becomes a concern.
|
|
325
|
+
*/
|
|
326
|
+
findActiveEpic(): EpicRecord | null {
|
|
327
|
+
const inProgress = this.#db
|
|
328
|
+
.query(
|
|
329
|
+
"SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'in_progress' LIMIT 1;",
|
|
330
|
+
)
|
|
331
|
+
.get() as EpicRow | null;
|
|
332
|
+
if (inProgress) {
|
|
333
|
+
return mapEpic(inProgress);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const todo = this.#db
|
|
337
|
+
.query(
|
|
338
|
+
"SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
|
|
339
|
+
)
|
|
340
|
+
.get() as EpicRow | null;
|
|
341
|
+
if (todo) {
|
|
342
|
+
return mapEpic(todo);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Fallback: oldest epic regardless of status (mirrors epics[0] from listEpics).
|
|
346
|
+
const oldest = this.#db
|
|
347
|
+
.query(
|
|
348
|
+
"SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
|
|
349
|
+
)
|
|
350
|
+
.get() as EpicRow | null;
|
|
351
|
+
return oldest ? mapEpic(oldest) : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
349
354
|
getEpic(id: string): EpicRecord | null {
|
|
350
355
|
const row = this.#db
|
|
351
356
|
.query("SELECT id, title, description, status, created_at, updated_at FROM epics WHERE id = ?;")
|
|
@@ -370,6 +375,7 @@ export class TrackerDomain {
|
|
|
370
375
|
id: string,
|
|
371
376
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
372
377
|
): EpicRecord {
|
|
378
|
+
this.#assertInTransaction("updateEpic");
|
|
373
379
|
const existing: EpicRecord = this.getEpicOrThrow(id);
|
|
374
380
|
const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
|
|
375
381
|
const nextDescription: string =
|
|
@@ -385,11 +391,13 @@ export class TrackerDomain {
|
|
|
385
391
|
}
|
|
386
392
|
|
|
387
393
|
deleteEpic(id: string): void {
|
|
394
|
+
this.#assertInTransaction("deleteEpic");
|
|
388
395
|
this.getEpicOrThrow(id);
|
|
389
396
|
this.#db.query("DELETE FROM epics WHERE id = ?;").run(id);
|
|
390
397
|
}
|
|
391
398
|
|
|
392
399
|
createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
|
|
400
|
+
this.#assertInTransaction("createTask");
|
|
393
401
|
const now: number = Date.now();
|
|
394
402
|
const id: string = randomUUID();
|
|
395
403
|
const epicId: string = assertNonEmpty("epicId", input.epicId);
|
|
@@ -428,13 +436,7 @@ export class TrackerDomain {
|
|
|
428
436
|
};
|
|
429
437
|
}
|
|
430
438
|
|
|
431
|
-
|
|
432
|
-
throw new DomainError({
|
|
433
|
-
code: "invalid_state",
|
|
434
|
-
message: "createTaskBatch must be called inside a writeTransaction",
|
|
435
|
-
details: { entity: "task" },
|
|
436
|
-
});
|
|
437
|
-
}
|
|
439
|
+
this.#assertInTransaction("createTaskBatch");
|
|
438
440
|
|
|
439
441
|
const TASK_COLS_PER_ROW = 7; // id, epic_id, title, description, status, created_at, updated_at (version is literal 1)
|
|
440
442
|
const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / TASK_COLS_PER_ROW);
|
|
@@ -535,6 +537,7 @@ export class TrackerDomain {
|
|
|
535
537
|
id: string,
|
|
536
538
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
537
539
|
): TaskRecord {
|
|
540
|
+
this.#assertInTransaction("updateTask");
|
|
538
541
|
const existing: TaskRecord = this.getTaskOrThrow(id);
|
|
539
542
|
const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
|
|
540
543
|
const nextDescription: string =
|
|
@@ -552,6 +555,7 @@ export class TrackerDomain {
|
|
|
552
555
|
}
|
|
553
556
|
|
|
554
557
|
deleteTask(id: string): void {
|
|
558
|
+
this.#assertInTransaction("deleteTask");
|
|
555
559
|
const normalizedTaskId: string = assertNonEmpty("id", id);
|
|
556
560
|
this.getTaskOrThrow(normalizedTaskId);
|
|
557
561
|
|
|
@@ -575,6 +579,7 @@ export class TrackerDomain {
|
|
|
575
579
|
createSubtask(
|
|
576
580
|
input: { taskId: string; title: string; description?: string | undefined; status?: string | undefined },
|
|
577
581
|
): SubtaskRecord {
|
|
582
|
+
this.#assertInTransaction("createSubtask");
|
|
578
583
|
const now: number = Date.now();
|
|
579
584
|
const id: string = randomUUID();
|
|
580
585
|
const taskId: string = assertNonEmpty("taskId", input.taskId);
|
|
@@ -623,13 +628,7 @@ export class TrackerDomain {
|
|
|
623
628
|
};
|
|
624
629
|
}
|
|
625
630
|
|
|
626
|
-
|
|
627
|
-
throw new DomainError({
|
|
628
|
-
code: "invalid_state",
|
|
629
|
-
message: "createSubtaskBatch must be called inside a writeTransaction",
|
|
630
|
-
details: { entity: "subtask" },
|
|
631
|
-
});
|
|
632
|
-
}
|
|
631
|
+
this.#assertInTransaction("createSubtaskBatch");
|
|
633
632
|
|
|
634
633
|
const SUBTASK_COLS_PER_ROW = 7; // id, task_id, title, description, status, created_at, updated_at (version is literal 1)
|
|
635
634
|
const WRITE_CHUNK_SIZE: number = Math.floor(SQLITE_MAX_VARIABLES / SUBTASK_COLS_PER_ROW);
|
|
@@ -786,6 +785,7 @@ export class TrackerDomain {
|
|
|
786
785
|
id: string,
|
|
787
786
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
788
787
|
): SubtaskRecord {
|
|
788
|
+
this.#assertInTransaction("updateSubtask");
|
|
789
789
|
const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
|
|
790
790
|
const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
|
|
791
791
|
const nextDescription: string =
|
|
@@ -803,6 +803,7 @@ export class TrackerDomain {
|
|
|
803
803
|
}
|
|
804
804
|
|
|
805
805
|
deleteSubtask(id: string): void {
|
|
806
|
+
this.#assertInTransaction("deleteSubtask");
|
|
806
807
|
this.getSubtaskOrThrow(id);
|
|
807
808
|
this.#db.query("DELETE FROM dependencies WHERE source_id = ? OR depends_on_id = ?;").run(id, id);
|
|
808
809
|
this.#db.query("DELETE FROM subtasks WHERE id = ?;").run(id);
|
|
@@ -941,37 +942,17 @@ export class TrackerDomain {
|
|
|
941
942
|
}
|
|
942
943
|
|
|
943
944
|
planStatusCascade(rootKind: StatusCascadeRootKind, rootId: string, targetStatus: string): StatusCascadePlan {
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
.filter((node) => !changedIdSet.has(node.id))
|
|
952
|
-
.map((node) => node.id);
|
|
953
|
-
const blockers = this.#collectStatusCascadeBlockers(orderedChanges, scopeIdSet, changedIdSet, normalizedTargetStatus);
|
|
954
|
-
|
|
955
|
-
return {
|
|
945
|
+
return planStatusCascadeImpl(
|
|
946
|
+
{
|
|
947
|
+
buildEpicTreeDetailed: (id) => this.buildEpicTreeDetailed(id),
|
|
948
|
+
buildTaskTreeDetailed: (id) => this.buildTaskTreeDetailed(id),
|
|
949
|
+
listDependenciesBySourceIds: (ids) => this.listDependenciesBySourceIds(ids),
|
|
950
|
+
loadDependencyTargetStatuses: (ids) => loadCascadeDependencyTargetStatuses(this.#db, ids),
|
|
951
|
+
},
|
|
956
952
|
rootKind,
|
|
957
953
|
rootId,
|
|
958
|
-
targetStatus
|
|
959
|
-
|
|
960
|
-
scope,
|
|
961
|
-
orderedChanges,
|
|
962
|
-
changedIds,
|
|
963
|
-
unchangedIds,
|
|
964
|
-
blockers,
|
|
965
|
-
counts: {
|
|
966
|
-
scope: scope.length,
|
|
967
|
-
changed: orderedChanges.length,
|
|
968
|
-
unchanged: unchangedIds.length,
|
|
969
|
-
blockers: blockers.length,
|
|
970
|
-
changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
|
|
971
|
-
changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
|
|
972
|
-
changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
|
|
973
|
-
},
|
|
974
|
-
};
|
|
954
|
+
targetStatus,
|
|
955
|
+
);
|
|
975
956
|
}
|
|
976
957
|
|
|
977
958
|
collectEpicSearchScope(epicId: string): readonly SearchNode[] {
|
|
@@ -1076,6 +1057,7 @@ export class TrackerDomain {
|
|
|
1076
1057
|
}
|
|
1077
1058
|
|
|
1078
1059
|
addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
|
|
1060
|
+
this.#assertInTransaction("addDependency");
|
|
1079
1061
|
const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
|
|
1080
1062
|
const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
|
|
1081
1063
|
|
|
@@ -1121,6 +1103,7 @@ export class TrackerDomain {
|
|
|
1121
1103
|
}
|
|
1122
1104
|
|
|
1123
1105
|
addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
|
|
1106
|
+
this.#assertInTransaction("addDependencyBatch");
|
|
1124
1107
|
const resolutions = input.specs.map((spec, index) => this.#resolveDependencyBatchSpec(index, spec));
|
|
1125
1108
|
const resolvedSpecs = resolutions.flatMap((resolution) => (resolution.spec === undefined ? [] : [resolution.spec]));
|
|
1126
1109
|
const issues = resolutions.flatMap((resolution) => resolution.issues).concat(this.#collectDependencyBatchIssues(resolvedSpecs));
|
|
@@ -1207,6 +1190,7 @@ export class TrackerDomain {
|
|
|
1207
1190
|
}
|
|
1208
1191
|
|
|
1209
1192
|
removeDependency(sourceId: string, dependsOnId: string): number {
|
|
1193
|
+
this.#assertInTransaction("removeDependency");
|
|
1210
1194
|
const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
|
|
1211
1195
|
const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
|
|
1212
1196
|
const result = this.#db
|
|
@@ -1300,44 +1284,56 @@ export class TrackerDomain {
|
|
|
1300
1284
|
}
|
|
1301
1285
|
|
|
1302
1286
|
/**
|
|
1303
|
-
* Resolves dependency statuses for multiple tasks using
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1306
|
-
* per
|
|
1287
|
+
* Resolves dependency statuses for multiple tasks using chunked
|
|
1288
|
+
* WHERE source_id IN (?...) queries — the same pattern as
|
|
1289
|
+
* #collectStatusCascadeBlockers. This reduces N queries to
|
|
1290
|
+
* ceil(N/999), eliminating the previous per-ID N+1 loop.
|
|
1307
1291
|
*/
|
|
1308
1292
|
batchResolveDependencyStatuses(
|
|
1309
1293
|
taskIds: readonly string[],
|
|
1310
1294
|
): Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }> {
|
|
1295
|
+
type DepStatusRow = {
|
|
1296
|
+
source_id: string;
|
|
1297
|
+
depends_on_id: string;
|
|
1298
|
+
depends_on_kind: "task" | "subtask";
|
|
1299
|
+
dep_status: string | null;
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1311
1302
|
const result = new Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }>();
|
|
1312
1303
|
|
|
1313
1304
|
if (taskIds.length === 0) {
|
|
1314
1305
|
return result;
|
|
1315
1306
|
}
|
|
1316
1307
|
|
|
1317
|
-
//
|
|
1318
|
-
// a dynamic IN-list into the SQL string. This is consistent with every
|
|
1319
|
-
// other query in TrackerDomain and avoids any placeholder-count confusion.
|
|
1320
|
-
const stmt = this.#db.query(
|
|
1321
|
-
`SELECT d.source_id, d.depends_on_id, d.depends_on_kind, COALESCE(t.status, s.status) AS dep_status
|
|
1322
|
-
FROM dependencies d
|
|
1323
|
-
LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
|
|
1324
|
-
LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
|
|
1325
|
-
WHERE d.source_id = ?
|
|
1326
|
-
ORDER BY d.created_at ASC, d.id ASC;`,
|
|
1327
|
-
);
|
|
1328
|
-
|
|
1308
|
+
// Pre-populate so every requested ID has an entry, even with no deps.
|
|
1329
1309
|
for (const taskId of taskIds) {
|
|
1330
|
-
|
|
1331
|
-
|
|
1310
|
+
result.set(taskId, { totalDependencies: 0, blockers: [] });
|
|
1311
|
+
}
|
|
1332
1312
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1313
|
+
// Batch-fetch all dependency rows with their target statuses using chunked
|
|
1314
|
+
// IN queries with JOINs. ceil(N/999) queries instead of N.
|
|
1315
|
+
for (let offset = 0; offset < taskIds.length; offset += SQLITE_MAX_VARIABLES) {
|
|
1316
|
+
const chunkIds = taskIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
|
|
1317
|
+
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
1318
|
+
|
|
1319
|
+
const rows = this.#db
|
|
1320
|
+
.query(
|
|
1321
|
+
`SELECT d.source_id, d.depends_on_id, d.depends_on_kind,
|
|
1322
|
+
COALESCE(t.status, s.status) AS dep_status
|
|
1323
|
+
FROM dependencies d
|
|
1324
|
+
LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
|
|
1325
|
+
LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
|
|
1326
|
+
WHERE d.source_id IN (${inPlaceholders})
|
|
1327
|
+
ORDER BY d.created_at ASC, d.id ASC;`,
|
|
1328
|
+
)
|
|
1329
|
+
.all(...chunkIds) as DepStatusRow[];
|
|
1339
1330
|
|
|
1340
1331
|
for (const row of rows) {
|
|
1332
|
+
const entry = result.get(row.source_id);
|
|
1333
|
+
if (entry === undefined) {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1341
1337
|
entry.totalDependencies += 1;
|
|
1342
1338
|
|
|
1343
1339
|
// Skip orphaned dependency rows (target deleted).
|
|
@@ -1420,279 +1416,25 @@ export class TrackerDomain {
|
|
|
1420
1416
|
}
|
|
1421
1417
|
|
|
1422
1418
|
#resolveDependencyBatchSpec(index: number, spec: CompactDependencySpec): DependencyBatchResolution {
|
|
1423
|
-
|
|
1424
|
-
const dependsOnResolution = this.#resolveDependencyBatchId(spec.dependsOn, "dependsOn", index);
|
|
1425
|
-
const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
|
|
1426
|
-
const sourceId = sourceResolution.id;
|
|
1427
|
-
const dependsOnId = dependsOnResolution.id;
|
|
1428
|
-
|
|
1429
|
-
if (sourceId === undefined || dependsOnId === undefined) {
|
|
1430
|
-
return {
|
|
1431
|
-
issues,
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
if (sourceId === dependsOnId) {
|
|
1436
|
-
return {
|
|
1437
|
-
issues: [
|
|
1438
|
-
...issues,
|
|
1439
|
-
{
|
|
1440
|
-
index,
|
|
1441
|
-
type: "cycle",
|
|
1442
|
-
sourceId,
|
|
1443
|
-
dependsOnId,
|
|
1444
|
-
details: { sourceId, dependsOnId, reason: "self_reference" },
|
|
1445
|
-
},
|
|
1446
|
-
],
|
|
1447
|
-
};
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
return {
|
|
1451
|
-
spec: {
|
|
1452
|
-
index,
|
|
1453
|
-
sourceId,
|
|
1454
|
-
sourceKind: this.resolveNodeKind(sourceId),
|
|
1455
|
-
dependsOnId,
|
|
1456
|
-
dependsOnKind: this.resolveNodeKind(dependsOnId),
|
|
1457
|
-
},
|
|
1458
|
-
issues,
|
|
1459
|
-
};
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
#resolveDependencyBatchId(
|
|
1463
|
-
reference: CompactEntityRef,
|
|
1464
|
-
field: "source" | "dependsOn",
|
|
1465
|
-
index: number,
|
|
1466
|
-
): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
|
|
1467
|
-
if (reference.kind === "temp_key") {
|
|
1468
|
-
return {
|
|
1469
|
-
issues: [
|
|
1470
|
-
{
|
|
1471
|
-
index,
|
|
1472
|
-
type: "missing_id",
|
|
1473
|
-
sourceId: field === "source" ? `@${reference.tempKey}` : "",
|
|
1474
|
-
dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
|
|
1475
|
-
details: {
|
|
1476
|
-
field,
|
|
1477
|
-
tempKey: reference.tempKey,
|
|
1478
|
-
message: `Unresolved temp key @${reference.tempKey}`,
|
|
1479
|
-
},
|
|
1480
|
-
},
|
|
1481
|
-
],
|
|
1482
|
-
};
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
const id = assertNonEmpty(field === "source" ? "sourceId" : "dependsOnId", reference.id);
|
|
1486
|
-
const task = this.getTask(id);
|
|
1487
|
-
const subtask = this.getSubtask(id);
|
|
1488
|
-
if (!task && !subtask) {
|
|
1489
|
-
return {
|
|
1490
|
-
issues: [
|
|
1491
|
-
{
|
|
1492
|
-
index,
|
|
1493
|
-
type: "missing_id",
|
|
1494
|
-
sourceId: field === "source" ? id : "",
|
|
1495
|
-
dependsOnId: field === "dependsOn" ? id : "",
|
|
1496
|
-
details: {
|
|
1497
|
-
field,
|
|
1498
|
-
id,
|
|
1499
|
-
message: `Node not found: ${id}`,
|
|
1500
|
-
},
|
|
1501
|
-
},
|
|
1502
|
-
],
|
|
1503
|
-
};
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
return { id, issues: [] };
|
|
1419
|
+
return resolveDependencyBatchSpecFn(index, spec, this.#makeBatchValidationReader());
|
|
1507
1420
|
}
|
|
1508
1421
|
|
|
1509
1422
|
#resolveEpicExpandSubtaskSpecs(
|
|
1510
1423
|
specs: readonly CompactSubtaskSpec[],
|
|
1511
1424
|
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
1512
1425
|
): CompactSubtaskSpec[] {
|
|
1513
|
-
return specs
|
|
1514
|
-
const parent = this.#resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent");
|
|
1515
|
-
if (parent.kind !== "task") {
|
|
1516
|
-
throw new DomainError({
|
|
1517
|
-
code: "invalid_input",
|
|
1518
|
-
message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
|
|
1519
|
-
details: {
|
|
1520
|
-
index,
|
|
1521
|
-
field: "parent",
|
|
1522
|
-
kind: parent.kind,
|
|
1523
|
-
id: parent.id,
|
|
1524
|
-
},
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
return {
|
|
1529
|
-
...spec,
|
|
1530
|
-
parent: {
|
|
1531
|
-
kind: "id",
|
|
1532
|
-
id: parent.id,
|
|
1533
|
-
},
|
|
1534
|
-
};
|
|
1535
|
-
});
|
|
1426
|
+
return resolveEpicExpandSubtaskSpecsFn(specs, mappings, this.#makeBatchValidationReader());
|
|
1536
1427
|
}
|
|
1537
1428
|
|
|
1538
1429
|
#resolveEpicExpandDependencySpecs(
|
|
1539
1430
|
specs: readonly CompactDependencySpec[],
|
|
1540
1431
|
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
1541
1432
|
): CompactDependencySpec[] {
|
|
1542
|
-
return specs
|
|
1543
|
-
source: {
|
|
1544
|
-
kind: "id",
|
|
1545
|
-
id: this.#resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source").id,
|
|
1546
|
-
},
|
|
1547
|
-
dependsOn: {
|
|
1548
|
-
kind: "id",
|
|
1549
|
-
id: this.#resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn").id,
|
|
1550
|
-
},
|
|
1551
|
-
}));
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
#resolveEpicExpandEntityRef(
|
|
1555
|
-
reference: CompactEntityRef,
|
|
1556
|
-
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
1557
|
-
option: "subtask" | "dep",
|
|
1558
|
-
index: number,
|
|
1559
|
-
field: "parent" | "source" | "dependsOn",
|
|
1560
|
-
): ResolvedCompactEntity {
|
|
1561
|
-
if (reference.kind === "temp_key") {
|
|
1562
|
-
const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
|
|
1563
|
-
if (mapping === undefined) {
|
|
1564
|
-
throw new DomainError({
|
|
1565
|
-
code: "invalid_input",
|
|
1566
|
-
message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
|
|
1567
|
-
details: {
|
|
1568
|
-
index,
|
|
1569
|
-
field,
|
|
1570
|
-
tempKey: reference.tempKey,
|
|
1571
|
-
option,
|
|
1572
|
-
},
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
return {
|
|
1577
|
-
id: mapping.id,
|
|
1578
|
-
kind: mapping.kind,
|
|
1579
|
-
};
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
const id = assertNonEmpty(field === "parent" ? "taskId" : `${field}Id`, reference.id);
|
|
1583
|
-
return {
|
|
1584
|
-
id,
|
|
1585
|
-
kind: this.resolveNodeKind(id),
|
|
1586
|
-
};
|
|
1433
|
+
return resolveEpicExpandDependencySpecsFn(specs, mappings, this.#makeBatchValidationReader());
|
|
1587
1434
|
}
|
|
1588
1435
|
|
|
1589
1436
|
#collectDependencyBatchIssues(specs: readonly ResolvedDependencyBatchSpec[]): DependencyBatchValidationIssue[] {
|
|
1590
|
-
|
|
1591
|
-
const seenEdges = new Map<string, number>();
|
|
1592
|
-
const adjacency = this.#buildDependencyAdjacency();
|
|
1593
|
-
|
|
1594
|
-
for (const spec of specs) {
|
|
1595
|
-
const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
|
|
1596
|
-
const existingIndex = seenEdges.get(edgeKey);
|
|
1597
|
-
if (existingIndex !== undefined) {
|
|
1598
|
-
issues.push({
|
|
1599
|
-
index: spec.index,
|
|
1600
|
-
type: "duplicate",
|
|
1601
|
-
sourceId: spec.sourceId,
|
|
1602
|
-
dependsOnId: spec.dependsOnId,
|
|
1603
|
-
details: {
|
|
1604
|
-
sourceId: spec.sourceId,
|
|
1605
|
-
dependsOnId: spec.dependsOnId,
|
|
1606
|
-
firstIndex: existingIndex,
|
|
1607
|
-
duplicateIndex: spec.index,
|
|
1608
|
-
duplicateKind: "batch",
|
|
1609
|
-
},
|
|
1610
|
-
});
|
|
1611
|
-
continue;
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
if (this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
|
|
1615
|
-
issues.push({
|
|
1616
|
-
index: spec.index,
|
|
1617
|
-
type: "duplicate",
|
|
1618
|
-
sourceId: spec.sourceId,
|
|
1619
|
-
dependsOnId: spec.dependsOnId,
|
|
1620
|
-
details: {
|
|
1621
|
-
sourceId: spec.sourceId,
|
|
1622
|
-
dependsOnId: spec.dependsOnId,
|
|
1623
|
-
duplicateKind: "existing",
|
|
1624
|
-
},
|
|
1625
|
-
});
|
|
1626
|
-
continue;
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
if (this.#wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
|
|
1630
|
-
issues.push({
|
|
1631
|
-
index: spec.index,
|
|
1632
|
-
type: "cycle",
|
|
1633
|
-
sourceId: spec.sourceId,
|
|
1634
|
-
dependsOnId: spec.dependsOnId,
|
|
1635
|
-
details: {
|
|
1636
|
-
sourceId: spec.sourceId,
|
|
1637
|
-
dependsOnId: spec.dependsOnId,
|
|
1638
|
-
},
|
|
1639
|
-
});
|
|
1640
|
-
continue;
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
|
|
1644
|
-
nextNeighbors.add(spec.dependsOnId);
|
|
1645
|
-
adjacency.set(spec.sourceId, nextNeighbors);
|
|
1646
|
-
seenEdges.set(edgeKey, spec.index);
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
#buildDependencyAdjacency(): Map<string, Set<string>> {
|
|
1653
|
-
const rows = this.#db.query("SELECT source_id, depends_on_id FROM dependencies ORDER BY source_id ASC, depends_on_id ASC;").all() as Array<{
|
|
1654
|
-
source_id: string;
|
|
1655
|
-
depends_on_id: string;
|
|
1656
|
-
}>;
|
|
1657
|
-
const adjacency = new Map<string, Set<string>>();
|
|
1658
|
-
|
|
1659
|
-
for (const row of rows) {
|
|
1660
|
-
const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
|
|
1661
|
-
neighbors.add(row.depends_on_id);
|
|
1662
|
-
adjacency.set(row.source_id, neighbors);
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
return adjacency;
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
#wouldCreateCycleInAdjacency(adjacency: ReadonlyMap<string, ReadonlySet<string>>, sourceId: string, dependsOnId: string): boolean {
|
|
1669
|
-
const visited = new Set<string>();
|
|
1670
|
-
const queue: string[] = [dependsOnId];
|
|
1671
|
-
|
|
1672
|
-
while (queue.length > 0) {
|
|
1673
|
-
const current = queue.shift();
|
|
1674
|
-
if (current === undefined || visited.has(current)) {
|
|
1675
|
-
continue;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
if (current === sourceId) {
|
|
1679
|
-
return true;
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
visited.add(current);
|
|
1683
|
-
const neighbors = adjacency.get(current);
|
|
1684
|
-
if (neighbors === undefined) {
|
|
1685
|
-
continue;
|
|
1686
|
-
}
|
|
1687
|
-
|
|
1688
|
-
for (const neighbor of neighbors) {
|
|
1689
|
-
if (!visited.has(neighbor)) {
|
|
1690
|
-
queue.push(neighbor);
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
return false;
|
|
1437
|
+
return collectDependencyBatchIssuesFn(specs, this.#makeBatchValidationReader());
|
|
1696
1438
|
}
|
|
1697
1439
|
|
|
1698
1440
|
private collectSearchMatches(
|
|
@@ -1700,33 +1442,7 @@ export class TrackerDomain {
|
|
|
1700
1442
|
searchText: string,
|
|
1701
1443
|
fields: readonly SearchField[],
|
|
1702
1444
|
): readonly SearchEntityMatch[] {
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
for (const node of nodes) {
|
|
1706
|
-
const matchedFields: SearchFieldMatch[] = [];
|
|
1707
|
-
for (const field of fields) {
|
|
1708
|
-
const count = countMatches(node[field], searchText);
|
|
1709
|
-
if (count > 0) {
|
|
1710
|
-
matchedFields.push({
|
|
1711
|
-
field,
|
|
1712
|
-
count,
|
|
1713
|
-
snippet: buildMatchSnippet(node[field], searchText),
|
|
1714
|
-
});
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
if (matchedFields.length === 0) {
|
|
1719
|
-
continue;
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
matches.push({
|
|
1723
|
-
kind: node.kind,
|
|
1724
|
-
id: node.id,
|
|
1725
|
-
fields: matchedFields,
|
|
1726
|
-
});
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
return matches;
|
|
1445
|
+
return collectSearchMatches(nodes, searchText, fields);
|
|
1730
1446
|
}
|
|
1731
1447
|
|
|
1732
1448
|
private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
|
|
@@ -1751,253 +1467,7 @@ export class TrackerDomain {
|
|
|
1751
1467
|
return row !== null;
|
|
1752
1468
|
}
|
|
1753
1469
|
|
|
1754
|
-
|
|
1755
|
-
if (rootKind === "task") {
|
|
1756
|
-
const tree = this.buildTaskTreeDetailed(rootId);
|
|
1757
|
-
return [
|
|
1758
|
-
{
|
|
1759
|
-
kind: "task",
|
|
1760
|
-
id: tree.id,
|
|
1761
|
-
parentId: tree.epicId,
|
|
1762
|
-
status: tree.status,
|
|
1763
|
-
},
|
|
1764
|
-
...tree.subtasks.map((subtask) => ({
|
|
1765
|
-
kind: "subtask" as const,
|
|
1766
|
-
id: subtask.id,
|
|
1767
|
-
parentId: subtask.taskId,
|
|
1768
|
-
status: subtask.status,
|
|
1769
|
-
})),
|
|
1770
|
-
];
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
const tree = this.buildEpicTreeDetailed(rootId);
|
|
1774
|
-
return [
|
|
1775
|
-
{
|
|
1776
|
-
kind: "epic",
|
|
1777
|
-
id: tree.id,
|
|
1778
|
-
status: tree.status,
|
|
1779
|
-
},
|
|
1780
|
-
...tree.tasks.flatMap((task) => [
|
|
1781
|
-
{
|
|
1782
|
-
kind: "task" as const,
|
|
1783
|
-
id: task.id,
|
|
1784
|
-
parentId: task.epicId,
|
|
1785
|
-
status: task.status,
|
|
1786
|
-
},
|
|
1787
|
-
...task.subtasks.map((subtask) => ({
|
|
1788
|
-
kind: "subtask" as const,
|
|
1789
|
-
id: subtask.id,
|
|
1790
|
-
parentId: subtask.taskId,
|
|
1791
|
-
status: subtask.status,
|
|
1792
|
-
})),
|
|
1793
|
-
]),
|
|
1794
|
-
];
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
#orderStatusCascadeChanges(scope: readonly StatusCascadeScopeNode[], targetStatus: string): StatusCascadeChange[] {
|
|
1798
|
-
const changes = scope
|
|
1799
|
-
.filter((node) => node.status !== targetStatus)
|
|
1800
|
-
.map((node) => {
|
|
1801
|
-
const change: StatusCascadeChange = {
|
|
1802
|
-
kind: node.kind,
|
|
1803
|
-
id: node.id,
|
|
1804
|
-
previousStatus: node.status,
|
|
1805
|
-
nextStatus: targetStatus,
|
|
1806
|
-
...(node.parentId === undefined ? {} : { parentId: node.parentId }),
|
|
1807
|
-
};
|
|
1808
|
-
return change;
|
|
1809
|
-
});
|
|
1810
|
-
|
|
1811
|
-
if (targetStatus !== "done") {
|
|
1812
|
-
return changes;
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
return this.#topologicallyOrderDoneCascadeChanges(changes);
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
#topologicallyOrderDoneCascadeChanges(changes: readonly StatusCascadeChange[]): StatusCascadeChange[] {
|
|
1819
|
-
const indexById = new Map<string, number>();
|
|
1820
|
-
const changeById = new Map<string, StatusCascadeChange>();
|
|
1821
|
-
const dependencyTargetsBySource = new Map<string, Set<string>>();
|
|
1822
|
-
const dependents = new Map<string, Set<string>>();
|
|
1823
|
-
const indegree = new Map<string, number>();
|
|
1824
|
-
const dependencyMap = this.listDependenciesBySourceIds(
|
|
1825
|
-
changes.filter((change) => change.kind === "task" || change.kind === "subtask").map((change) => change.id),
|
|
1826
|
-
);
|
|
1827
|
-
|
|
1828
|
-
changes.forEach((change, index) => {
|
|
1829
|
-
indexById.set(change.id, index);
|
|
1830
|
-
changeById.set(change.id, change);
|
|
1831
|
-
indegree.set(change.id, 0);
|
|
1832
|
-
|
|
1833
|
-
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
1834
|
-
return;
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
const dependencyTargets = new Set((dependencyMap.get(change.id) ?? []).map((dependency) => dependency.dependsOnId));
|
|
1838
|
-
dependencyTargetsBySource.set(change.id, dependencyTargets);
|
|
1839
|
-
});
|
|
1840
|
-
|
|
1841
|
-
const addEdge = (fromId: string, toId: string): void => {
|
|
1842
|
-
if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
|
|
1843
|
-
return;
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
const neighbors = dependents.get(fromId) ?? new Set<string>();
|
|
1847
|
-
if (neighbors.has(toId)) {
|
|
1848
|
-
return;
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
neighbors.add(toId);
|
|
1852
|
-
dependents.set(fromId, neighbors);
|
|
1853
|
-
indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
|
|
1854
|
-
};
|
|
1855
|
-
|
|
1856
|
-
for (const change of changes) {
|
|
1857
|
-
const dependencyTargets = dependencyTargetsBySource.get(change.id);
|
|
1858
|
-
|
|
1859
|
-
if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
|
|
1860
|
-
addEdge(change.id, change.parentId);
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
|
|
1864
|
-
addEdge(change.id, change.parentId);
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
1868
|
-
continue;
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
for (const dependencyTargetId of dependencyTargets ?? []) {
|
|
1872
|
-
addEdge(dependencyTargetId, change.id);
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
|
|
1876
|
-
const ordered: StatusCascadeChange[] = [];
|
|
1877
|
-
const ready = changes
|
|
1878
|
-
.filter((change) => (indegree.get(change.id) ?? 0) === 0)
|
|
1879
|
-
.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
|
|
1880
|
-
|
|
1881
|
-
while (ready.length > 0) {
|
|
1882
|
-
const next = ready.shift();
|
|
1883
|
-
if (next === undefined) {
|
|
1884
|
-
continue;
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
ordered.push(next);
|
|
1888
|
-
for (const dependentId of dependents.get(next.id) ?? []) {
|
|
1889
|
-
const remaining = (indegree.get(dependentId) ?? 0) - 1;
|
|
1890
|
-
indegree.set(dependentId, remaining);
|
|
1891
|
-
if (remaining !== 0) {
|
|
1892
|
-
continue;
|
|
1893
|
-
}
|
|
1894
|
-
|
|
1895
|
-
const dependent = changeById.get(dependentId);
|
|
1896
|
-
if (dependent === undefined) {
|
|
1897
|
-
continue;
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
ready.push(dependent);
|
|
1901
|
-
ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
if (ordered.length !== changes.length) {
|
|
1906
|
-
throw new DomainError({
|
|
1907
|
-
code: "invalid_dependency",
|
|
1908
|
-
message: "unable to determine dependency-safe cascade order",
|
|
1909
|
-
details: {
|
|
1910
|
-
changedIds: changes.map((change) => change.id),
|
|
1911
|
-
},
|
|
1912
|
-
});
|
|
1913
|
-
}
|
|
1914
|
-
|
|
1915
|
-
return ordered;
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
#collectStatusCascadeBlockers(
|
|
1919
|
-
changes: readonly StatusCascadeChange[],
|
|
1920
|
-
scopeIdSet: ReadonlySet<string>,
|
|
1921
|
-
changedIdSet: ReadonlySet<string>,
|
|
1922
|
-
targetStatus: string,
|
|
1923
|
-
): StatusCascadeBlocker[] {
|
|
1924
|
-
if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
|
|
1925
|
-
return [];
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
// Collect all dependency-eligible change IDs upfront.
|
|
1929
|
-
const eligibleIds: string[] = [];
|
|
1930
|
-
for (const change of changes) {
|
|
1931
|
-
if (change.kind === "task" || change.kind === "subtask") {
|
|
1932
|
-
eligibleIds.push(change.id);
|
|
1933
|
-
}
|
|
1934
|
-
}
|
|
1935
|
-
|
|
1936
|
-
if (eligibleIds.length === 0) {
|
|
1937
|
-
return [];
|
|
1938
|
-
}
|
|
1939
|
-
|
|
1940
|
-
// Batch-fetch all dependency rows with their target statuses using a
|
|
1941
|
-
// chunked IN query with JOINs. This replaces the previous per-ID
|
|
1942
|
-
// prepared statement approach, reducing N queries to ceil(N/999).
|
|
1943
|
-
type DepStatusRow = {
|
|
1944
|
-
source_id: string;
|
|
1945
|
-
source_kind: "task" | "subtask";
|
|
1946
|
-
depends_on_id: string;
|
|
1947
|
-
depends_on_kind: "task" | "subtask";
|
|
1948
|
-
dep_status: string | null;
|
|
1949
|
-
};
|
|
1950
|
-
|
|
1951
|
-
const blockers: StatusCascadeBlocker[] = [];
|
|
1952
|
-
|
|
1953
|
-
for (let offset = 0; offset < eligibleIds.length; offset += SQLITE_MAX_VARIABLES) {
|
|
1954
|
-
const chunkIds = eligibleIds.slice(offset, offset + SQLITE_MAX_VARIABLES);
|
|
1955
|
-
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
1956
|
-
const rows = this.#db
|
|
1957
|
-
.query(
|
|
1958
|
-
`SELECT d.source_id, d.source_kind, d.depends_on_id, d.depends_on_kind,
|
|
1959
|
-
COALESCE(t.status, s.status) AS dep_status
|
|
1960
|
-
FROM dependencies d
|
|
1961
|
-
LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
|
|
1962
|
-
LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
|
|
1963
|
-
WHERE d.source_id IN (${inPlaceholders})
|
|
1964
|
-
ORDER BY d.created_at ASC, d.id ASC;`,
|
|
1965
|
-
)
|
|
1966
|
-
.all(...chunkIds) as DepStatusRow[];
|
|
1967
|
-
|
|
1968
|
-
for (const row of rows) {
|
|
1969
|
-
// Skip orphaned dependency rows where the referenced node no longer exists.
|
|
1970
|
-
if (row.dep_status === null) {
|
|
1971
|
-
continue;
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
const inScope = scopeIdSet.has(row.depends_on_id);
|
|
1975
|
-
const willCascade = targetStatus === "done" && changedIdSet.has(row.depends_on_id);
|
|
1976
|
-
if (row.dep_status === "done" || willCascade) {
|
|
1977
|
-
continue;
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
blockers.push({
|
|
1981
|
-
sourceId: row.source_id,
|
|
1982
|
-
sourceKind: row.source_kind,
|
|
1983
|
-
dependsOnId: row.depends_on_id,
|
|
1984
|
-
dependsOnKind: row.depends_on_kind,
|
|
1985
|
-
dependsOnStatus: row.dep_status,
|
|
1986
|
-
inScope,
|
|
1987
|
-
willCascade,
|
|
1988
|
-
});
|
|
1989
|
-
}
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
return blockers.sort(
|
|
1993
|
-
(left, right) =>
|
|
1994
|
-
left.sourceId.localeCompare(right.sourceId) ||
|
|
1995
|
-
left.dependsOnId.localeCompare(right.dependsOnId) ||
|
|
1996
|
-
left.dependsOnKind.localeCompare(right.dependsOnKind),
|
|
1997
|
-
);
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
private assertNoUnresolvedDependenciesForStatusTransition(
|
|
1470
|
+
assertNoUnresolvedDependenciesForStatusTransition(
|
|
2001
1471
|
id: string,
|
|
2002
1472
|
kind: DependencyNodeKind,
|
|
2003
1473
|
existingStatus: string,
|