trekoon 0.4.0 → 0.4.2
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 +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- 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 +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -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 +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -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 +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- 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 +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- 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 +650 -147
package/src/commands/task.ts
CHANGED
|
@@ -47,6 +47,7 @@ const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
|
47
47
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
48
48
|
const CREATE_MANY_OPTIONS = ["epic", "e", "task"] as const;
|
|
49
49
|
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t", "owner"] as const;
|
|
50
|
+
const CLAIM_OPTIONS = ["owner"] as const;
|
|
50
51
|
const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
|
|
51
52
|
|
|
52
53
|
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
@@ -1179,10 +1180,9 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1179
1180
|
|
|
1180
1181
|
const targets = updateAll ? [...domain.listTasks()] : ids.map((id) => domain.getTaskOrThrow(id));
|
|
1181
1182
|
const tasks = targets.map((target) =>
|
|
1182
|
-
|
|
1183
|
-
status
|
|
1184
|
-
|
|
1185
|
-
}),
|
|
1183
|
+
append !== undefined
|
|
1184
|
+
? mutations.appendToTaskDescription({ taskId: target.id, append, status })
|
|
1185
|
+
: mutations.updateTask(target.id, { status }),
|
|
1186
1186
|
);
|
|
1187
1187
|
|
|
1188
1188
|
return okResult({
|
|
@@ -1208,11 +1208,10 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1208
1208
|
});
|
|
1209
1209
|
}
|
|
1210
1210
|
|
|
1211
|
-
const
|
|
1212
|
-
append
|
|
1213
|
-
?
|
|
1214
|
-
:
|
|
1215
|
-
const task = mutations.updateTask(taskId, { title, description: nextDescription, status, owner });
|
|
1211
|
+
const task =
|
|
1212
|
+
append !== undefined
|
|
1213
|
+
? mutations.appendToTaskDescription({ taskId, append, status, owner })
|
|
1214
|
+
: mutations.updateTask(taskId, { title, description, status, owner });
|
|
1216
1215
|
|
|
1217
1216
|
return okResult({
|
|
1218
1217
|
command: "task.update",
|
|
@@ -1247,98 +1246,148 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1247
1246
|
});
|
|
1248
1247
|
}
|
|
1249
1248
|
|
|
1250
|
-
if (existingTask.status === "done")
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
const
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
title: item.task.title,
|
|
1302
|
-
status: item.task.status,
|
|
1303
|
-
wasBlockedBy: [taskId],
|
|
1304
|
-
}));
|
|
1305
|
-
|
|
1306
|
-
const nextTree = nextCandidate !== null ? domain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
|
|
1307
|
-
const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
|
|
1308
|
-
|
|
1309
|
-
const readinessStats = {
|
|
1310
|
-
readyCount: readiness.summary.readyCount,
|
|
1311
|
-
blockedCount: readiness.summary.blockedCount,
|
|
1312
|
-
};
|
|
1249
|
+
// Note: the redundant `if (existingTask.status === "done")` pre-check
|
|
1250
|
+
// was removed in the cr-expert hardening pass — `markTaskDoneAtomically`
|
|
1251
|
+
// raises `already_done` itself inside the same transaction that would
|
|
1252
|
+
// perform the flip, eliminating a race window between this read and
|
|
1253
|
+
// the atomic write. The thrown DomainError propagates to the outer
|
|
1254
|
+
// catch and is rendered with the same `code: "already_done"` payload
|
|
1255
|
+
// by `unexpectedFailureResult`.
|
|
1256
|
+
|
|
1257
|
+
// Single-transaction atomic completion: the entire flow (status flip,
|
|
1258
|
+
// event emission, unblocked-diff snapshot) runs inside ONE
|
|
1259
|
+
// `BEGIN IMMEDIATE`/`COMMIT` pair. Bypasses the public transition
|
|
1260
|
+
// checker — this is the documented allowed exception (see
|
|
1261
|
+
// MutationService.markTaskDoneAtomically and docs/machine-contracts.md).
|
|
1262
|
+
// A crash mid-flight rolls the row back to its original status; the
|
|
1263
|
+
// task is never observable in a phantom `in_progress` state.
|
|
1264
|
+
const snapshot = mutations.markTaskDoneAtomically({
|
|
1265
|
+
taskId,
|
|
1266
|
+
computeSnapshot: ({ domain: txDomain, completed, preBlockedReverseDepIds }) => {
|
|
1267
|
+
const openSubtasks = txDomain.getOpenSubtasks(taskId);
|
|
1268
|
+
const readiness = buildTaskReadiness(txDomain, completed.epicId);
|
|
1269
|
+
const nextCandidate = readiness.candidates[0] ?? null;
|
|
1270
|
+
|
|
1271
|
+
const preBlockedIds = new Set<string>(preBlockedReverseDepIds);
|
|
1272
|
+
const unblockedTasks = readiness.candidates
|
|
1273
|
+
.filter((item) => preBlockedIds.has(item.task.id))
|
|
1274
|
+
.map((item) => ({
|
|
1275
|
+
id: item.task.id,
|
|
1276
|
+
kind: "task" as const,
|
|
1277
|
+
title: item.task.title,
|
|
1278
|
+
status: item.task.status,
|
|
1279
|
+
wasBlockedBy: [taskId],
|
|
1280
|
+
}));
|
|
1281
|
+
|
|
1282
|
+
const nextTree = nextCandidate !== null ? txDomain.buildTaskTreeDetailed(nextCandidate.task.id) : null;
|
|
1283
|
+
const nextDeps = nextCandidate?.blockerSummary.blockedBy ?? [];
|
|
1284
|
+
|
|
1285
|
+
return {
|
|
1286
|
+
completed,
|
|
1287
|
+
openSubtaskCount: openSubtasks.length,
|
|
1288
|
+
openSubtaskIds: openSubtasks.map((s) => s.id),
|
|
1289
|
+
unblocked: unblockedTasks,
|
|
1290
|
+
next: nextTree,
|
|
1291
|
+
nextCandidate,
|
|
1292
|
+
nextDeps,
|
|
1293
|
+
readiness: {
|
|
1294
|
+
readyCount: readiness.summary.readyCount,
|
|
1295
|
+
blockedCount: readiness.summary.blockedCount,
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
},
|
|
1299
|
+
});
|
|
1313
1300
|
|
|
1314
|
-
const subtaskWarning = openSubtaskCount > 0
|
|
1315
|
-
? `Warning: ${openSubtaskCount} subtask(s) still open.`
|
|
1301
|
+
const subtaskWarning = snapshot.openSubtaskCount > 0
|
|
1302
|
+
? `Warning: ${snapshot.openSubtaskCount} subtask(s) still open.`
|
|
1316
1303
|
: null;
|
|
1317
1304
|
|
|
1318
|
-
let human = `Task ${completed.title} marked done.`;
|
|
1305
|
+
let human = `Task ${snapshot.completed.title} marked done.`;
|
|
1319
1306
|
if (subtaskWarning !== null) {
|
|
1320
1307
|
human += `\n${subtaskWarning}`;
|
|
1321
1308
|
}
|
|
1322
|
-
if (
|
|
1323
|
-
human += `\nUnblocked: ${
|
|
1309
|
+
if (snapshot.unblocked.length > 0) {
|
|
1310
|
+
human += `\nUnblocked: ${snapshot.unblocked.map((t) => t.title).join(", ")}`;
|
|
1324
1311
|
}
|
|
1325
|
-
if (
|
|
1326
|
-
human += `\nNext: ${formatTask(nextCandidate.task)}`;
|
|
1312
|
+
if (snapshot.next !== null && snapshot.nextCandidate !== null) {
|
|
1313
|
+
human += `\nNext: ${formatTask(snapshot.nextCandidate.task)}`;
|
|
1327
1314
|
}
|
|
1328
|
-
human += `\nReadiness: ready=${
|
|
1315
|
+
human += `\nReadiness: ready=${snapshot.readiness.readyCount}, blocked=${snapshot.readiness.blockedCount}.`;
|
|
1329
1316
|
|
|
1330
1317
|
return okResult({
|
|
1331
1318
|
command: "task.done",
|
|
1332
1319
|
human,
|
|
1333
1320
|
data: {
|
|
1334
|
-
completed,
|
|
1335
|
-
openSubtaskCount,
|
|
1336
|
-
openSubtaskIds,
|
|
1321
|
+
completed: snapshot.completed,
|
|
1322
|
+
openSubtaskCount: snapshot.openSubtaskCount,
|
|
1323
|
+
openSubtaskIds: snapshot.openSubtaskIds,
|
|
1337
1324
|
warning: subtaskWarning,
|
|
1338
|
-
unblocked:
|
|
1339
|
-
next:
|
|
1340
|
-
nextDeps,
|
|
1341
|
-
readiness:
|
|
1325
|
+
unblocked: snapshot.unblocked,
|
|
1326
|
+
next: snapshot.next,
|
|
1327
|
+
nextDeps: snapshot.nextDeps,
|
|
1328
|
+
readiness: snapshot.readiness,
|
|
1329
|
+
},
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
case "claim": {
|
|
1333
|
+
const claimUnknownOption = findUnknownOption(parsed, CLAIM_OPTIONS);
|
|
1334
|
+
if (claimUnknownOption !== undefined) {
|
|
1335
|
+
return unknownOption("task.claim", claimUnknownOption, CLAIM_OPTIONS);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const missingClaimOption = readMissingOptionValue(parsed.missingOptionValues, "owner");
|
|
1339
|
+
if (missingClaimOption !== undefined) {
|
|
1340
|
+
return failMissingOptionValue("task.claim", missingClaimOption);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const taskId: string = parsed.positional[1] ?? "";
|
|
1344
|
+
if (taskId.length === 0) {
|
|
1345
|
+
return failResult({
|
|
1346
|
+
command: "task.claim",
|
|
1347
|
+
human: "Provide a task id. Usage: trekoon task claim <id> --owner <owner>",
|
|
1348
|
+
data: { code: "invalid_input" },
|
|
1349
|
+
error: {
|
|
1350
|
+
code: "invalid_input",
|
|
1351
|
+
message: "Missing task id",
|
|
1352
|
+
},
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const owner: string | undefined = readOption(parsed.options, "owner");
|
|
1357
|
+
if (owner === undefined || owner.trim().length === 0) {
|
|
1358
|
+
return failResult({
|
|
1359
|
+
command: "task.claim",
|
|
1360
|
+
human: "--owner is required. Usage: trekoon task claim <id> --owner <owner>",
|
|
1361
|
+
data: { code: "invalid_input", option: "owner" },
|
|
1362
|
+
error: {
|
|
1363
|
+
code: "invalid_input",
|
|
1364
|
+
message: "Missing required option --owner",
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const claimResult = mutations.claimTask({ taskId, owner });
|
|
1370
|
+
|
|
1371
|
+
if (claimResult.claimed) {
|
|
1372
|
+
return okResult({
|
|
1373
|
+
command: "task.claim",
|
|
1374
|
+
human: `Claimed task ${taskId} for ${owner}`,
|
|
1375
|
+
data: {
|
|
1376
|
+
claimed: true,
|
|
1377
|
+
currentOwner: claimResult.currentOwner,
|
|
1378
|
+
currentStatus: claimResult.currentStatus,
|
|
1379
|
+
task: claimResult.task,
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return okResult({
|
|
1385
|
+
command: "task.claim",
|
|
1386
|
+
human: `Task ${taskId} not claimed: status=${claimResult.currentStatus}, owner=${claimResult.currentOwner ?? "none"}`,
|
|
1387
|
+
data: {
|
|
1388
|
+
claimed: false,
|
|
1389
|
+
currentOwner: claimResult.currentOwner,
|
|
1390
|
+
currentStatus: claimResult.currentStatus,
|
|
1342
1391
|
},
|
|
1343
1392
|
});
|
|
1344
1393
|
}
|
|
@@ -1355,7 +1404,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
|
|
|
1355
1404
|
default:
|
|
1356
1405
|
return failResult({
|
|
1357
1406
|
command: "task",
|
|
1358
|
-
human: "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete>",
|
|
1407
|
+
human: "Usage: trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete|claim>",
|
|
1359
1408
|
data: {
|
|
1360
1409
|
args: context.args,
|
|
1361
1410
|
},
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompactDependencySpec,
|
|
3
|
+
type CompactEntityRef,
|
|
4
|
+
type CompactSubtaskSpec,
|
|
5
|
+
type DependencyRecord,
|
|
6
|
+
DomainError,
|
|
7
|
+
type SubtaskRecord,
|
|
8
|
+
type TaskRecord,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Shared interfaces
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface ResolvedDependencyBatchSpec {
|
|
16
|
+
readonly index: number;
|
|
17
|
+
readonly sourceId: string;
|
|
18
|
+
readonly sourceKind: "task" | "subtask";
|
|
19
|
+
readonly dependsOnId: string;
|
|
20
|
+
readonly dependsOnKind: "task" | "subtask";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DependencyBatchValidationIssue {
|
|
24
|
+
readonly index: number;
|
|
25
|
+
readonly type: "missing_id" | "duplicate" | "cycle";
|
|
26
|
+
readonly sourceId: string;
|
|
27
|
+
readonly dependsOnId: string;
|
|
28
|
+
readonly details: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DependencyBatchResolution {
|
|
32
|
+
readonly spec?: ResolvedDependencyBatchSpec;
|
|
33
|
+
readonly issues: readonly DependencyBatchValidationIssue[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResolvedCompactEntity {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly kind: "task" | "subtask";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Reader interface — the only DB-touching surface the callers must supply
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface BatchValidationReader {
|
|
46
|
+
getTask(id: string): TaskRecord | null;
|
|
47
|
+
getSubtask(id: string): SubtaskRecord | null;
|
|
48
|
+
getDependencyByEdge(sourceId: string, dependsOnId: string): DependencyRecord | null;
|
|
49
|
+
buildDependencyAdjacency(): Map<string, Set<string>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function assertNonEmptyLocal(field: string, value: string | undefined | null): string {
|
|
57
|
+
const normalized: string = (value ?? "").trim();
|
|
58
|
+
if (!normalized) {
|
|
59
|
+
throw new DomainError({
|
|
60
|
+
code: "invalid_input",
|
|
61
|
+
message: `${field} must be a non-empty string`,
|
|
62
|
+
details: { field },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveNodeKindLocal(id: string, reader: BatchValidationReader): "task" | "subtask" {
|
|
69
|
+
if (reader.getTask(id)) return "task";
|
|
70
|
+
if (reader.getSubtask(id)) return "subtask";
|
|
71
|
+
throw new DomainError({
|
|
72
|
+
code: "not_found",
|
|
73
|
+
message: `node not found: ${id}`,
|
|
74
|
+
details: { id, expectedKinds: ["task", "subtask"] },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveDependencyBatchId(
|
|
79
|
+
reference: CompactEntityRef,
|
|
80
|
+
field: "source" | "dependsOn",
|
|
81
|
+
index: number,
|
|
82
|
+
reader: BatchValidationReader,
|
|
83
|
+
): { readonly id?: string; readonly issues: readonly DependencyBatchValidationIssue[] } {
|
|
84
|
+
if (reference.kind === "temp_key") {
|
|
85
|
+
return {
|
|
86
|
+
issues: [
|
|
87
|
+
{
|
|
88
|
+
index,
|
|
89
|
+
type: "missing_id",
|
|
90
|
+
sourceId: field === "source" ? `@${reference.tempKey}` : "",
|
|
91
|
+
dependsOnId: field === "dependsOn" ? `@${reference.tempKey}` : "",
|
|
92
|
+
details: {
|
|
93
|
+
field,
|
|
94
|
+
tempKey: reference.tempKey,
|
|
95
|
+
message: `Unresolved temp key @${reference.tempKey}`,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const id = assertNonEmptyLocal(field === "source" ? "sourceId" : "dependsOnId", reference.id);
|
|
103
|
+
const task = reader.getTask(id);
|
|
104
|
+
const subtask = reader.getSubtask(id);
|
|
105
|
+
if (!task && !subtask) {
|
|
106
|
+
return {
|
|
107
|
+
issues: [
|
|
108
|
+
{
|
|
109
|
+
index,
|
|
110
|
+
type: "missing_id",
|
|
111
|
+
sourceId: field === "source" ? id : "",
|
|
112
|
+
dependsOnId: field === "dependsOn" ? id : "",
|
|
113
|
+
details: {
|
|
114
|
+
field,
|
|
115
|
+
id,
|
|
116
|
+
message: `Node not found: ${id}`,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { id, issues: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function wouldCreateCycleInAdjacency(
|
|
127
|
+
adjacency: ReadonlyMap<string, ReadonlySet<string>>,
|
|
128
|
+
sourceId: string,
|
|
129
|
+
dependsOnId: string,
|
|
130
|
+
): boolean {
|
|
131
|
+
const visited = new Set<string>();
|
|
132
|
+
const queue: string[] = [dependsOnId];
|
|
133
|
+
|
|
134
|
+
while (queue.length > 0) {
|
|
135
|
+
const current = queue.shift();
|
|
136
|
+
if (current === undefined || visited.has(current)) continue;
|
|
137
|
+
if (current === sourceId) return true;
|
|
138
|
+
visited.add(current);
|
|
139
|
+
const neighbors = adjacency.get(current);
|
|
140
|
+
if (neighbors === undefined) continue;
|
|
141
|
+
for (const neighbor of neighbors) {
|
|
142
|
+
if (!visited.has(neighbor)) queue.push(neighbor);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveEpicExpandEntityRef(
|
|
150
|
+
reference: CompactEntityRef,
|
|
151
|
+
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
152
|
+
option: "subtask" | "dep",
|
|
153
|
+
index: number,
|
|
154
|
+
field: "parent" | "source" | "dependsOn",
|
|
155
|
+
reader: BatchValidationReader,
|
|
156
|
+
): ResolvedCompactEntity {
|
|
157
|
+
if (reference.kind === "temp_key") {
|
|
158
|
+
const mapping = mappings.find((candidate) => candidate.tempKey === reference.tempKey);
|
|
159
|
+
if (mapping === undefined) {
|
|
160
|
+
throw new DomainError({
|
|
161
|
+
code: "invalid_input",
|
|
162
|
+
message: `Unknown temp key @${reference.tempKey} in --${option} spec ${index + 1}`,
|
|
163
|
+
details: { index, field, tempKey: reference.tempKey, option },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return { id: mapping.id, kind: mapping.kind };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const id = assertNonEmptyLocal(field === "parent" ? "taskId" : `${field}Id`, reference.id);
|
|
170
|
+
return { id, kind: resolveNodeKindLocal(id, reader) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Exported pure functions
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export function resolveDependencyBatchSpec(
|
|
178
|
+
index: number,
|
|
179
|
+
spec: CompactDependencySpec,
|
|
180
|
+
reader: BatchValidationReader,
|
|
181
|
+
): DependencyBatchResolution {
|
|
182
|
+
const sourceResolution = resolveDependencyBatchId(spec.source, "source", index, reader);
|
|
183
|
+
const dependsOnResolution = resolveDependencyBatchId(spec.dependsOn, "dependsOn", index, reader);
|
|
184
|
+
const issues = [...sourceResolution.issues, ...dependsOnResolution.issues];
|
|
185
|
+
const sourceId = sourceResolution.id;
|
|
186
|
+
const dependsOnId = dependsOnResolution.id;
|
|
187
|
+
|
|
188
|
+
if (sourceId === undefined || dependsOnId === undefined) {
|
|
189
|
+
return { issues };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (sourceId === dependsOnId) {
|
|
193
|
+
return {
|
|
194
|
+
issues: [
|
|
195
|
+
...issues,
|
|
196
|
+
{
|
|
197
|
+
index,
|
|
198
|
+
type: "cycle",
|
|
199
|
+
sourceId,
|
|
200
|
+
dependsOnId,
|
|
201
|
+
details: { sourceId, dependsOnId, reason: "self_reference" },
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
spec: {
|
|
209
|
+
index,
|
|
210
|
+
sourceId,
|
|
211
|
+
sourceKind: resolveNodeKindLocal(sourceId, reader),
|
|
212
|
+
dependsOnId,
|
|
213
|
+
dependsOnKind: resolveNodeKindLocal(dependsOnId, reader),
|
|
214
|
+
},
|
|
215
|
+
issues,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function resolveEpicExpandSubtaskSpecs(
|
|
220
|
+
specs: readonly CompactSubtaskSpec[],
|
|
221
|
+
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
222
|
+
reader: BatchValidationReader,
|
|
223
|
+
): CompactSubtaskSpec[] {
|
|
224
|
+
return specs.map((spec, index) => {
|
|
225
|
+
const parent = resolveEpicExpandEntityRef(spec.parent, mappings, "subtask", index, "parent", reader);
|
|
226
|
+
if (parent.kind !== "task") {
|
|
227
|
+
throw new DomainError({
|
|
228
|
+
code: "invalid_input",
|
|
229
|
+
message: `Subtask parent must resolve to a task in --subtask spec ${index + 1}`,
|
|
230
|
+
details: { index, field: "parent", kind: parent.kind, id: parent.id },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return { ...spec, parent: { kind: "id", id: parent.id } };
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function resolveEpicExpandDependencySpecs(
|
|
238
|
+
specs: readonly CompactDependencySpec[],
|
|
239
|
+
mappings: readonly { tempKey: string; id: string; kind: "task" | "subtask" }[],
|
|
240
|
+
reader: BatchValidationReader,
|
|
241
|
+
): CompactDependencySpec[] {
|
|
242
|
+
return specs.map((spec, index) => ({
|
|
243
|
+
source: {
|
|
244
|
+
kind: "id",
|
|
245
|
+
id: resolveEpicExpandEntityRef(spec.source, mappings, "dep", index, "source", reader).id,
|
|
246
|
+
},
|
|
247
|
+
dependsOn: {
|
|
248
|
+
kind: "id",
|
|
249
|
+
id: resolveEpicExpandEntityRef(spec.dependsOn, mappings, "dep", index, "dependsOn", reader).id,
|
|
250
|
+
},
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function collectDependencyBatchIssues(
|
|
255
|
+
specs: readonly ResolvedDependencyBatchSpec[],
|
|
256
|
+
reader: BatchValidationReader,
|
|
257
|
+
): DependencyBatchValidationIssue[] {
|
|
258
|
+
const issues: DependencyBatchValidationIssue[] = [];
|
|
259
|
+
const seenEdges = new Map<string, number>();
|
|
260
|
+
const adjacency = reader.buildDependencyAdjacency();
|
|
261
|
+
|
|
262
|
+
for (const spec of specs) {
|
|
263
|
+
const edgeKey = `${spec.sourceId}->${spec.dependsOnId}`;
|
|
264
|
+
const existingIndex = seenEdges.get(edgeKey);
|
|
265
|
+
if (existingIndex !== undefined) {
|
|
266
|
+
issues.push({
|
|
267
|
+
index: spec.index,
|
|
268
|
+
type: "duplicate",
|
|
269
|
+
sourceId: spec.sourceId,
|
|
270
|
+
dependsOnId: spec.dependsOnId,
|
|
271
|
+
details: {
|
|
272
|
+
sourceId: spec.sourceId,
|
|
273
|
+
dependsOnId: spec.dependsOnId,
|
|
274
|
+
firstIndex: existingIndex,
|
|
275
|
+
duplicateIndex: spec.index,
|
|
276
|
+
duplicateKind: "batch",
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (reader.getDependencyByEdge(spec.sourceId, spec.dependsOnId) !== null) {
|
|
283
|
+
issues.push({
|
|
284
|
+
index: spec.index,
|
|
285
|
+
type: "duplicate",
|
|
286
|
+
sourceId: spec.sourceId,
|
|
287
|
+
dependsOnId: spec.dependsOnId,
|
|
288
|
+
details: {
|
|
289
|
+
sourceId: spec.sourceId,
|
|
290
|
+
dependsOnId: spec.dependsOnId,
|
|
291
|
+
duplicateKind: "existing",
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
if (wouldCreateCycleInAdjacency(adjacency, spec.sourceId, spec.dependsOnId)) {
|
|
300
|
+
issues.push({
|
|
301
|
+
index: spec.index,
|
|
302
|
+
type: "cycle",
|
|
303
|
+
sourceId: spec.sourceId,
|
|
304
|
+
dependsOnId: spec.dependsOnId,
|
|
305
|
+
details: { sourceId: spec.sourceId, dependsOnId: spec.dependsOnId },
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const nextNeighbors = adjacency.get(spec.sourceId) ?? new Set<string>();
|
|
311
|
+
nextNeighbors.add(spec.dependsOnId);
|
|
312
|
+
adjacency.set(spec.sourceId, nextNeighbors);
|
|
313
|
+
seenEdges.set(edgeKey, spec.index);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return issues.sort((left, right) => left.index - right.index || left.type.localeCompare(right.type));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function buildDependencyAdjacency(
|
|
320
|
+
rows: ReadonlyArray<{ source_id: string; depends_on_id: string }>,
|
|
321
|
+
): Map<string, Set<string>> {
|
|
322
|
+
const adjacency = new Map<string, Set<string>>();
|
|
323
|
+
for (const row of rows) {
|
|
324
|
+
const neighbors = adjacency.get(row.source_id) ?? new Set<string>();
|
|
325
|
+
neighbors.add(row.depends_on_id);
|
|
326
|
+
adjacency.set(row.source_id, neighbors);
|
|
327
|
+
}
|
|
328
|
+
return adjacency;
|
|
329
|
+
}
|