trekoon 0.2.9 → 0.3.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 +162 -26
- package/README.md +18 -15
- package/docs/ai-agents.md +49 -4
- package/docs/commands.md +90 -16
- package/docs/machine-contracts.md +120 -0
- package/docs/plans/r1-unified-skill-rewrite.md +290 -0
- package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
- package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
- package/docs/quickstart.md +41 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +1 -0
- package/src/board/assets/components/EpicRow.js +21 -6
- package/src/board/assets/components/EpicsOverview.js +5 -1
- package/src/board/assets/components/Notice.js +19 -12
- package/src/board/assets/components/Workspace.js +16 -5
- package/src/board/assets/components/helpers.js +17 -0
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +33 -0
- package/src/board/assets/state/actions.js +68 -0
- package/src/board/assets/state/store.js +1 -0
- package/src/board/assets/styles/board.css +156 -36
- package/src/board/routes.ts +2 -0
- package/src/commands/epic.ts +74 -3
- package/src/commands/session.ts +7 -75
- package/src/commands/subtask.ts +7 -5
- package/src/commands/suggest.ts +283 -0
- package/src/commands/sync-helpers.ts +75 -0
- package/src/commands/task-readiness.ts +8 -20
- package/src/commands/task.ts +59 -3
- package/src/domain/mutation-service.ts +69 -42
- package/src/domain/tracker-domain.ts +151 -22
- package/src/domain/types.ts +12 -0
- package/src/index.ts +1 -1
- package/src/io/output.ts +4 -2
- package/src/runtime/cli-shell.ts +26 -3
- package/src/runtime/command-types.ts +1 -1
- package/src/storage/database.ts +43 -1
- package/src/storage/events-retention.ts +57 -8
- package/src/storage/migrations.ts +58 -3
- package/src/sync/service.ts +101 -24
- package/src/sync/types.ts +1 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
|
+
import { writeTransaction } from "../storage/database";
|
|
3
4
|
import { appendEventWithGitContext } from "../sync/event-writes";
|
|
4
5
|
import { ENTITY_OPERATIONS } from "./mutation-operations";
|
|
5
|
-
import { TrackerDomain } from "./tracker-domain";
|
|
6
|
+
import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
|
|
6
7
|
import {
|
|
7
8
|
type CompactEpicCreateResult,
|
|
8
9
|
type CompactEpicExpandResult,
|
|
@@ -104,7 +105,7 @@ export class MutationService {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
|
|
107
|
-
return this.#db
|
|
108
|
+
return writeTransaction(this.#db, (): EpicRecord => {
|
|
108
109
|
const epic = this.#domain.createEpic(input);
|
|
109
110
|
this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
|
|
110
111
|
title: epic.title,
|
|
@@ -112,7 +113,7 @@ export class MutationService {
|
|
|
112
113
|
status: epic.status,
|
|
113
114
|
});
|
|
114
115
|
return epic;
|
|
115
|
-
})
|
|
116
|
+
});
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
createEpicGraph(input: {
|
|
@@ -123,7 +124,7 @@ export class MutationService {
|
|
|
123
124
|
subtaskSpecs: readonly CompactSubtaskSpec[];
|
|
124
125
|
dependencySpecs: readonly CompactDependencySpec[];
|
|
125
126
|
}): CompactEpicCreateResult {
|
|
126
|
-
return this.#db
|
|
127
|
+
return writeTransaction(this.#db, (): CompactEpicCreateResult => {
|
|
127
128
|
const epic = this.#domain.createEpic(input);
|
|
128
129
|
const created = this.#domain.expandEpic({
|
|
129
130
|
epicId: epic.id,
|
|
@@ -172,14 +173,18 @@ export class MutationService {
|
|
|
172
173
|
dependencies: created.dependencies,
|
|
173
174
|
result: created.result,
|
|
174
175
|
};
|
|
175
|
-
})
|
|
176
|
+
});
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
updateEpic(
|
|
179
180
|
id: string,
|
|
180
181
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
181
182
|
): EpicRecord {
|
|
182
|
-
return this.#db
|
|
183
|
+
return writeTransaction(this.#db, (): EpicRecord => {
|
|
184
|
+
if (input.status !== undefined) {
|
|
185
|
+
const existing = this.#domain.getEpicOrThrow(id);
|
|
186
|
+
validateStatusTransition(existing.status, input.status, "epic", id);
|
|
187
|
+
}
|
|
183
188
|
const epic = this.#domain.updateEpic(id, input);
|
|
184
189
|
this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
|
|
185
190
|
title: epic.title,
|
|
@@ -187,27 +192,27 @@ export class MutationService {
|
|
|
187
192
|
status: epic.status,
|
|
188
193
|
});
|
|
189
194
|
return epic;
|
|
190
|
-
})
|
|
195
|
+
});
|
|
191
196
|
}
|
|
192
197
|
|
|
193
198
|
updateEpicStatusCascade(id: string, status: string): StatusCascadePlan {
|
|
194
|
-
return this.#db
|
|
199
|
+
return writeTransaction(this.#db, (): StatusCascadePlan => {
|
|
195
200
|
const plan = this.#domain.planStatusCascade("epic", id, status);
|
|
196
201
|
this.#assertCascadeNotBlocked(plan);
|
|
197
202
|
this.#applyStatusCascadePlan(plan);
|
|
198
203
|
return plan;
|
|
199
|
-
})
|
|
204
|
+
});
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
deleteEpic(id: string): void {
|
|
203
|
-
this.#db
|
|
208
|
+
writeTransaction(this.#db, (): void => {
|
|
204
209
|
this.#domain.deleteEpic(id);
|
|
205
210
|
this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
|
|
206
|
-
})
|
|
211
|
+
});
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
|
|
210
|
-
return this.#db
|
|
215
|
+
return writeTransaction(this.#db, (): TaskRecord => {
|
|
211
216
|
const task = this.#domain.createTask(input);
|
|
212
217
|
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
|
|
213
218
|
epic_id: task.epicId,
|
|
@@ -216,11 +221,11 @@ export class MutationService {
|
|
|
216
221
|
status: task.status,
|
|
217
222
|
});
|
|
218
223
|
return task;
|
|
219
|
-
})
|
|
224
|
+
});
|
|
220
225
|
}
|
|
221
226
|
|
|
222
227
|
createTaskBatch(input: { epicId: string; specs: readonly CompactTaskSpec[] }): CompactTaskBatchCreateResult {
|
|
223
|
-
return this.#db
|
|
228
|
+
return writeTransaction(this.#db, (): CompactTaskBatchCreateResult => {
|
|
224
229
|
const created = this.#domain.createTaskBatch(input);
|
|
225
230
|
for (const task of created.tasks) {
|
|
226
231
|
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
|
|
@@ -231,7 +236,7 @@ export class MutationService {
|
|
|
231
236
|
});
|
|
232
237
|
}
|
|
233
238
|
return created;
|
|
234
|
-
})
|
|
239
|
+
});
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
expandEpic(input: {
|
|
@@ -240,7 +245,7 @@ export class MutationService {
|
|
|
240
245
|
subtaskSpecs: readonly CompactSubtaskSpec[];
|
|
241
246
|
dependencySpecs: readonly CompactDependencySpec[];
|
|
242
247
|
}): CompactEpicExpandResult {
|
|
243
|
-
return this.#db
|
|
248
|
+
return writeTransaction(this.#db, (): CompactEpicExpandResult => {
|
|
244
249
|
const created = this.#domain.expandEpic(input);
|
|
245
250
|
for (const task of created.tasks) {
|
|
246
251
|
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
|
|
@@ -270,39 +275,44 @@ export class MutationService {
|
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
return created;
|
|
273
|
-
})
|
|
278
|
+
});
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
updateTask(
|
|
277
282
|
id: string,
|
|
278
|
-
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
283
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
279
284
|
): TaskRecord {
|
|
280
|
-
return this.#db
|
|
285
|
+
return writeTransaction(this.#db, (): TaskRecord => {
|
|
286
|
+
if (input.status !== undefined) {
|
|
287
|
+
const existing = this.#domain.getTaskOrThrow(id);
|
|
288
|
+
validateStatusTransition(existing.status, input.status, "task", id);
|
|
289
|
+
}
|
|
281
290
|
const task = this.#domain.updateTask(id, input);
|
|
282
291
|
this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
|
|
283
292
|
epic_id: task.epicId,
|
|
284
293
|
title: task.title,
|
|
285
294
|
description: task.description,
|
|
286
295
|
status: task.status,
|
|
296
|
+
owner: task.owner,
|
|
287
297
|
});
|
|
288
298
|
return task;
|
|
289
|
-
})
|
|
299
|
+
});
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
|
|
293
|
-
return this.#db
|
|
303
|
+
return writeTransaction(this.#db, (): StatusCascadePlan => {
|
|
294
304
|
const plan = this.#domain.planStatusCascade("task", id, status);
|
|
295
305
|
this.#assertCascadeNotBlocked(plan);
|
|
296
306
|
this.#applyStatusCascadePlan(plan);
|
|
297
307
|
return plan;
|
|
298
|
-
})
|
|
308
|
+
});
|
|
299
309
|
}
|
|
300
310
|
|
|
301
311
|
deleteTask(id: string): void {
|
|
302
|
-
this.#db
|
|
312
|
+
writeTransaction(this.#db, (): void => {
|
|
303
313
|
this.#domain.deleteTask(id);
|
|
304
314
|
this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
|
|
305
|
-
})
|
|
315
|
+
});
|
|
306
316
|
}
|
|
307
317
|
|
|
308
318
|
createSubtask(input: {
|
|
@@ -311,7 +321,7 @@ export class MutationService {
|
|
|
311
321
|
description?: string | undefined;
|
|
312
322
|
status?: string | undefined;
|
|
313
323
|
}): SubtaskRecord {
|
|
314
|
-
return this.#db
|
|
324
|
+
return writeTransaction(this.#db, (): SubtaskRecord => {
|
|
315
325
|
const subtask = this.#domain.createSubtask(input);
|
|
316
326
|
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
|
|
317
327
|
task_id: subtask.taskId,
|
|
@@ -320,11 +330,11 @@ export class MutationService {
|
|
|
320
330
|
status: subtask.status,
|
|
321
331
|
});
|
|
322
332
|
return subtask;
|
|
323
|
-
})
|
|
333
|
+
});
|
|
324
334
|
}
|
|
325
335
|
|
|
326
336
|
createSubtaskBatch(input: { taskId: string; specs: readonly CompactSubtaskSpec[] }): CompactSubtaskBatchCreateResult {
|
|
327
|
-
return this.#db
|
|
337
|
+
return writeTransaction(this.#db, (): CompactSubtaskBatchCreateResult => {
|
|
328
338
|
const created = this.#domain.createSubtaskBatch(input);
|
|
329
339
|
for (const subtask of created.subtasks) {
|
|
330
340
|
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
|
|
@@ -335,34 +345,39 @@ export class MutationService {
|
|
|
335
345
|
});
|
|
336
346
|
}
|
|
337
347
|
return created;
|
|
338
|
-
})
|
|
348
|
+
});
|
|
339
349
|
}
|
|
340
350
|
|
|
341
351
|
updateSubtask(
|
|
342
352
|
id: string,
|
|
343
|
-
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
353
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
344
354
|
): SubtaskRecord {
|
|
345
|
-
return this.#db
|
|
355
|
+
return writeTransaction(this.#db, (): SubtaskRecord => {
|
|
356
|
+
if (input.status !== undefined) {
|
|
357
|
+
const existing = this.#domain.getSubtaskOrThrow(id);
|
|
358
|
+
validateStatusTransition(existing.status, input.status, "subtask", id);
|
|
359
|
+
}
|
|
346
360
|
const subtask = this.#domain.updateSubtask(id, input);
|
|
347
361
|
this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
|
|
348
362
|
task_id: subtask.taskId,
|
|
349
363
|
title: subtask.title,
|
|
350
364
|
description: subtask.description,
|
|
351
365
|
status: subtask.status,
|
|
366
|
+
owner: subtask.owner,
|
|
352
367
|
});
|
|
353
368
|
return subtask;
|
|
354
|
-
})
|
|
369
|
+
});
|
|
355
370
|
}
|
|
356
371
|
|
|
357
372
|
deleteSubtask(id: string): void {
|
|
358
|
-
this.#db
|
|
373
|
+
writeTransaction(this.#db, (): void => {
|
|
359
374
|
this.#domain.deleteSubtask(id);
|
|
360
375
|
this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
|
|
361
|
-
})
|
|
376
|
+
});
|
|
362
377
|
}
|
|
363
378
|
|
|
364
379
|
addDependency(sourceId: string, dependsOnId: string): DependencyRecord {
|
|
365
|
-
return this.#db
|
|
380
|
+
return writeTransaction(this.#db, (): DependencyRecord => {
|
|
366
381
|
const dependency = this.#domain.addDependency(sourceId, dependsOnId);
|
|
367
382
|
this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
|
|
368
383
|
source_id: dependency.sourceId,
|
|
@@ -371,11 +386,11 @@ export class MutationService {
|
|
|
371
386
|
depends_on_kind: dependency.dependsOnKind,
|
|
372
387
|
});
|
|
373
388
|
return dependency;
|
|
374
|
-
})
|
|
389
|
+
});
|
|
375
390
|
}
|
|
376
391
|
|
|
377
392
|
addDependencyBatch(input: { specs: readonly CompactDependencySpec[] }): CompactDependencyBatchAddResult {
|
|
378
|
-
return this.#db
|
|
393
|
+
return writeTransaction(this.#db, (): CompactDependencyBatchAddResult => {
|
|
379
394
|
const created = this.#domain.addDependencyBatch(input);
|
|
380
395
|
for (const dependency of created.dependencies) {
|
|
381
396
|
this.#appendEntityEvent("dependency", dependency.id, ENTITY_OPERATIONS.dependency.added, {
|
|
@@ -386,11 +401,11 @@ export class MutationService {
|
|
|
386
401
|
});
|
|
387
402
|
}
|
|
388
403
|
return created;
|
|
389
|
-
})
|
|
404
|
+
});
|
|
390
405
|
}
|
|
391
406
|
|
|
392
407
|
removeDependency(sourceId: string, dependsOnId: string): number {
|
|
393
|
-
return this.#db
|
|
408
|
+
return writeTransaction(this.#db, (): number => {
|
|
394
409
|
const removed = this.#domain.removeDependency(sourceId, dependsOnId);
|
|
395
410
|
if (removed > 0) {
|
|
396
411
|
this.#appendEntityEvent("dependency", `${sourceId}->${dependsOnId}`, ENTITY_OPERATIONS.dependency.removed, {
|
|
@@ -399,11 +414,19 @@ export class MutationService {
|
|
|
399
414
|
});
|
|
400
415
|
}
|
|
401
416
|
return removed;
|
|
402
|
-
})
|
|
417
|
+
});
|
|
403
418
|
}
|
|
404
419
|
|
|
405
420
|
describeError(error: unknown): string | undefined {
|
|
406
|
-
if (!(error instanceof DomainError)
|
|
421
|
+
if (!(error instanceof DomainError)) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (error.code === "status_transition_invalid") {
|
|
426
|
+
return error.message;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (error.code !== "dependency_blocked") {
|
|
407
430
|
return undefined;
|
|
408
431
|
}
|
|
409
432
|
|
|
@@ -560,6 +583,7 @@ export class MutationService {
|
|
|
560
583
|
title: task.title,
|
|
561
584
|
description: task.description,
|
|
562
585
|
status: task.status,
|
|
586
|
+
owner: task.owner,
|
|
563
587
|
});
|
|
564
588
|
continue;
|
|
565
589
|
}
|
|
@@ -570,6 +594,7 @@ export class MutationService {
|
|
|
570
594
|
title: subtask.title,
|
|
571
595
|
description: subtask.description,
|
|
572
596
|
status: subtask.status,
|
|
597
|
+
owner: subtask.owner,
|
|
573
598
|
});
|
|
574
599
|
}
|
|
575
600
|
}
|
|
@@ -582,7 +607,7 @@ export class MutationService {
|
|
|
582
607
|
): ScopeReplacementResult {
|
|
583
608
|
const result = this.#buildScopeReplacementResult(nodes, searchText, replacementText, fields, "apply");
|
|
584
609
|
|
|
585
|
-
this.#db
|
|
610
|
+
writeTransaction(this.#db, (): void => {
|
|
586
611
|
for (const node of nodes) {
|
|
587
612
|
const nextTitle = fields.includes("title") ? replaceMatches(node.title, searchText, replacementText) : node.title;
|
|
588
613
|
const nextDescription = fields.includes("description")
|
|
@@ -610,6 +635,7 @@ export class MutationService {
|
|
|
610
635
|
title: task.title,
|
|
611
636
|
description: task.description,
|
|
612
637
|
status: task.status,
|
|
638
|
+
owner: task.owner,
|
|
613
639
|
});
|
|
614
640
|
continue;
|
|
615
641
|
}
|
|
@@ -620,9 +646,10 @@ export class MutationService {
|
|
|
620
646
|
title: subtask.title,
|
|
621
647
|
description: subtask.description,
|
|
622
648
|
status: subtask.status,
|
|
649
|
+
owner: subtask.owner,
|
|
623
650
|
});
|
|
624
651
|
}
|
|
625
|
-
})
|
|
652
|
+
});
|
|
626
653
|
|
|
627
654
|
return result;
|
|
628
655
|
}
|
|
@@ -32,10 +32,13 @@ import {
|
|
|
32
32
|
type SubtaskRecord,
|
|
33
33
|
type TaskTreeDetailed,
|
|
34
34
|
type TaskRecord,
|
|
35
|
+
VALID_STATUSES,
|
|
36
|
+
VALID_TRANSITIONS,
|
|
37
|
+
type ValidStatus,
|
|
35
38
|
} from "./types";
|
|
36
39
|
|
|
37
40
|
const DEFAULT_STATUS = "todo";
|
|
38
|
-
const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "
|
|
41
|
+
const DEPENDENCY_GATED_STATUSES = new Set<string>(["in_progress", "done"]);
|
|
39
42
|
|
|
40
43
|
interface EpicRow {
|
|
41
44
|
id: string;
|
|
@@ -48,10 +51,12 @@ interface EpicRow {
|
|
|
48
51
|
|
|
49
52
|
interface TaskRow extends EpicRow {
|
|
50
53
|
epic_id: string;
|
|
54
|
+
owner: string | null;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
interface SubtaskRow extends EpicRow {
|
|
54
58
|
task_id: string;
|
|
59
|
+
owner: string | null;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
interface DependencyRow {
|
|
@@ -146,6 +151,45 @@ function normalizeSubtaskDescription(value: string | undefined): string {
|
|
|
146
151
|
return value.trim();
|
|
147
152
|
}
|
|
148
153
|
|
|
154
|
+
function isValidStatus(status: string): status is ValidStatus {
|
|
155
|
+
return (VALID_STATUSES as readonly string[]).includes(status);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function validateStatusTransition(fromStatus: string, toStatus: string, entityKind: string, entityId: string): void {
|
|
159
|
+
if (fromStatus === toStatus) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!isValidStatus(toStatus)) {
|
|
164
|
+
throw new DomainError({
|
|
165
|
+
code: "status_transition_invalid",
|
|
166
|
+
message: `invalid status '${toStatus}' for ${entityKind} ${entityId}; allowed statuses: ${VALID_STATUSES.join(", ")}`,
|
|
167
|
+
details: { entity: entityKind, id: entityId, fromStatus, toStatus, allowedStatuses: [...VALID_STATUSES] },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!isValidStatus(fromStatus)) {
|
|
172
|
+
// Legacy/custom status from pre-0.3.1 data; allow transition to any valid
|
|
173
|
+
// status so existing databases can migrate forward without manual fixups.
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const allowed = VALID_TRANSITIONS.get(fromStatus);
|
|
178
|
+
if (!allowed || !allowed.has(toStatus)) {
|
|
179
|
+
throw new DomainError({
|
|
180
|
+
code: "status_transition_invalid",
|
|
181
|
+
message: `cannot transition ${entityKind} ${entityId} from '${fromStatus}' to '${toStatus}'`,
|
|
182
|
+
details: {
|
|
183
|
+
entity: entityKind,
|
|
184
|
+
id: entityId,
|
|
185
|
+
fromStatus,
|
|
186
|
+
toStatus,
|
|
187
|
+
allowedTransitions: allowed ? [...allowed] : [],
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
149
193
|
function mapEpic(row: EpicRow): EpicRecord {
|
|
150
194
|
return {
|
|
151
195
|
id: row.id,
|
|
@@ -164,6 +208,7 @@ function mapTask(row: TaskRow): TaskRecord {
|
|
|
164
208
|
title: row.title,
|
|
165
209
|
description: row.description,
|
|
166
210
|
status: row.status,
|
|
211
|
+
owner: row.owner ?? null,
|
|
167
212
|
createdAt: row.created_at,
|
|
168
213
|
updatedAt: row.updated_at,
|
|
169
214
|
};
|
|
@@ -176,6 +221,7 @@ function mapSubtask(row: SubtaskRow): SubtaskRecord {
|
|
|
176
221
|
title: row.title,
|
|
177
222
|
description: row.description,
|
|
178
223
|
status: row.status,
|
|
224
|
+
owner: row.owner ?? null,
|
|
179
225
|
createdAt: row.created_at,
|
|
180
226
|
updatedAt: row.updated_at,
|
|
181
227
|
};
|
|
@@ -376,21 +422,21 @@ export class TrackerDomain {
|
|
|
376
422
|
this.getEpicOrThrow(epicId);
|
|
377
423
|
const rows = this.#db
|
|
378
424
|
.query(
|
|
379
|
-
"SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
|
|
425
|
+
"SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE epic_id = ? ORDER BY created_at ASC, id ASC;",
|
|
380
426
|
)
|
|
381
427
|
.all(epicId) as TaskRow[];
|
|
382
428
|
return rows.map(mapTask);
|
|
383
429
|
}
|
|
384
430
|
|
|
385
431
|
const rows = this.#db
|
|
386
|
-
.query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
|
|
432
|
+
.query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks ORDER BY created_at ASC, id ASC;")
|
|
387
433
|
.all() as TaskRow[];
|
|
388
434
|
return rows.map(mapTask);
|
|
389
435
|
}
|
|
390
436
|
|
|
391
437
|
getTask(id: string): TaskRecord | null {
|
|
392
438
|
const row = this.#db
|
|
393
|
-
.query("SELECT id, epic_id, title, description, status, created_at, updated_at FROM tasks WHERE id = ?;")
|
|
439
|
+
.query("SELECT id, epic_id, title, description, status, owner, created_at, updated_at FROM tasks WHERE id = ?;")
|
|
394
440
|
.get(id) as TaskRow | null;
|
|
395
441
|
return row ? mapTask(row) : null;
|
|
396
442
|
}
|
|
@@ -410,19 +456,20 @@ export class TrackerDomain {
|
|
|
410
456
|
|
|
411
457
|
updateTask(
|
|
412
458
|
id: string,
|
|
413
|
-
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
459
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
414
460
|
): TaskRecord {
|
|
415
461
|
const existing: TaskRecord = this.getTaskOrThrow(id);
|
|
416
462
|
const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
|
|
417
463
|
const nextDescription: string =
|
|
418
464
|
input.description !== undefined ? assertNonEmpty("description", input.description) : existing.description;
|
|
419
465
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
466
|
+
const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
|
|
420
467
|
this.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
|
|
421
468
|
const now: number = Date.now();
|
|
422
469
|
|
|
423
470
|
this.#db
|
|
424
|
-
.query("UPDATE tasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
425
|
-
.run(nextTitle, nextDescription, nextStatus, now, id);
|
|
471
|
+
.query("UPDATE tasks SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
472
|
+
.run(nextTitle, nextDescription, nextStatus, nextOwner, now, id);
|
|
426
473
|
|
|
427
474
|
return this.getTaskOrThrow(id);
|
|
428
475
|
}
|
|
@@ -541,7 +588,7 @@ export class TrackerDomain {
|
|
|
541
588
|
this.getTaskOrThrow(taskId);
|
|
542
589
|
const rows = this.#db
|
|
543
590
|
.query(
|
|
544
|
-
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
|
|
591
|
+
"SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? ORDER BY created_at ASC, id ASC;",
|
|
545
592
|
)
|
|
546
593
|
.all(taskId) as SubtaskRow[];
|
|
547
594
|
return rows.map(mapSubtask);
|
|
@@ -549,15 +596,25 @@ export class TrackerDomain {
|
|
|
549
596
|
|
|
550
597
|
const rows = this.#db
|
|
551
598
|
.query(
|
|
552
|
-
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
|
|
599
|
+
"SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks ORDER BY created_at ASC, id ASC;",
|
|
553
600
|
)
|
|
554
601
|
.all() as SubtaskRow[];
|
|
555
602
|
return rows.map(mapSubtask);
|
|
556
603
|
}
|
|
557
604
|
|
|
605
|
+
getOpenSubtasks(taskId: string): readonly SubtaskRecord[] {
|
|
606
|
+
this.getTaskOrThrow(taskId);
|
|
607
|
+
const rows = this.#db
|
|
608
|
+
.query(
|
|
609
|
+
"SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id = ? AND status != 'done' ORDER BY created_at ASC, id ASC;",
|
|
610
|
+
)
|
|
611
|
+
.all(taskId) as SubtaskRow[];
|
|
612
|
+
return rows.map(mapSubtask);
|
|
613
|
+
}
|
|
614
|
+
|
|
558
615
|
getSubtask(id: string): SubtaskRecord | null {
|
|
559
616
|
const row = this.#db
|
|
560
|
-
.query("SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE id = ?;")
|
|
617
|
+
.query("SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE id = ?;")
|
|
561
618
|
.get(id) as SubtaskRow | null;
|
|
562
619
|
return row ? mapSubtask(row) : null;
|
|
563
620
|
}
|
|
@@ -577,19 +634,20 @@ export class TrackerDomain {
|
|
|
577
634
|
|
|
578
635
|
updateSubtask(
|
|
579
636
|
id: string,
|
|
580
|
-
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
637
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
581
638
|
): SubtaskRecord {
|
|
582
639
|
const existing: SubtaskRecord = this.getSubtaskOrThrow(id);
|
|
583
640
|
const nextTitle: string = input.title !== undefined ? assertNonEmpty("title", input.title) : existing.title;
|
|
584
641
|
const nextDescription: string =
|
|
585
642
|
input.description !== undefined ? normalizeSubtaskDescription(input.description) : existing.description;
|
|
586
643
|
const nextStatus: string = input.status !== undefined ? assertNonEmpty("status", input.status) : existing.status;
|
|
644
|
+
const nextOwner: string | null = input.owner !== undefined ? input.owner : existing.owner;
|
|
587
645
|
this.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
|
|
588
646
|
const now: number = Date.now();
|
|
589
647
|
|
|
590
648
|
this.#db
|
|
591
|
-
.query("UPDATE subtasks SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
592
|
-
.run(nextTitle, nextDescription, nextStatus, now, id);
|
|
649
|
+
.query("UPDATE subtasks SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
650
|
+
.run(nextTitle, nextDescription, nextStatus, nextOwner, now, id);
|
|
593
651
|
|
|
594
652
|
return this.getSubtaskOrThrow(id);
|
|
595
653
|
}
|
|
@@ -605,7 +663,7 @@ export class TrackerDomain {
|
|
|
605
663
|
const taskIds = new Set(tasks.map((task) => task.id));
|
|
606
664
|
const subtasks = this.#db
|
|
607
665
|
.query(
|
|
608
|
-
"SELECT id, task_id, title, description, status, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
|
|
666
|
+
"SELECT id, task_id, title, description, status, owner, created_at, updated_at FROM subtasks WHERE task_id IN (SELECT id FROM tasks WHERE epic_id = ?) ORDER BY created_at ASC, id ASC;",
|
|
609
667
|
)
|
|
610
668
|
.all(epicId) as SubtaskRow[];
|
|
611
669
|
|
|
@@ -921,6 +979,65 @@ export class TrackerDomain {
|
|
|
921
979
|
return rows.map(mapDependency);
|
|
922
980
|
}
|
|
923
981
|
|
|
982
|
+
/**
|
|
983
|
+
* Resolves dependency statuses for multiple tasks using a single prepared
|
|
984
|
+
* statement executed once per task ID. This avoids the previous N+1 pattern
|
|
985
|
+
* where each task required separate getTaskOrThrow/getSubtaskOrThrow calls
|
|
986
|
+
* per dependency.
|
|
987
|
+
*/
|
|
988
|
+
batchResolveDependencyStatuses(
|
|
989
|
+
taskIds: readonly string[],
|
|
990
|
+
): Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }> {
|
|
991
|
+
const result = new Map<string, { totalDependencies: number; blockers: Array<{ id: string; kind: "task" | "subtask"; status: string }> }>();
|
|
992
|
+
|
|
993
|
+
if (taskIds.length === 0) {
|
|
994
|
+
return result;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Use a static parameterised query per task ID rather than interpolating
|
|
998
|
+
// a dynamic IN-list into the SQL string. This is consistent with every
|
|
999
|
+
// other query in TrackerDomain and avoids any placeholder-count confusion.
|
|
1000
|
+
const stmt = this.#db.query(
|
|
1001
|
+
`SELECT d.source_id, d.depends_on_id, d.depends_on_kind, COALESCE(t.status, s.status) AS dep_status
|
|
1002
|
+
FROM dependencies d
|
|
1003
|
+
LEFT JOIN tasks t ON d.depends_on_kind = 'task' AND d.depends_on_id = t.id
|
|
1004
|
+
LEFT JOIN subtasks s ON d.depends_on_kind = 'subtask' AND d.depends_on_id = s.id
|
|
1005
|
+
WHERE d.source_id = ?
|
|
1006
|
+
ORDER BY d.created_at ASC, d.id ASC;`,
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
for (const taskId of taskIds) {
|
|
1010
|
+
const entry = { totalDependencies: 0, blockers: [] as Array<{ id: string; kind: "task" | "subtask"; status: string }> };
|
|
1011
|
+
result.set(taskId, entry);
|
|
1012
|
+
|
|
1013
|
+
const rows = stmt.all(taskId) as Array<{
|
|
1014
|
+
source_id: string;
|
|
1015
|
+
depends_on_id: string;
|
|
1016
|
+
depends_on_kind: "task" | "subtask";
|
|
1017
|
+
dep_status: string | null;
|
|
1018
|
+
}>;
|
|
1019
|
+
|
|
1020
|
+
for (const row of rows) {
|
|
1021
|
+
entry.totalDependencies += 1;
|
|
1022
|
+
|
|
1023
|
+
// Skip orphaned dependency rows (target deleted).
|
|
1024
|
+
if (row.dep_status === null) {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (row.dep_status !== "done") {
|
|
1029
|
+
entry.blockers.push({
|
|
1030
|
+
id: row.depends_on_id,
|
|
1031
|
+
kind: row.depends_on_kind,
|
|
1032
|
+
status: row.dep_status,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return result;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
924
1041
|
listReverseDependencies(nodeId: string): readonly ReverseDependencyNode[] {
|
|
925
1042
|
const normalizedNodeId: string = assertNonEmpty("nodeId", nodeId);
|
|
926
1043
|
this.resolveNodeKind(normalizedNodeId);
|
|
@@ -1492,10 +1609,17 @@ export class TrackerDomain {
|
|
|
1492
1609
|
}
|
|
1493
1610
|
|
|
1494
1611
|
for (const dependency of this.listDependencies(change.id)) {
|
|
1495
|
-
const
|
|
1612
|
+
const dependencyNode =
|
|
1496
1613
|
dependency.dependsOnKind === "task"
|
|
1497
|
-
? this.
|
|
1498
|
-
: this.
|
|
1614
|
+
? this.getTask(dependency.dependsOnId)
|
|
1615
|
+
: this.getSubtask(dependency.dependsOnId);
|
|
1616
|
+
|
|
1617
|
+
// Skip orphaned dependency rows where the referenced node no longer exists.
|
|
1618
|
+
if (!dependencyNode) {
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const dependencyStatus = dependencyNode.status;
|
|
1499
1623
|
const inScope = scopeIdSet.has(dependency.dependsOnId);
|
|
1500
1624
|
const willCascade = targetStatus === "done" && changedIdSet.has(dependency.dependsOnId);
|
|
1501
1625
|
if (dependencyStatus === "done" || willCascade) {
|
|
@@ -1560,19 +1684,24 @@ export class TrackerDomain {
|
|
|
1560
1684
|
const unresolved: UnresolvedDependencyBlocker[] = [];
|
|
1561
1685
|
|
|
1562
1686
|
for (const dependency of dependencies) {
|
|
1563
|
-
const
|
|
1687
|
+
const dependencyNode =
|
|
1564
1688
|
dependency.dependsOnKind === "task"
|
|
1565
|
-
? this.
|
|
1566
|
-
: this.
|
|
1689
|
+
? this.getTask(dependency.dependsOnId)
|
|
1690
|
+
: this.getSubtask(dependency.dependsOnId);
|
|
1691
|
+
|
|
1692
|
+
// Skip orphaned dependency rows where the referenced node no longer exists.
|
|
1693
|
+
if (!dependencyNode) {
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1567
1696
|
|
|
1568
|
-
if (
|
|
1697
|
+
if (dependencyNode.status === "done") {
|
|
1569
1698
|
continue;
|
|
1570
1699
|
}
|
|
1571
1700
|
|
|
1572
1701
|
unresolved.push({
|
|
1573
1702
|
id: dependency.dependsOnId,
|
|
1574
1703
|
kind: dependency.dependsOnKind,
|
|
1575
|
-
status:
|
|
1704
|
+
status: dependencyNode.status,
|
|
1576
1705
|
});
|
|
1577
1706
|
}
|
|
1578
1707
|
|
package/src/domain/types.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export type NodeKind = "epic" | "task" | "subtask";
|
|
2
2
|
|
|
3
|
+
export const VALID_STATUSES = ["todo", "in_progress", "done", "blocked"] as const;
|
|
4
|
+
export type ValidStatus = (typeof VALID_STATUSES)[number];
|
|
5
|
+
|
|
6
|
+
export const VALID_TRANSITIONS: ReadonlyMap<ValidStatus, ReadonlySet<ValidStatus>> = new Map<ValidStatus, ReadonlySet<ValidStatus>>([
|
|
7
|
+
["todo", new Set<ValidStatus>(["in_progress", "blocked"])],
|
|
8
|
+
["in_progress", new Set<ValidStatus>(["done", "blocked"])],
|
|
9
|
+
["blocked", new Set<ValidStatus>(["in_progress", "todo"])],
|
|
10
|
+
["done", new Set<ValidStatus>(["in_progress"])],
|
|
11
|
+
]);
|
|
12
|
+
|
|
3
13
|
export const COMPACT_TEMP_KEY_PREFIX = "@";
|
|
4
14
|
|
|
5
15
|
export type CompactTempKey = string;
|
|
@@ -95,6 +105,7 @@ export interface TaskRecord {
|
|
|
95
105
|
readonly title: string;
|
|
96
106
|
readonly description: string;
|
|
97
107
|
readonly status: string;
|
|
108
|
+
readonly owner: string | null;
|
|
98
109
|
readonly createdAt: number;
|
|
99
110
|
readonly updatedAt: number;
|
|
100
111
|
}
|
|
@@ -105,6 +116,7 @@ export interface SubtaskRecord {
|
|
|
105
116
|
readonly title: string;
|
|
106
117
|
readonly description: string;
|
|
107
118
|
readonly status: string;
|
|
119
|
+
readonly owner: string | null;
|
|
108
120
|
readonly createdAt: number;
|
|
109
121
|
readonly updatedAt: number;
|
|
110
122
|
}
|