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