trekoon 0.2.0 → 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 +174 -301
- package/README.md +215 -4
- package/package.json +1 -1
- package/src/commands/arg-parser.ts +116 -0
- package/src/commands/dep.ts +197 -25
- package/src/commands/epic.ts +490 -28
- package/src/commands/error-utils.ts +111 -0
- package/src/commands/events.ts +23 -3
- package/src/commands/help.ts +36 -4
- package/src/commands/init.ts +11 -3
- package/src/commands/migrate.ts +11 -4
- package/src/commands/subtask.ts +224 -26
- package/src/commands/sync.ts +7 -1
- package/src/commands/task.ts +197 -26
- package/src/domain/mutation-service.ts +152 -0
- package/src/domain/tracker-domain.ts +503 -0
- package/src/domain/types.ts +80 -0
- 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,
|
|
@@ -62,6 +70,47 @@ interface UnresolvedDependencyBlocker {
|
|
|
62
70
|
readonly status: string;
|
|
63
71
|
}
|
|
64
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
|
+
|
|
65
114
|
function assertNonEmpty(field: string, value: string | undefined | null): string {
|
|
66
115
|
const normalized: string = (value ?? "").trim();
|
|
67
116
|
if (!normalized) {
|
|
@@ -271,6 +320,43 @@ export class TrackerDomain {
|
|
|
271
320
|
return this.getTaskOrThrow(id);
|
|
272
321
|
}
|
|
273
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
|
+
|
|
274
360
|
listTasks(epicId?: string): readonly TaskRecord[] {
|
|
275
361
|
if (epicId) {
|
|
276
362
|
this.getEpicOrThrow(epicId);
|
|
@@ -353,6 +439,89 @@ export class TrackerDomain {
|
|
|
353
439
|
return this.getSubtaskOrThrow(id);
|
|
354
440
|
}
|
|
355
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
|
+
|
|
356
525
|
listSubtasks(taskId?: string): readonly SubtaskRecord[] {
|
|
357
526
|
if (taskId) {
|
|
358
527
|
this.getTaskOrThrow(taskId);
|
|
@@ -633,6 +802,54 @@ export class TrackerDomain {
|
|
|
633
802
|
return this.getDependencyOrThrow(id);
|
|
634
803
|
}
|
|
635
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
|
+
|
|
636
853
|
removeDependency(sourceId: string, dependsOnId: string): number {
|
|
637
854
|
const normalizedSourceId: string = assertNonEmpty("sourceId", sourceId);
|
|
638
855
|
const normalizedDependsOnId: string = assertNonEmpty("dependsOnId", dependsOnId);
|
|
@@ -707,6 +924,292 @@ export class TrackerDomain {
|
|
|
707
924
|
return mapDependency(row);
|
|
708
925
|
}
|
|
709
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
|
+
|
|
710
1213
|
private collectSearchMatches(
|
|
711
1214
|
nodes: readonly SearchNode[],
|
|
712
1215
|
searchText: string,
|
package/src/domain/types.ts
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
export type NodeKind = "epic" | "task" | "subtask";
|
|
2
2
|
|
|
3
|
+
export const COMPACT_TEMP_KEY_PREFIX = "@";
|
|
4
|
+
|
|
5
|
+
export type CompactTempKey = string;
|
|
6
|
+
|
|
7
|
+
export interface CompactTempKeyRef {
|
|
8
|
+
readonly kind: "temp_key";
|
|
9
|
+
readonly tempKey: CompactTempKey;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CompactEntityIdRef {
|
|
13
|
+
readonly kind: "id";
|
|
14
|
+
readonly id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type CompactEntityRef = CompactTempKeyRef | CompactEntityIdRef;
|
|
18
|
+
|
|
19
|
+
export interface CompactTaskSpec {
|
|
20
|
+
readonly tempKey: CompactTempKey;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
readonly status?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CompactSubtaskSpec {
|
|
27
|
+
readonly parent: CompactEntityRef;
|
|
28
|
+
readonly tempKey: CompactTempKey;
|
|
29
|
+
readonly title: string;
|
|
30
|
+
readonly description: string;
|
|
31
|
+
readonly status?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CompactDependencySpec {
|
|
35
|
+
readonly source: CompactEntityRef;
|
|
36
|
+
readonly dependsOn: CompactEntityRef;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CompactTempKeyMapping<TKind extends Extract<NodeKind, "task" | "subtask"> = Extract<NodeKind, "task" | "subtask">> {
|
|
40
|
+
readonly kind: TKind;
|
|
41
|
+
readonly tempKey: CompactTempKey;
|
|
42
|
+
readonly id: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CompactBatchResultContract {
|
|
46
|
+
readonly mappings: ReadonlyArray<CompactTempKeyMapping>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CompactTaskBatchCreateResult {
|
|
50
|
+
readonly tasks: ReadonlyArray<TaskRecord>;
|
|
51
|
+
readonly result: CompactBatchResultContract;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CompactSubtaskBatchCreateResult {
|
|
55
|
+
readonly subtasks: ReadonlyArray<SubtaskRecord>;
|
|
56
|
+
readonly result: CompactBatchResultContract;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CompactDependencyBatchAddResult {
|
|
60
|
+
readonly dependencies: ReadonlyArray<DependencyRecord>;
|
|
61
|
+
readonly result: CompactBatchResultContract;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CompactBatchCounts {
|
|
65
|
+
readonly tasks: number;
|
|
66
|
+
readonly subtasks: number;
|
|
67
|
+
readonly dependencies: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CompactEpicExpandResult {
|
|
71
|
+
readonly tasks: ReadonlyArray<TaskRecord>;
|
|
72
|
+
readonly subtasks: ReadonlyArray<SubtaskRecord>;
|
|
73
|
+
readonly dependencies: ReadonlyArray<DependencyRecord>;
|
|
74
|
+
readonly result: CompactBatchResultContract & {
|
|
75
|
+
readonly counts: CompactBatchCounts;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CompactEpicCreateResult extends CompactEpicExpandResult {
|
|
80
|
+
readonly epic: EpicRecord;
|
|
81
|
+
}
|
|
82
|
+
|
|
3
83
|
export interface EpicRecord {
|
|
4
84
|
readonly id: string;
|
|
5
85
|
readonly title: string;
|
package/src/sync/service.ts
CHANGED
|
@@ -401,6 +401,18 @@ function rowExists(db: Database, tableName: string, id: string): boolean {
|
|
|
401
401
|
return row !== null;
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
function dependencyNodeExists(db: Database, nodeKind: string, nodeId: string): boolean {
|
|
405
|
+
if (nodeKind === "task") {
|
|
406
|
+
return rowExists(db, "tasks", nodeId);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (nodeKind === "subtask") {
|
|
410
|
+
return rowExists(db, "subtasks", nodeId);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
404
416
|
function validateRequiredStringField(fields: Record<string, unknown>, fieldName: string): string | null {
|
|
405
417
|
const value: unknown = fields[fieldName];
|
|
406
418
|
if (typeof value !== "string" || value.length === 0) {
|
|
@@ -503,6 +515,10 @@ function applyCreate(db: Database, event: StoredEvent, fields: Record<string, un
|
|
|
503
515
|
return false;
|
|
504
516
|
}
|
|
505
517
|
|
|
518
|
+
if (!dependencyNodeExists(db, sourceKind, sourceId) || !dependencyNodeExists(db, dependsOnKind, dependsOnId)) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
|
|
506
522
|
db.query(
|
|
507
523
|
`
|
|
508
524
|
INSERT INTO dependencies (
|
|
@@ -625,6 +641,28 @@ function applyEntityFields(db: Database, event: StoredEvent, fields: Record<stri
|
|
|
625
641
|
return false;
|
|
626
642
|
}
|
|
627
643
|
|
|
644
|
+
function applyReplayedCreateWithConflicts(
|
|
645
|
+
db: Database,
|
|
646
|
+
event: StoredEvent,
|
|
647
|
+
fields: Record<string, unknown>,
|
|
648
|
+
withheldConflictCount: number,
|
|
649
|
+
): boolean {
|
|
650
|
+
if (withheldConflictCount === 0 || !event.operation.endsWith(".created")) {
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const tableName = tableForEntityKind(event.entity_kind);
|
|
655
|
+
if (!tableName || !rowExists(db, tableName, event.entity_id)) {
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (Object.keys(fields).length === 0) {
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return applyUpdatePatch(db, event, fields);
|
|
664
|
+
}
|
|
665
|
+
|
|
628
666
|
function storeEvent(db: Database, event: StoredEvent): void {
|
|
629
667
|
db.query(
|
|
630
668
|
`
|
|
@@ -727,11 +765,13 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
727
765
|
|
|
728
766
|
const payload: EventPayload = { fields: payloadValidation.fields };
|
|
729
767
|
const fieldsToApply: Record<string, unknown> = {};
|
|
768
|
+
let withheldConflictCount = 0;
|
|
730
769
|
|
|
731
770
|
for (const [fieldName, value] of Object.entries(payload.fields)) {
|
|
732
771
|
const conflict = entityFieldConflict(storage.db, sourceBranch, incoming, fieldName, value);
|
|
733
772
|
|
|
734
773
|
if (conflict) {
|
|
774
|
+
withheldConflictCount += 1;
|
|
735
775
|
conflictEvents += 1;
|
|
736
776
|
createConflict(storage.db, incoming, fieldName, conflict.oursValue, conflict.theirsValue);
|
|
737
777
|
createdConflicts += 1;
|
|
@@ -743,6 +783,8 @@ export function syncPull(cwd: string, sourceBranch: string): PullSummary {
|
|
|
743
783
|
|
|
744
784
|
if (applyEntityFields(storage.db, incoming, fieldsToApply)) {
|
|
745
785
|
appliedEvents += 1;
|
|
786
|
+
} else if (applyReplayedCreateWithConflicts(storage.db, incoming, fieldsToApply, withheldConflictCount)) {
|
|
787
|
+
appliedEvents += 1;
|
|
746
788
|
} else if (incoming.operation !== "resolve_conflict") {
|
|
747
789
|
applyRejectedEvents += 1;
|
|
748
790
|
quarantinedEvents += 1;
|