trekoon 0.2.6 → 0.2.7
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/README.md +57 -723
- package/docs/ai-agents.md +198 -0
- package/docs/commands.md +126 -0
- package/docs/machine-contracts.md +253 -0
- package/docs/plans/2026-03-15-trekoon-board-design.md +13 -0
- package/docs/quickstart.md +134 -0
- package/package.json +2 -1
- package/src/commands/epic.ts +104 -3
- package/src/commands/help.ts +31 -13
- package/src/commands/subtask.ts +78 -1
- package/src/commands/task.ts +113 -7
- package/src/domain/mutation-service.ts +76 -0
- package/src/domain/tracker-domain.ts +250 -2
- package/src/domain/types.ts +51 -0
package/src/commands/task.ts
CHANGED
|
@@ -20,19 +20,14 @@ import { unexpectedFailureResult } from "./error-utils";
|
|
|
20
20
|
import {
|
|
21
21
|
buildTaskReadiness,
|
|
22
22
|
DEFAULT_OPEN_TASK_STATUSES,
|
|
23
|
-
type DependencyBlocker,
|
|
24
|
-
READY_REASON_BLOCKED,
|
|
25
|
-
READY_REASON_READY,
|
|
26
|
-
type ReadyReason,
|
|
27
23
|
taskStatusPriority,
|
|
28
24
|
type TaskReadinessResult,
|
|
29
|
-
type TaskReadinessSummary,
|
|
30
25
|
type TaskReadyCandidate,
|
|
31
26
|
} from "./task-readiness";
|
|
32
27
|
|
|
33
28
|
import { MutationService } from "../domain/mutation-service";
|
|
34
29
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
35
|
-
import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type TaskRecord } from "../domain/types";
|
|
30
|
+
import { type CompactBatchResultContract, type CompactTaskSpec, type SearchEntityMatch, type StatusCascadePlan, type TaskRecord } from "../domain/types";
|
|
36
31
|
import { formatHumanTable } from "../io/human-table";
|
|
37
32
|
import { failResult, okResult } from "../io/output";
|
|
38
33
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -45,9 +40,14 @@ function formatTask(task: TaskRecord): string {
|
|
|
45
40
|
const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
46
41
|
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
47
42
|
const DEFAULT_TASK_LIST_LIMIT = 10;
|
|
43
|
+
const CREATE_OPTIONS = ["epic", "e", "title", "t", "description", "d", "status", "s"] as const;
|
|
44
|
+
const LIST_OPTIONS = ["epic", "e", "status", "s", "limit", "l", "cursor", "all", "view"] as const;
|
|
45
|
+
const SHOW_OPTIONS = ["view", "all"] as const;
|
|
48
46
|
const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
49
47
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
50
48
|
const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
|
|
49
|
+
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
|
|
50
|
+
const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
|
|
51
51
|
|
|
52
52
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
53
53
|
if (rawIds === undefined) {
|
|
@@ -185,6 +185,44 @@ function appendLine(existing: string, line: string): string {
|
|
|
185
185
|
return existing.length > 0 ? `${existing}\n${line}` : line;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
function isStatusCascadeUpdateStatus(status: string | undefined): status is (typeof STATUS_CASCADE_UPDATE_STATUSES)[number] {
|
|
189
|
+
return status === "done" || status === "todo";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildStatusCascadeData(plan: StatusCascadePlan): Record<string, unknown> {
|
|
193
|
+
return {
|
|
194
|
+
mode: "descendants",
|
|
195
|
+
root: {
|
|
196
|
+
kind: plan.rootKind,
|
|
197
|
+
id: plan.rootId,
|
|
198
|
+
},
|
|
199
|
+
targetStatus: plan.targetStatus,
|
|
200
|
+
atomic: plan.atomic,
|
|
201
|
+
changedIds: plan.changedIds,
|
|
202
|
+
unchangedIds: plan.unchangedIds,
|
|
203
|
+
counts: plan.counts,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatStatusCascadeHuman(entityLabel: string, plan: StatusCascadePlan): string {
|
|
208
|
+
return `Cascade updated ${entityLabel} ${plan.rootId} to ${plan.targetStatus} (${plan.counts.changed} changed, ${plan.counts.unchanged} unchanged; tasks=${plan.counts.changedTasks}, subtasks=${plan.counts.changedSubtasks})`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function failCascadeStatusUpdate(command: string, entityLabel: string, data: Record<string, unknown>): CliResult {
|
|
212
|
+
return failResult({
|
|
213
|
+
command,
|
|
214
|
+
human: `${entityLabel} descendant cascade requires --status done or --status todo and does not support --append, --description, or --title.`,
|
|
215
|
+
data: {
|
|
216
|
+
code: "invalid_input",
|
|
217
|
+
...data,
|
|
218
|
+
},
|
|
219
|
+
error: {
|
|
220
|
+
code: "invalid_input",
|
|
221
|
+
message: `${entityLabel} descendant cascade requires status-only done/todo mode`,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
188
226
|
function formatTaskListTable(tasks: readonly TaskRecord[]): string {
|
|
189
227
|
const rows = tasks.map((task) => [task.id, task.epicId, task.title, task.status]);
|
|
190
228
|
return formatHumanTable(["ID", "EPIC", "TITLE", "STATUS"], rows, { wrapColumns: [2] });
|
|
@@ -426,6 +464,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
426
464
|
|
|
427
465
|
switch (subcommand) {
|
|
428
466
|
case "create": {
|
|
467
|
+
const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
|
|
468
|
+
if (createUnknownOption !== undefined) {
|
|
469
|
+
return unknownOption("task.create", createUnknownOption, CREATE_OPTIONS);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const unexpectedCreatePositionals = readUnexpectedPositionals(parsed, 1);
|
|
473
|
+
if (unexpectedCreatePositionals.length > 0) {
|
|
474
|
+
return failUnexpectedPositionals("task.create", unexpectedCreatePositionals);
|
|
475
|
+
}
|
|
476
|
+
|
|
429
477
|
const missingCreateOption =
|
|
430
478
|
readMissingOptionValue(parsed.missingOptionValues, "epic", "e") ??
|
|
431
479
|
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
@@ -502,6 +550,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
502
550
|
});
|
|
503
551
|
}
|
|
504
552
|
case "list": {
|
|
553
|
+
const listUnknownOption = findUnknownOption(parsed, LIST_OPTIONS);
|
|
554
|
+
if (listUnknownOption !== undefined) {
|
|
555
|
+
return unknownOption("task.list", listUnknownOption, LIST_OPTIONS);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const unexpectedListPositionals = readUnexpectedPositionals(parsed, 1);
|
|
559
|
+
if (unexpectedListPositionals.length > 0) {
|
|
560
|
+
return failUnexpectedPositionals("task.list", unexpectedListPositionals);
|
|
561
|
+
}
|
|
562
|
+
|
|
505
563
|
const missingListOption =
|
|
506
564
|
readMissingOptionValue(parsed.missingOptionValues, "view") ??
|
|
507
565
|
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
@@ -660,6 +718,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
660
718
|
});
|
|
661
719
|
}
|
|
662
720
|
case "show": {
|
|
721
|
+
const showUnknownOption = findUnknownOption(parsed, SHOW_OPTIONS);
|
|
722
|
+
if (showUnknownOption !== undefined) {
|
|
723
|
+
return unknownOption("task.show", showUnknownOption, SHOW_OPTIONS);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const unexpectedShowPositionals = readUnexpectedPositionals(parsed, 2);
|
|
727
|
+
if (unexpectedShowPositionals.length > 0) {
|
|
728
|
+
return failUnexpectedPositionals("task.show", unexpectedShowPositionals);
|
|
729
|
+
}
|
|
730
|
+
|
|
663
731
|
const missingShowOption = readMissingOptionValue(parsed.missingOptionValues, "view");
|
|
664
732
|
if (missingShowOption !== undefined) {
|
|
665
733
|
return failMissingOptionValue("task.show", missingShowOption);
|
|
@@ -989,6 +1057,16 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
989
1057
|
});
|
|
990
1058
|
}
|
|
991
1059
|
case "update": {
|
|
1060
|
+
const updateUnknownOption = findUnknownOption(parsed, UPDATE_OPTIONS);
|
|
1061
|
+
if (updateUnknownOption !== undefined) {
|
|
1062
|
+
return unknownOption("task.update", updateUnknownOption, UPDATE_OPTIONS);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const unexpectedUpdatePositionals = readUnexpectedPositionals(parsed, 2);
|
|
1066
|
+
if (unexpectedUpdatePositionals.length > 0) {
|
|
1067
|
+
return failUnexpectedPositionals("task.update", unexpectedUpdatePositionals);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
992
1070
|
const missingUpdateOption =
|
|
993
1071
|
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
994
1072
|
readMissingOptionValue(parsed.missingOptionValues, "append") ??
|
|
@@ -1031,7 +1109,35 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1031
1109
|
});
|
|
1032
1110
|
}
|
|
1033
1111
|
|
|
1034
|
-
const
|
|
1112
|
+
const cascadeMode = updateAll && taskId.length > 0;
|
|
1113
|
+
if (cascadeMode) {
|
|
1114
|
+
if (title !== undefined || description !== undefined || append !== undefined || !isStatusCascadeUpdateStatus(status)) {
|
|
1115
|
+
return failCascadeStatusUpdate("task.update", "Task", {
|
|
1116
|
+
id: taskId,
|
|
1117
|
+
status,
|
|
1118
|
+
allowedStatuses: [...STATUS_CASCADE_UPDATE_STATUSES],
|
|
1119
|
+
fields: {
|
|
1120
|
+
title: title !== undefined,
|
|
1121
|
+
description: description !== undefined,
|
|
1122
|
+
append: append !== undefined,
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const cascade = mutations.updateTaskStatusCascade(taskId, status);
|
|
1128
|
+
const task = domain.getTaskOrThrow(taskId);
|
|
1129
|
+
|
|
1130
|
+
return okResult({
|
|
1131
|
+
command: "task.update",
|
|
1132
|
+
human: formatStatusCascadeHuman("task", cascade),
|
|
1133
|
+
data: {
|
|
1134
|
+
task,
|
|
1135
|
+
cascade: buildStatusCascadeData(cascade),
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const hasBulkTarget = (updateAll && taskId.length === 0) || ids.length > 0;
|
|
1035
1141
|
if (hasBulkTarget) {
|
|
1036
1142
|
if (taskId.length > 0) {
|
|
1037
1143
|
return failResult({
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
type SearchField,
|
|
19
19
|
type SearchNode,
|
|
20
20
|
type SearchSummary,
|
|
21
|
+
type StatusCascadePlan,
|
|
21
22
|
type SubtaskRecord,
|
|
22
23
|
type TaskRecord,
|
|
24
|
+
DomainError,
|
|
23
25
|
} from "./types";
|
|
24
26
|
|
|
25
27
|
function countMatches(value: string, searchText: string): number {
|
|
@@ -187,6 +189,15 @@ export class MutationService {
|
|
|
187
189
|
})();
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
|
|
193
|
+
return this.#db.transaction((): StatusCascadePlan => {
|
|
194
|
+
const plan = this.#domain.planStatusCascade("epic", id, status);
|
|
195
|
+
this.#assertCascadeNotBlocked(plan);
|
|
196
|
+
this.#applyStatusCascadePlan(plan);
|
|
197
|
+
return plan;
|
|
198
|
+
})();
|
|
199
|
+
}
|
|
200
|
+
|
|
190
201
|
deleteEpic(id: string): void {
|
|
191
202
|
this.#db.transaction((): void => {
|
|
192
203
|
this.#domain.deleteEpic(id);
|
|
@@ -277,6 +288,15 @@ export class MutationService {
|
|
|
277
288
|
})();
|
|
278
289
|
}
|
|
279
290
|
|
|
291
|
+
updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
|
|
292
|
+
return this.#db.transaction((): StatusCascadePlan => {
|
|
293
|
+
const plan = this.#domain.planStatusCascade("task", id, status);
|
|
294
|
+
this.#assertCascadeNotBlocked(plan);
|
|
295
|
+
this.#applyStatusCascadePlan(plan);
|
|
296
|
+
return plan;
|
|
297
|
+
})();
|
|
298
|
+
}
|
|
299
|
+
|
|
280
300
|
deleteTask(id: string): void {
|
|
281
301
|
this.#db.transaction((): void => {
|
|
282
302
|
this.#domain.deleteTask(id);
|
|
@@ -458,6 +478,62 @@ export class MutationService {
|
|
|
458
478
|
return this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields);
|
|
459
479
|
}
|
|
460
480
|
|
|
481
|
+
#assertCascadeNotBlocked(plan: StatusCascadePlan): void {
|
|
482
|
+
if (plan.blockers.length === 0) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
throw new DomainError({
|
|
487
|
+
code: "dependency_blocked",
|
|
488
|
+
message: `${plan.rootKind} cascade cannot transition to ${plan.targetStatus} while dependencies are unresolved`,
|
|
489
|
+
details: {
|
|
490
|
+
entity: plan.rootKind,
|
|
491
|
+
id: plan.rootId,
|
|
492
|
+
status: plan.targetStatus,
|
|
493
|
+
atomic: plan.atomic,
|
|
494
|
+
changedIds: plan.changedIds,
|
|
495
|
+
unchangedIds: plan.unchangedIds,
|
|
496
|
+
blockerCount: plan.blockers.length,
|
|
497
|
+
blockers: plan.blockers,
|
|
498
|
+
blockedNodeIds: [...new Set(plan.blockers.map((blocker) => blocker.sourceId))],
|
|
499
|
+
unresolvedDependencyIds: [...new Set(plan.blockers.map((blocker) => blocker.dependsOnId))],
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#applyStatusCascadePlan(plan: StatusCascadePlan): void {
|
|
505
|
+
for (const change of plan.orderedChanges) {
|
|
506
|
+
if (change.kind === "epic") {
|
|
507
|
+
const epic = this.#domain.updateEpic(change.id, { status: change.nextStatus });
|
|
508
|
+
this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
|
|
509
|
+
title: epic.title,
|
|
510
|
+
description: epic.description,
|
|
511
|
+
status: epic.status,
|
|
512
|
+
});
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (change.kind === "task") {
|
|
517
|
+
const task = this.#domain.updateTask(change.id, { status: change.nextStatus });
|
|
518
|
+
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
|
|
519
|
+
epic_id: task.epicId,
|
|
520
|
+
title: task.title,
|
|
521
|
+
description: task.description,
|
|
522
|
+
status: task.status,
|
|
523
|
+
});
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const subtask = this.#domain.updateSubtask(change.id, { status: change.nextStatus });
|
|
528
|
+
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
|
|
529
|
+
task_id: subtask.taskId,
|
|
530
|
+
title: subtask.title,
|
|
531
|
+
description: subtask.description,
|
|
532
|
+
status: subtask.status,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
461
537
|
#applyScopeReplacement(
|
|
462
538
|
nodes: readonly SearchNode[],
|
|
463
539
|
searchText: string,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type CompactTaskBatchCreateResult,
|
|
13
13
|
type CompactTaskSpec,
|
|
14
14
|
type DependencyRecord,
|
|
15
|
+
type DependencyNodeKind,
|
|
15
16
|
DomainError,
|
|
16
17
|
type EpicTreeDetailed,
|
|
17
18
|
type EpicRecord,
|
|
@@ -23,6 +24,11 @@ import {
|
|
|
23
24
|
type SearchFieldMatch,
|
|
24
25
|
type SearchNode,
|
|
25
26
|
type SearchSummary,
|
|
27
|
+
type StatusCascadeBlocker,
|
|
28
|
+
type StatusCascadeChange,
|
|
29
|
+
type StatusCascadePlan,
|
|
30
|
+
type StatusCascadeRootKind,
|
|
31
|
+
type StatusCascadeScopeNode,
|
|
26
32
|
type SubtaskRecord,
|
|
27
33
|
type TaskTreeDetailed,
|
|
28
34
|
type TaskRecord,
|
|
@@ -66,7 +72,7 @@ interface ReverseDependencyRow {
|
|
|
66
72
|
|
|
67
73
|
interface UnresolvedDependencyBlocker {
|
|
68
74
|
readonly id: string;
|
|
69
|
-
readonly kind:
|
|
75
|
+
readonly kind: DependencyNodeKind;
|
|
70
76
|
readonly status: string;
|
|
71
77
|
}
|
|
72
78
|
|
|
@@ -656,6 +662,40 @@ export class TrackerDomain {
|
|
|
656
662
|
};
|
|
657
663
|
}
|
|
658
664
|
|
|
665
|
+
planStatusCascade(rootKind: StatusCascadeRootKind, rootId: string, targetStatus: string): StatusCascadePlan {
|
|
666
|
+
const normalizedTargetStatus = assertNonEmpty("status", targetStatus);
|
|
667
|
+
const scope = this.#collectStatusCascadeScope(rootKind, rootId);
|
|
668
|
+
const scopeIdSet = new Set(scope.map((node) => node.id));
|
|
669
|
+
const orderedChanges = this.#orderStatusCascadeChanges(scope, normalizedTargetStatus);
|
|
670
|
+
const changedIds = orderedChanges.map((change) => change.id);
|
|
671
|
+
const changedIdSet = new Set(changedIds);
|
|
672
|
+
const unchangedIds = scope
|
|
673
|
+
.filter((node) => !changedIdSet.has(node.id))
|
|
674
|
+
.map((node) => node.id);
|
|
675
|
+
const blockers = this.#collectStatusCascadeBlockers(orderedChanges, scopeIdSet, changedIdSet, normalizedTargetStatus);
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
rootKind,
|
|
679
|
+
rootId,
|
|
680
|
+
targetStatus: normalizedTargetStatus,
|
|
681
|
+
atomic: true,
|
|
682
|
+
scope,
|
|
683
|
+
orderedChanges,
|
|
684
|
+
changedIds,
|
|
685
|
+
unchangedIds,
|
|
686
|
+
blockers,
|
|
687
|
+
counts: {
|
|
688
|
+
scope: scope.length,
|
|
689
|
+
changed: orderedChanges.length,
|
|
690
|
+
unchanged: unchangedIds.length,
|
|
691
|
+
blockers: blockers.length,
|
|
692
|
+
changedEpics: orderedChanges.filter((change) => change.kind === "epic").length,
|
|
693
|
+
changedTasks: orderedChanges.filter((change) => change.kind === "task").length,
|
|
694
|
+
changedSubtasks: orderedChanges.filter((change) => change.kind === "subtask").length,
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
659
699
|
collectEpicSearchScope(epicId: string): readonly SearchNode[] {
|
|
660
700
|
const tree = this.buildEpicTreeDetailed(epicId);
|
|
661
701
|
|
|
@@ -1266,9 +1306,217 @@ export class TrackerDomain {
|
|
|
1266
1306
|
return row !== null;
|
|
1267
1307
|
}
|
|
1268
1308
|
|
|
1309
|
+
#collectStatusCascadeScope(rootKind: StatusCascadeRootKind, rootId: string): StatusCascadeScopeNode[] {
|
|
1310
|
+
if (rootKind === "task") {
|
|
1311
|
+
const tree = this.buildTaskTreeDetailed(rootId);
|
|
1312
|
+
return [
|
|
1313
|
+
{
|
|
1314
|
+
kind: "task",
|
|
1315
|
+
id: tree.id,
|
|
1316
|
+
parentId: tree.epicId,
|
|
1317
|
+
status: tree.status,
|
|
1318
|
+
},
|
|
1319
|
+
...tree.subtasks.map((subtask) => ({
|
|
1320
|
+
kind: "subtask" as const,
|
|
1321
|
+
id: subtask.id,
|
|
1322
|
+
parentId: subtask.taskId,
|
|
1323
|
+
status: subtask.status,
|
|
1324
|
+
})),
|
|
1325
|
+
];
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const tree = this.buildEpicTreeDetailed(rootId);
|
|
1329
|
+
return [
|
|
1330
|
+
{
|
|
1331
|
+
kind: "epic",
|
|
1332
|
+
id: tree.id,
|
|
1333
|
+
status: tree.status,
|
|
1334
|
+
},
|
|
1335
|
+
...tree.tasks.flatMap((task) => [
|
|
1336
|
+
{
|
|
1337
|
+
kind: "task" as const,
|
|
1338
|
+
id: task.id,
|
|
1339
|
+
parentId: task.epicId,
|
|
1340
|
+
status: task.status,
|
|
1341
|
+
},
|
|
1342
|
+
...task.subtasks.map((subtask) => ({
|
|
1343
|
+
kind: "subtask" as const,
|
|
1344
|
+
id: subtask.id,
|
|
1345
|
+
parentId: subtask.taskId,
|
|
1346
|
+
status: subtask.status,
|
|
1347
|
+
})),
|
|
1348
|
+
]),
|
|
1349
|
+
];
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
#orderStatusCascadeChanges(scope: readonly StatusCascadeScopeNode[], targetStatus: string): StatusCascadeChange[] {
|
|
1353
|
+
const changes = scope
|
|
1354
|
+
.filter((node) => node.status !== targetStatus)
|
|
1355
|
+
.map((node) => {
|
|
1356
|
+
const change: StatusCascadeChange = {
|
|
1357
|
+
kind: node.kind,
|
|
1358
|
+
id: node.id,
|
|
1359
|
+
previousStatus: node.status,
|
|
1360
|
+
nextStatus: targetStatus,
|
|
1361
|
+
...(node.parentId === undefined ? {} : { parentId: node.parentId }),
|
|
1362
|
+
};
|
|
1363
|
+
return change;
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
if (targetStatus !== "done") {
|
|
1367
|
+
return changes;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return this.#topologicallyOrderDoneCascadeChanges(changes);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
#topologicallyOrderDoneCascadeChanges(changes: readonly StatusCascadeChange[]): StatusCascadeChange[] {
|
|
1374
|
+
const indexById = new Map<string, number>();
|
|
1375
|
+
const changeById = new Map<string, StatusCascadeChange>();
|
|
1376
|
+
const dependencyTargetsBySource = new Map<string, Set<string>>();
|
|
1377
|
+
const dependents = new Map<string, Set<string>>();
|
|
1378
|
+
const indegree = new Map<string, number>();
|
|
1379
|
+
|
|
1380
|
+
changes.forEach((change, index) => {
|
|
1381
|
+
indexById.set(change.id, index);
|
|
1382
|
+
changeById.set(change.id, change);
|
|
1383
|
+
indegree.set(change.id, 0);
|
|
1384
|
+
|
|
1385
|
+
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const dependencyTargets = new Set(this.listDependencies(change.id).map((dependency) => dependency.dependsOnId));
|
|
1390
|
+
dependencyTargetsBySource.set(change.id, dependencyTargets);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
const addEdge = (fromId: string, toId: string): void => {
|
|
1394
|
+
if (fromId === toId || !changeById.has(fromId) || !changeById.has(toId)) {
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const neighbors = dependents.get(fromId) ?? new Set<string>();
|
|
1399
|
+
if (neighbors.has(toId)) {
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
neighbors.add(toId);
|
|
1404
|
+
dependents.set(fromId, neighbors);
|
|
1405
|
+
indegree.set(toId, (indegree.get(toId) ?? 0) + 1);
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
for (const change of changes) {
|
|
1409
|
+
const dependencyTargets = dependencyTargetsBySource.get(change.id);
|
|
1410
|
+
|
|
1411
|
+
if (change.kind === "subtask" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
|
|
1412
|
+
addEdge(change.id, change.parentId);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
if (change.kind === "task" && change.parentId !== undefined && !dependencyTargets?.has(change.parentId)) {
|
|
1416
|
+
addEdge(change.id, change.parentId);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
for (const dependencyTargetId of dependencyTargets ?? []) {
|
|
1424
|
+
addEdge(dependencyTargetId, change.id);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const ordered: StatusCascadeChange[] = [];
|
|
1429
|
+
const ready = changes
|
|
1430
|
+
.filter((change) => (indegree.get(change.id) ?? 0) === 0)
|
|
1431
|
+
.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
|
|
1432
|
+
|
|
1433
|
+
while (ready.length > 0) {
|
|
1434
|
+
const next = ready.shift();
|
|
1435
|
+
if (next === undefined) {
|
|
1436
|
+
continue;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
ordered.push(next);
|
|
1440
|
+
for (const dependentId of dependents.get(next.id) ?? []) {
|
|
1441
|
+
const remaining = (indegree.get(dependentId) ?? 0) - 1;
|
|
1442
|
+
indegree.set(dependentId, remaining);
|
|
1443
|
+
if (remaining !== 0) {
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const dependent = changeById.get(dependentId);
|
|
1448
|
+
if (dependent === undefined) {
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
ready.push(dependent);
|
|
1453
|
+
ready.sort((left, right) => (indexById.get(left.id) ?? 0) - (indexById.get(right.id) ?? 0));
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if (ordered.length !== changes.length) {
|
|
1458
|
+
throw new DomainError({
|
|
1459
|
+
code: "invalid_dependency",
|
|
1460
|
+
message: "unable to determine dependency-safe cascade order",
|
|
1461
|
+
details: {
|
|
1462
|
+
changedIds: changes.map((change) => change.id),
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return ordered;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
#collectStatusCascadeBlockers(
|
|
1471
|
+
changes: readonly StatusCascadeChange[],
|
|
1472
|
+
scopeIdSet: ReadonlySet<string>,
|
|
1473
|
+
changedIdSet: ReadonlySet<string>,
|
|
1474
|
+
targetStatus: string,
|
|
1475
|
+
): StatusCascadeBlocker[] {
|
|
1476
|
+
if (!DEPENDENCY_GATED_STATUSES.has(targetStatus)) {
|
|
1477
|
+
return [];
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const blockers: StatusCascadeBlocker[] = [];
|
|
1481
|
+
for (const change of changes) {
|
|
1482
|
+
if (change.kind !== "task" && change.kind !== "subtask") {
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
for (const dependency of this.listDependencies(change.id)) {
|
|
1487
|
+
const dependencyStatus =
|
|
1488
|
+
dependency.dependsOnKind === "task"
|
|
1489
|
+
? this.getTaskOrThrow(dependency.dependsOnId).status
|
|
1490
|
+
: this.getSubtaskOrThrow(dependency.dependsOnId).status;
|
|
1491
|
+
const inScope = scopeIdSet.has(dependency.dependsOnId);
|
|
1492
|
+
const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
|
|
1493
|
+
if (dependencyStatus === "done" || willCascade) {
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
blockers.push({
|
|
1498
|
+
sourceId: dependency.sourceId,
|
|
1499
|
+
sourceKind: dependency.sourceKind,
|
|
1500
|
+
dependsOnId: dependency.dependsOnId,
|
|
1501
|
+
dependsOnKind: dependency.dependsOnKind,
|
|
1502
|
+
dependsOnStatus: dependencyStatus,
|
|
1503
|
+
inScope,
|
|
1504
|
+
willCascade,
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return blockers.sort(
|
|
1510
|
+
(left, right) =>
|
|
1511
|
+
left.sourceId.localeCompare(right.sourceId) ||
|
|
1512
|
+
left.dependsOnId.localeCompare(right.dependsOnId) ||
|
|
1513
|
+
left.dependsOnKind.localeCompare(right.dependsOnKind),
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1269
1517
|
private assertNoUnresolvedDependenciesForStatusTransition(
|
|
1270
1518
|
id: string,
|
|
1271
|
-
kind:
|
|
1519
|
+
kind: DependencyNodeKind,
|
|
1272
1520
|
existingStatus: string,
|
|
1273
1521
|
nextStatus: string,
|
|
1274
1522
|
): void {
|
package/src/domain/types.ts
CHANGED
|
@@ -192,6 +192,57 @@ export interface SearchNode {
|
|
|
192
192
|
readonly description: string;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
export type StatusCascadeRootKind = Extract<NodeKind, "epic" | "task">;
|
|
196
|
+
export type DependencyNodeKind = Extract<NodeKind, "task" | "subtask">;
|
|
197
|
+
|
|
198
|
+
export interface StatusCascadeScopeNode {
|
|
199
|
+
readonly kind: NodeKind;
|
|
200
|
+
readonly id: string;
|
|
201
|
+
readonly parentId?: string;
|
|
202
|
+
readonly status: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface StatusCascadeChange {
|
|
206
|
+
readonly kind: NodeKind;
|
|
207
|
+
readonly id: string;
|
|
208
|
+
readonly parentId?: string;
|
|
209
|
+
readonly previousStatus: string;
|
|
210
|
+
readonly nextStatus: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface StatusCascadeBlocker {
|
|
214
|
+
readonly sourceId: string;
|
|
215
|
+
readonly sourceKind: DependencyNodeKind;
|
|
216
|
+
readonly dependsOnId: string;
|
|
217
|
+
readonly dependsOnKind: DependencyNodeKind;
|
|
218
|
+
readonly dependsOnStatus: string;
|
|
219
|
+
readonly inScope: boolean;
|
|
220
|
+
readonly willCascade: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface StatusCascadeCounts {
|
|
224
|
+
readonly scope: number;
|
|
225
|
+
readonly changed: number;
|
|
226
|
+
readonly unchanged: number;
|
|
227
|
+
readonly blockers: number;
|
|
228
|
+
readonly changedEpics: number;
|
|
229
|
+
readonly changedTasks: number;
|
|
230
|
+
readonly changedSubtasks: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface StatusCascadePlan {
|
|
234
|
+
readonly rootKind: StatusCascadeRootKind;
|
|
235
|
+
readonly rootId: string;
|
|
236
|
+
readonly targetStatus: string;
|
|
237
|
+
readonly atomic: true;
|
|
238
|
+
readonly scope: ReadonlyArray<StatusCascadeScopeNode>;
|
|
239
|
+
readonly orderedChanges: ReadonlyArray<StatusCascadeChange>;
|
|
240
|
+
readonly changedIds: ReadonlyArray<string>;
|
|
241
|
+
readonly unchangedIds: ReadonlyArray<string>;
|
|
242
|
+
readonly blockers: ReadonlyArray<StatusCascadeBlocker>;
|
|
243
|
+
readonly counts: StatusCascadeCounts;
|
|
244
|
+
}
|
|
245
|
+
|
|
195
246
|
export interface DomainErrorShape {
|
|
196
247
|
readonly code: string;
|
|
197
248
|
readonly message: string;
|