trekoon 0.1.9 → 0.2.1
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 +176 -230
- package/README.md +299 -7
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +198 -0
- package/src/commands/dep.ts +197 -25
- package/src/commands/epic.ts +674 -28
- package/src/commands/error-utils.ts +111 -0
- package/src/commands/events.ts +23 -3
- package/src/commands/help.ts +66 -4
- package/src/commands/init.ts +11 -3
- package/src/commands/migrate.ts +11 -4
- package/src/commands/subtask.ts +408 -26
- package/src/commands/sync.ts +7 -1
- package/src/commands/task.ts +381 -26
- package/src/domain/mutation-service.ts +394 -1
- package/src/domain/tracker-domain.ts +674 -0
- package/src/domain/types.ts +107 -0
- package/src/sync/event-writes.ts +21 -1
- package/src/sync/service.ts +42 -0
|
@@ -3,6 +3,14 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
type CompactEpicExpandResult,
|
|
7
|
+
type CompactDependencyBatchAddResult,
|
|
8
|
+
type CompactDependencySpec,
|
|
9
|
+
type CompactEntityRef,
|
|
10
|
+
type CompactSubtaskBatchCreateResult,
|
|
11
|
+
type CompactSubtaskSpec,
|
|
12
|
+
type CompactTaskBatchCreateResult,
|
|
13
|
+
type CompactTaskSpec,
|
|
6
14
|
type DependencyRecord,
|
|
7
15
|
DomainError,
|
|
8
16
|
type EpicTreeDetailed,
|
|
@@ -10,6 +18,11 @@ import {
|
|
|
10
18
|
type EpicTree,
|
|
11
19
|
type NodeKind,
|
|
12
20
|
type ReverseDependencyNode,
|
|
21
|
+
type SearchEntityMatch,
|
|
22
|
+
type SearchField,
|
|
23
|
+
type SearchFieldMatch,
|
|
24
|
+
type SearchNode,
|
|
25
|
+
type SearchSummary,
|
|
13
26
|
type SubtaskRecord,
|
|
14
27
|
type TaskTreeDetailed,
|
|
15
28
|
type TaskRecord,
|
|
@@ -57,6 +70,47 @@ interface UnresolvedDependencyBlocker {
|
|
|
57
70
|
readonly status: string;
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
interface ValidatedTaskBatchSpec {
|
|
74
|
+
readonly tempKey: string;
|
|
75
|
+
readonly title: string;
|
|
76
|
+
readonly description: string;
|
|
77
|
+
readonly status: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ValidatedSubtaskBatchSpec {
|
|
81
|
+
readonly tempKey: string;
|
|
82
|
+
readonly taskId: string;
|
|
83
|
+
readonly title: string;
|
|
84
|
+
readonly description: string;
|
|
85
|
+
readonly status: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ResolvedDependencyBatchSpec {
|
|
89
|
+
readonly index: number;
|
|
90
|
+
readonly sourceId: string;
|
|
91
|
+
readonly sourceKind: "task" | "subtask";
|
|
92
|
+
readonly dependsOnId: string;
|
|
93
|
+
readonly dependsOnKind: "task" | "subtask";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface DependencyBatchValidationIssue {
|
|
97
|
+
readonly index: number;
|
|
98
|
+
readonly type: "missing_id" | "duplicate" | "cycle";
|
|
99
|
+
readonly sourceId: string;
|
|
100
|
+
readonly dependsOnId: string;
|
|
101
|
+
readonly details: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface DependencyBatchResolution {
|
|
105
|
+
readonly spec?: ResolvedDependencyBatchSpec;
|
|
106
|
+
readonly issues: readonly DependencyBatchValidationIssue[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ResolvedCompactEntity {
|
|
110
|
+
readonly id: string;
|
|
111
|
+
readonly kind: "task" | "subtask";
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
function assertNonEmpty(field: string, value: string | undefined | null): string {
|
|
61
115
|
const normalized: string = (value ?? "").trim();
|
|
62
116
|
if (!normalized) {
|
|
@@ -125,6 +179,55 @@ function mapDependency(row: DependencyRow): DependencyRecord {
|
|
|
125
179
|
};
|
|
126
180
|
}
|
|
127
181
|
|
|
182
|
+
function countMatches(value: string, searchText: string): number {
|
|
183
|
+
if (searchText.length === 0) {
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let count = 0;
|
|
188
|
+
let offset = 0;
|
|
189
|
+
while (offset <= value.length - searchText.length) {
|
|
190
|
+
const nextIndex = value.indexOf(searchText, offset);
|
|
191
|
+
if (nextIndex === -1) {
|
|
192
|
+
return count;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
count += 1;
|
|
196
|
+
offset = nextIndex + searchText.length;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return count;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
|
|
203
|
+
if (searchText.length === 0) {
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const matchIndex = value.indexOf(searchText);
|
|
208
|
+
if (matchIndex === -1) {
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const start = Math.max(0, matchIndex - contextSize);
|
|
213
|
+
const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
|
|
214
|
+
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
215
|
+
const prefix = start > 0 ? "…" : "";
|
|
216
|
+
const suffix = end < value.length ? "…" : "";
|
|
217
|
+
return `${prefix}${rawSnippet}${suffix}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
|
|
221
|
+
return {
|
|
222
|
+
matchedEntities: matches.length,
|
|
223
|
+
matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
|
|
224
|
+
totalMatches: matches.reduce(
|
|
225
|
+
(total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
|
|
226
|
+
0,
|
|
227
|
+
),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
128
231
|
export class TrackerDomain {
|
|
129
232
|
readonly #db: Database;
|
|
130
233
|
|
|
@@ -217,6 +320,43 @@ export class TrackerDomain {
|
|
|
217
320
|
return this.getTaskOrThrow(id);
|
|
218
321
|
}
|
|
219
322
|
|
|
323
|
+
createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
|
|
324
|
+
const epicId: string = assertNonEmpty("epicId", input.epicId);
|
|
325
|
+
this.getEpicOrThrow(epicId);
|
|
326
|
+
|
|
327
|
+
const validatedSpecs: ValidatedTaskBatchSpec[] = input.specs.map((spec) => ({
|
|
328
|
+
tempKey: assertNonEmpty("tempKey", spec.tempKey),
|
|
329
|
+
title: assertNonEmpty("title", spec.title),
|
|
330
|
+
description: assertNonEmpty("description", spec.description),
|
|
331
|
+
status: normalizeStatus(spec.status),
|
|
332
|
+
}));
|
|
333
|
+
|
|
334
|
+
const tasks: TaskRecord[] = [];
|
|
335
|
+
for (const spec of validatedSpecs) {
|
|
336
|
+
const now: number = Date.now();
|
|
337
|
+
const id: string = randomUUID();
|
|
338
|
+
|
|
339
|
+
this.#db
|
|
340
|
+
.query(
|
|
341
|
+
"INSERT INTO tasks (id, epic_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
|
|
342
|
+
)
|
|
343
|
+
.run(id, epicId, spec.title, spec.description, spec.status, now, now);
|
|
344
|
+
|
|
345
|
+
tasks.push(this.getTaskOrThrow(id));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
tasks,
|
|
350
|
+
result: {
|
|
351
|
+
mappings: tasks.map((task, index) => ({
|
|
352
|
+
kind: "task",
|
|
353
|
+
tempKey: validatedSpecs[index]?.tempKey ?? "",
|
|
354
|
+
id: task.id,
|
|
355
|
+
})),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
220
360
|
listTasks(epicId?: string): readonly TaskRecord[] {
|
|
221
361
|
if (epicId) {
|
|
222
362
|
this.getEpicOrThrow(epicId);
|
|
@@ -299,6 +439,89 @@ export class TrackerDomain {
|
|
|
299
439
|
return this.getSubtaskOrThrow(id);
|
|
300
440
|
}
|
|
301
441
|
|
|
442
|
+
createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
|
|
443
|
+
const defaultTaskId: string = assertNonEmpty("taskId", input.taskId);
|
|
444
|
+
this.getTaskOrThrow(defaultTaskId);
|
|
445
|
+
|
|
446
|
+
const validatedSpecs: ValidatedSubtaskBatchSpec[] = input.specs.map((spec) => {
|
|
447
|
+
const taskId = spec.parent.kind === "id" ? assertNonEmpty("taskId", spec.parent.id) : defaultTaskId;
|
|
448
|
+
this.getTaskOrThrow(taskId);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
tempKey: assertNonEmpty("tempKey", spec.tempKey),
|
|
452
|
+
taskId,
|
|
453
|
+
title: assertNonEmpty("title", spec.title),
|
|
454
|
+
description: spec.description === undefined ? "" : assertNonEmpty("description", spec.description),
|
|
455
|
+
status: normalizeStatus(spec.status),
|
|
456
|
+
};
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const subtasks: SubtaskRecord[] = [];
|
|
460
|
+
for (const spec of validatedSpecs) {
|
|
461
|
+
const now: number = Date.now();
|
|
462
|
+
const id: string = randomUUID();
|
|
463
|
+
|
|
464
|
+
this.#db
|
|
465
|
+
.query(
|
|
466
|
+
"INSERT INTO subtasks (id, task_id, title, description, status, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
|
|
467
|
+
)
|
|
468
|
+
.run(id, spec.taskId, spec.title, spec.description, spec.status, now, now);
|
|
469
|
+
|
|
470
|
+
subtasks.push(this.getSubtaskOrThrow(id));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
subtasks,
|
|
475
|
+
result: {
|
|
476
|
+
mappings: subtasks.map((subtask, index) => ({
|
|
477
|
+
kind: "subtask",
|
|
478
|
+
tempKey: validatedSpecs[index]?.tempKey ?? "",
|
|
479
|
+
id: subtask.id,
|
|
480
|
+
})),
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
expandEpic(input: {
|
|
486
|
+
epicId: string;
|
|
487
|
+
taskSpecs: readonly CompactTaskSpec[];
|
|
488
|
+
subtaskSpecs: readonly CompactSubtaskSpec[];
|
|
489
|
+
dependencySpecs: readonly CompactDependencySpec[];
|
|
490
|
+
}): CompactEpicExpandResult {
|
|
491
|
+
const createdTasks = this.createTaskBatch({
|
|
492
|
+
epicId: input.epicId,
|
|
493
|
+
specs: input.taskSpecs,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const resolvedSubtaskSpecs = this.#resolveEpicExpandSubtaskSpecs(input.subtaskSpecs, createdTasks.result.mappings);
|
|
497
|
+
const createdSubtasks = resolvedSubtaskSpecs.length === 0
|
|
498
|
+
? { subtasks: [], result: { mappings: [] } }
|
|
499
|
+
: this.createSubtaskBatch({
|
|
500
|
+
taskId: resolvedSubtaskSpecs[0]?.parent.kind === "id" ? resolvedSubtaskSpecs[0].parent.id : "",
|
|
501
|
+
specs: resolvedSubtaskSpecs,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const mappings = [...createdTasks.result.mappings, ...createdSubtasks.result.mappings];
|
|
505
|
+
const resolvedDependencySpecs = this.#resolveEpicExpandDependencySpecs(input.dependencySpecs, mappings);
|
|
506
|
+
const createdDependencies = resolvedDependencySpecs.length === 0
|
|
507
|
+
? { dependencies: [], result: { mappings: [] } }
|
|
508
|
+
: this.addDependencyBatch({ specs: resolvedDependencySpecs });
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
tasks: createdTasks.tasks,
|
|
512
|
+
subtasks: createdSubtasks.subtasks,
|
|
513
|
+
dependencies: createdDependencies.dependencies,
|
|
514
|
+
result: {
|
|
515
|
+
mappings,
|
|
516
|
+
counts: {
|
|
517
|
+
tasks: createdTasks.tasks.length,
|
|
518
|
+
subtasks: createdSubtasks.subtasks.length,
|
|
519
|
+
dependencies: createdDependencies.dependencies.length,
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
302
525
|
listSubtasks(taskId?: string): readonly SubtaskRecord[] {
|
|
303
526
|
if (taskId) {
|
|
304
527
|
this.getTaskOrThrow(taskId);
|
|
@@ -433,6 +656,89 @@ export class TrackerDomain {
|
|
|
433
656
|
};
|
|
434
657
|
}
|
|
435
658
|
|
|
659
|
+
collectEpicSearchScope(epicId: string): readonly SearchNode[] {
|
|
660
|
+
const tree = this.buildEpicTreeDetailed(epicId);
|
|
661
|
+
|
|
662
|
+
return [
|
|
663
|
+
{
|
|
664
|
+
kind: "epic",
|
|
665
|
+
id: tree.id,
|
|
666
|
+
title: tree.title,
|
|
667
|
+
description: tree.description,
|
|
668
|
+
},
|
|
669
|
+
...tree.tasks.flatMap((task) => [
|
|
670
|
+
{
|
|
671
|
+
kind: "task" as const,
|
|
672
|
+
id: task.id,
|
|
673
|
+
title: task.title,
|
|
674
|
+
description: task.description,
|
|
675
|
+
},
|
|
676
|
+
...task.subtasks.map((subtask) => ({
|
|
677
|
+
kind: "subtask" as const,
|
|
678
|
+
id: subtask.id,
|
|
679
|
+
title: subtask.title,
|
|
680
|
+
description: subtask.description,
|
|
681
|
+
})),
|
|
682
|
+
]),
|
|
683
|
+
];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
collectTaskSearchScope(taskId: string): readonly SearchNode[] {
|
|
687
|
+
const tree = this.buildTaskTreeDetailed(taskId);
|
|
688
|
+
|
|
689
|
+
return [
|
|
690
|
+
{
|
|
691
|
+
kind: "task",
|
|
692
|
+
id: tree.id,
|
|
693
|
+
title: tree.title,
|
|
694
|
+
description: tree.description,
|
|
695
|
+
},
|
|
696
|
+
...tree.subtasks.map((subtask) => ({
|
|
697
|
+
kind: "subtask" as const,
|
|
698
|
+
id: subtask.id,
|
|
699
|
+
title: subtask.title,
|
|
700
|
+
description: subtask.description,
|
|
701
|
+
})),
|
|
702
|
+
];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
collectSubtaskSearchScope(subtaskId: string): readonly SearchNode[] {
|
|
706
|
+
const subtask = this.getSubtaskOrThrow(subtaskId);
|
|
707
|
+
|
|
708
|
+
return [
|
|
709
|
+
{
|
|
710
|
+
kind: "subtask",
|
|
711
|
+
id: subtask.id,
|
|
712
|
+
title: subtask.title,
|
|
713
|
+
description: subtask.description,
|
|
714
|
+
},
|
|
715
|
+
];
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
searchEpicScope(epicId: string, searchText: string, fields: readonly SearchField[]): { readonly matches: readonly SearchEntityMatch[]; readonly summary: SearchSummary } {
|
|
719
|
+
const matches = this.collectSearchMatches(this.collectEpicSearchScope(epicId), searchText, fields);
|
|
720
|
+
return {
|
|
721
|
+
matches,
|
|
722
|
+
summary: summarizeMatches(matches),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
searchTaskScope(taskId: string, searchText: string, fields: readonly SearchField[]): { readonly matches: readonly SearchEntityMatch[]; readonly summary: SearchSummary } {
|
|
727
|
+
const matches = this.collectSearchMatches(this.collectTaskSearchScope(taskId), searchText, fields);
|
|
728
|
+
return {
|
|
729
|
+
matches,
|
|
730
|
+
summary: summarizeMatches(matches),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
searchSubtaskScope(subtaskId: string, searchText: string, fields: readonly SearchField[]): { readonly matches: readonly SearchEntityMatch[]; readonly summary: SearchSummary } {
|
|
735
|
+
const matches = this.collectSearchMatches(this.collectSubtaskSearchScope(subtaskId), searchText, fields);
|
|
736
|
+
return {
|
|
737
|
+
matches,
|
|
738
|
+
summary: summarizeMatches(matches),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
436
742
|
resolveNodeKind(id: string): "task" | "subtask" {
|
|
437
743
|
const task = this.getTask(id);
|
|
438
744
|
if (task) {
|
|
@@ -496,6 +802,54 @@ export class TrackerDomain {
|
|
|
496
802
|
return this.getDependencyOrThrow(id);
|
|
497
803
|
}
|
|
498
804
|
|
|
805
|
+
addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
|
|
806
|
+
const resolutions = input.specs.map((spec, index) => this.#resolveDependencyBatchSpec(index, spec));
|
|
807
|
+
const resolvedSpecs = resolutions.flatMap((resolution) => (resolution.spec === undefined ? [] : [resolution.spec]));
|
|
808
|
+
const issues = resolutions.flatMap((resolution) => resolution.issues).concat(this.#collectDependencyBatchIssues(resolvedSpecs));
|
|
809
|
+
if (issues.length > 0) {
|
|
810
|
+
const orderedIssues = issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
|
|
811
|
+
const firstIssue = orderedIssues[0] ?? null;
|
|
812
|
+
throw new DomainError({
|
|
813
|
+
code: "invalid_dependency",
|
|
814
|
+
message:
|
|
815
|
+
firstIssue?.type === "missing_id"
|
|
816
|
+
? "dependency batch contains missing ids"
|
|
817
|
+
: firstIssue?.type === "duplicate"
|
|
818
|
+
? "dependency batch contains duplicate edges"
|
|
819
|
+
: "dependency batch contains cycles",
|
|
820
|
+
details: {
|
|
821
|
+
issues: orderedIssues,
|
|
822
|
+
firstIssue,
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const dependencies: DependencyRecord[] = [];
|
|
828
|
+
for (const spec of resolvedSpecs) {
|
|
829
|
+
const existing = this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId);
|
|
830
|
+
if (existing) {
|
|
831
|
+
dependencies.push(existing);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const id: string = randomUUID();
|
|
836
|
+
const now: number = Date.now();
|
|
837
|
+
|
|
838
|
+
this.#db
|
|
839
|
+
.query(
|
|
840
|
+
"INSERT INTO dependencies (id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, 1);",
|
|
841
|
+
)
|
|
842
|
+
.run(id, spec.sourceId, spec.sourceKind, spec.dependsOnId, spec.dependsOnKind, now, now);
|
|
843
|
+
|
|
844
|
+
dependencies.push(this.getDependencyOrThrow(id));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
dependencies,
|
|
849
|
+
result: { mappings: [] },
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
499
853
|
removeDependency(sourceId: string, dependsOnId: string): number {
|
|
500
854
|
const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
|
|
501
855
|
const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
|
|
@@ -570,6 +924,326 @@ export class TrackerDomain {
|
|
|
570
924
|
return mapDependency(row);
|
|
571
925
|
}
|
|
572
926
|
|
|
927
|
+
#getDependencyByEdge(sourceId: string, dependsOnId: string): DependencyRecord | null {
|
|
928
|
+
const row = this.#db
|
|
929
|
+
.query(
|
|
930
|
+
"SELECT id, source_id, source_kind, depends_on_id, depends_on_kind, created_at, updated_at FROM dependencies WHERE source_id = ? AND depends_on_id = ?;",
|
|
931
|
+
)
|
|
932
|
+
.get(sourceId, dependsOnId) as DependencyRow | null;
|
|
933
|
+
|
|
934
|
+
return row ? mapDependency(row) : null;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
#resolveDependencyBatchSpec(index: number, spec: CompactDependencySpec): DependencyBatchResolution {
|
|
938
|
+
const sourceResolution = this.#resolveDependencyBatchId(spec.source, "source", index);
|
|
939
|
+
const dependsOnResolution = this.#resolveDependencyBatchId(spec.dependsOn, "dependsOn", index);
|
|
940
|
+
const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
|
|
941
|
+
const sourceId = sourceResolution.id;
|
|
942
|
+
const dependsOnId = dependsOnResolution.id;
|
|
943
|
+
|
|
944
|
+
if (sourceId === undefined || dependsOnId === undefined) {
|
|
945
|
+
return {
|
|
946
|
+
issues,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (sourceId === dependsOnId) {
|
|
951
|
+
return {
|
|
952
|
+
issues: [
|
|
953
|
+
...issues,
|
|
954
|
+
{
|
|
955
|
+
index,
|
|
956
|
+
type: "cycle",
|
|
957
|
+
sourceId,
|
|
958
|
+
dependsOnId,
|
|
959
|
+
details: { sourceId, dependsOnId, reason: "self_reference" },
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return {
|
|
966
|
+
spec: {
|
|
967
|
+
index,
|
|
968
|
+
sourceId,
|
|
969
|
+
sourceKind: this.resolveNodeKind(sourceId),
|
|
970
|
+
dependsOnId,
|
|
971
|
+
dependsOnKind: this.resolveNodeKind(dependsOnId),
|
|
972
|
+
},
|
|
973
|
+
issues,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
#resolveDependencyBatchId(
|
|
978
|
+
reference: CompactEntityRef,
|
|
979
|
+
field: "source" | "dependsOn",
|
|
980
|
+
index: number,
|
|
981
|
+
): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
|
|
982
|
+
if (reference.kind === "temp_key") {
|
|
983
|
+
return {
|
|
984
|
+
issues: [
|
|
985
|
+
{
|
|
986
|
+
index,
|
|
987
|
+
type: "missing_id",
|
|
988
|
+
sourceId: field === "source" ? `@${reference.tempKey}` : "",
|
|
989
|
+
dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
|
|
990
|
+
details: {
|
|
991
|
+
field,
|
|
992
|
+
tempKey: reference.tempKey,
|
|
993
|
+
message: `Unresolved temp key @${reference.tempKey}`,
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const id = assertNonEmpty(field === "source" ? "sourceId" : "dependsOnId", reference.id);
|
|
1001
|
+
const task = this.getTask(id);
|
|
1002
|
+
const subtask = this.getSubtask(id);
|
|
1003
|
+
if (!task && !subtask) {
|
|
1004
|
+
return {
|
|
1005
|
+
issues: [
|
|
1006
|
+
{
|
|
1007
|
+
index,
|
|
1008
|
+
type: "missing_id",
|
|
1009
|
+
sourceId: field === "source" ? id : "",
|
|
1010
|
+
dependsOnId: field === "dependsOn" ? id : "",
|
|
1011
|
+
details: {
|
|
1012
|
+
field,
|
|
1013
|
+
id,
|
|
1014
|
+
message: `Node not found: ${id}`,
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
],
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return { id, issues: [] };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
#resolveEpicExpandSubtaskSpecs(
|
|
1025
|
+
specs: readonly CompactSubtaskSpec[],
|
|
1026
|
+
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
1027
|
+
): CompactSubtaskSpec[] {
|
|
1028
|
+
return specs.map((spec, index) => {
|
|
1029
|
+
const parent = this.#resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent");
|
|
1030
|
+
if (parent.kind !== "task") {
|
|
1031
|
+
throw new DomainError({
|
|
1032
|
+
code: "invalid_input",
|
|
1033
|
+
message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
|
|
1034
|
+
details: {
|
|
1035
|
+
index,
|
|
1036
|
+
field: "parent",
|
|
1037
|
+
kind: parent.kind,
|
|
1038
|
+
id: parent.id,
|
|
1039
|
+
},
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
...spec,
|
|
1045
|
+
parent: {
|
|
1046
|
+
kind: "id",
|
|
1047
|
+
id: parent.id,
|
|
1048
|
+
},
|
|
1049
|
+
};
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
#resolveEpicExpandDependencySpecs(
|
|
1054
|
+
specs: readonly CompactDependencySpec[],
|
|
1055
|
+
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
1056
|
+
): CompactDependencySpec[] {
|
|
1057
|
+
return specs.map((spec, index) => ({
|
|
1058
|
+
source: {
|
|
1059
|
+
kind: "id",
|
|
1060
|
+
id: this.#resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source").id,
|
|
1061
|
+
},
|
|
1062
|
+
dependsOn: {
|
|
1063
|
+
kind: "id",
|
|
1064
|
+
id: this.#resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn").id,
|
|
1065
|
+
},
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
#resolveEpicExpandEntityRef(
|
|
1070
|
+
reference: CompactEntityRef,
|
|
1071
|
+
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
1072
|
+
option: "subtask" | "dep",
|
|
1073
|
+
index: number,
|
|
1074
|
+
field: "parent" | "source" | "dependsOn",
|
|
1075
|
+
): ResolvedCompactEntity {
|
|
1076
|
+
if (reference.kind === "temp_key") {
|
|
1077
|
+
const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
|
|
1078
|
+
if (mapping === undefined) {
|
|
1079
|
+
throw new DomainError({
|
|
1080
|
+
code: "invalid_input",
|
|
1081
|
+
message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
|
|
1082
|
+
details: {
|
|
1083
|
+
index,
|
|
1084
|
+
field,
|
|
1085
|
+
tempKey: reference.tempKey,
|
|
1086
|
+
option,
|
|
1087
|
+
},
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return {
|
|
1092
|
+
id: mapping.id,
|
|
1093
|
+
kind: mapping.kind,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const id = assertNonEmpty(field === "parent" ? "taskId" : `${field}Id`, reference.id);
|
|
1098
|
+
return {
|
|
1099
|
+
id,
|
|
1100
|
+
kind: this.resolveNodeKind(id),
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
#collectDependencyBatchIssues(specs: readonly ResolvedDependencyBatchSpec[]): DependencyBatchValidationIssue[] {
|
|
1105
|
+
const issues: DependencyBatchValidationIssue[] = [];
|
|
1106
|
+
const seenEdges = new Map<string, number>();
|
|
1107
|
+
const adjacency = this.#buildDependencyAdjacency();
|
|
1108
|
+
|
|
1109
|
+
for (const spec of specs) {
|
|
1110
|
+
const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
|
|
1111
|
+
const existingIndex = seenEdges.get(edgeKey);
|
|
1112
|
+
if (existingIndex !== undefined) {
|
|
1113
|
+
issues.push({
|
|
1114
|
+
index: spec.index,
|
|
1115
|
+
type: "duplicate",
|
|
1116
|
+
sourceId: spec.sourceId,
|
|
1117
|
+
dependsOnId: spec.dependsOnId,
|
|
1118
|
+
details: {
|
|
1119
|
+
sourceId: spec.sourceId,
|
|
1120
|
+
dependsOnId: spec.dependsOnId,
|
|
1121
|
+
firstIndex: existingIndex,
|
|
1122
|
+
duplicateIndex: spec.index,
|
|
1123
|
+
duplicateKind: "batch",
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (this.#getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
|
|
1130
|
+
issues.push({
|
|
1131
|
+
index: spec.index,
|
|
1132
|
+
type: "duplicate",
|
|
1133
|
+
sourceId: spec.sourceId,
|
|
1134
|
+
dependsOnId: spec.dependsOnId,
|
|
1135
|
+
details: {
|
|
1136
|
+
sourceId: spec.sourceId,
|
|
1137
|
+
dependsOnId: spec.dependsOnId,
|
|
1138
|
+
duplicateKind: "existing",
|
|
1139
|
+
},
|
|
1140
|
+
});
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (this.#wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
|
|
1145
|
+
issues.push({
|
|
1146
|
+
index: spec.index,
|
|
1147
|
+
type: "cycle",
|
|
1148
|
+
sourceId: spec.sourceId,
|
|
1149
|
+
dependsOnId: spec.dependsOnId,
|
|
1150
|
+
details: {
|
|
1151
|
+
sourceId: spec.sourceId,
|
|
1152
|
+
dependsOnId: spec.dependsOnId,
|
|
1153
|
+
},
|
|
1154
|
+
});
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
|
|
1159
|
+
nextNeighbors.add(spec.dependsOnId);
|
|
1160
|
+
adjacency.set(spec.sourceId, nextNeighbors);
|
|
1161
|
+
seenEdges.set(edgeKey, spec.index);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
#buildDependencyAdjacency(): Map<string, Set<string>> {
|
|
1168
|
+
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<{
|
|
1169
|
+
source_id: string;
|
|
1170
|
+
depends_on_id: string;
|
|
1171
|
+
}>;
|
|
1172
|
+
const adjacency = new Map<string, Set<string>>();
|
|
1173
|
+
|
|
1174
|
+
for (const row of rows) {
|
|
1175
|
+
const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
|
|
1176
|
+
neighbors.add(row.depends_on_id);
|
|
1177
|
+
adjacency.set(row.source_id, neighbors);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
return adjacency;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
#wouldCreateCycleInAdjacency(adjacency: ReadonlyMap<string, ReadonlySet<string>>, sourceId: string, dependsOnId: string): boolean {
|
|
1184
|
+
const visited = new Set<string>();
|
|
1185
|
+
const queue: string[] = [dependsOnId];
|
|
1186
|
+
|
|
1187
|
+
while (queue.length > 0) {
|
|
1188
|
+
const current = queue.shift();
|
|
1189
|
+
if (current === undefined || visited.has(current)) {
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (current === sourceId) {
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
visited.add(current);
|
|
1198
|
+
const neighbors = adjacency.get(current);
|
|
1199
|
+
if (neighbors === undefined) {
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
for (const neighbor of neighbors) {
|
|
1204
|
+
if (!visited.has(neighbor)) {
|
|
1205
|
+
queue.push(neighbor);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private collectSearchMatches(
|
|
1214
|
+
nodes: readonly SearchNode[],
|
|
1215
|
+
searchText: string,
|
|
1216
|
+
fields: readonly SearchField[],
|
|
1217
|
+
): readonly SearchEntityMatch[] {
|
|
1218
|
+
const matches: SearchEntityMatch[] = [];
|
|
1219
|
+
|
|
1220
|
+
for (const node of nodes) {
|
|
1221
|
+
const matchedFields: SearchFieldMatch[] = [];
|
|
1222
|
+
for (const field of fields) {
|
|
1223
|
+
const count = countMatches(node[field], searchText);
|
|
1224
|
+
if (count > 0) {
|
|
1225
|
+
matchedFields.push({
|
|
1226
|
+
field,
|
|
1227
|
+
count,
|
|
1228
|
+
snippet: buildMatchSnippet(node[field], searchText),
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (matchedFields.length === 0) {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
matches.push({
|
|
1238
|
+
kind: node.kind,
|
|
1239
|
+
id: node.id,
|
|
1240
|
+
fields: matchedFields,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
return matches;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
573
1247
|
private wouldCreateCycle(sourceId: string, dependsOnId: string): boolean {
|
|
574
1248
|
const row = this.#db
|
|
575
1249
|
.query(
|