trekoon 0.4.2 → 0.4.4
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 -208
- package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
- package/.agents/skills/trekoon/reference/execution.md +170 -380
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +193 -330
- package/.agents/skills/trekoon/reference/sync.md +56 -103
- package/README.md +29 -10
- package/docs/ai-agents.md +48 -4
- package/docs/commands.md +34 -25
- package/docs/machine-contracts.md +1 -1
- package/docs/quickstart.md +9 -9
- package/package.json +2 -2
- package/src/board/asset-root.ts +73 -0
- package/src/board/assets/app.js +5 -3
- package/src/board/assets/components/Component.js +6 -8
- package/src/board/assets/state/actions.js +3 -0
- package/src/board/assets/state/api.js +48 -34
- package/src/board/assets/state/store.js +3 -0
- package/src/board/event-bus.ts +15 -0
- package/src/board/routes.ts +94 -83
- package/src/board/server.ts +35 -8
- package/src/board/snapshot.ts +6 -0
- package/src/board/types.ts +2 -34
- package/src/board/wal-watcher.ts +170 -28
- package/src/commands/board.ts +20 -42
- package/src/commands/help.ts +11 -12
- package/src/commands/init.ts +0 -29
- package/src/commands/quickstart.ts +1 -1
- package/src/commands/skills.ts +17 -5
- package/src/domain/mutation-service.ts +61 -42
- package/src/domain/tracker-domain.ts +20 -16
- package/src/domain/types.ts +3 -0
- package/src/export/render-markdown.ts +1 -2
- package/src/runtime/daemon.ts +110 -49
- package/src/runtime/version.ts +10 -2
- package/src/storage/database.ts +9 -2
- package/src/storage/migrations.ts +19 -2
- package/src/storage/path.ts +0 -36
- package/src/sync/service.ts +47 -27
- package/src/board/install.ts +0 -196
package/src/commands/init.ts
CHANGED
|
@@ -3,8 +3,6 @@ import { resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { unexpectedFailureResult } from "./error-utils";
|
|
5
5
|
|
|
6
|
-
import { ensureBoardInstalled } from "../board/install";
|
|
7
|
-
import { BoardInstallError } from "../board/types";
|
|
8
6
|
import { DomainError } from "../domain/types";
|
|
9
7
|
import { failResult, okResult } from "../io/output";
|
|
10
8
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
@@ -108,11 +106,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
108
106
|
try {
|
|
109
107
|
database = openTrekoonDatabase(context.cwd);
|
|
110
108
|
const diagnostics = database.diagnostics;
|
|
111
|
-
const bundledAssetRoot: string | undefined = process.env.TREKOON_BOARD_ASSET_ROOT;
|
|
112
|
-
const board = ensureBoardInstalled({
|
|
113
|
-
workingDirectory: context.cwd,
|
|
114
|
-
...(bundledAssetRoot === undefined ? {} : { bundledAssetRoot }),
|
|
115
|
-
});
|
|
116
109
|
const gitignoreAction: GitignoreAction = ensureGitignore(
|
|
117
110
|
database.paths.storageDir,
|
|
118
111
|
diagnostics.storageMode,
|
|
@@ -125,8 +118,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
125
118
|
`Shared storage root: ${diagnostics.sharedStorageRoot}`,
|
|
126
119
|
`Storage directory: ${database.paths.storageDir}`,
|
|
127
120
|
`Database file: ${database.paths.databaseFile}`,
|
|
128
|
-
`Board assets: ${board.action}`,
|
|
129
|
-
`Board runtime root: ${board.paths.runtimeRoot}`,
|
|
130
121
|
`Gitignore: ${gitignoreAction}`,
|
|
131
122
|
...buildRecoverySummary(database),
|
|
132
123
|
];
|
|
@@ -142,11 +133,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
142
133
|
sharedStorageRoot: diagnostics.sharedStorageRoot,
|
|
143
134
|
storageDir: database.paths.storageDir,
|
|
144
135
|
databaseFile: database.paths.databaseFile,
|
|
145
|
-
board: {
|
|
146
|
-
action: board.action,
|
|
147
|
-
paths: board.paths,
|
|
148
|
-
manifest: board.manifest,
|
|
149
|
-
},
|
|
150
136
|
gitignore: {
|
|
151
137
|
action: gitignoreAction,
|
|
152
138
|
path: resolve(database.paths.storageDir, ".gitignore"),
|
|
@@ -170,21 +156,6 @@ export async function runInit(context: CliContext): Promise<CliResult> {
|
|
|
170
156
|
}
|
|
171
157
|
}
|
|
172
158
|
|
|
173
|
-
if (error instanceof BoardInstallError) {
|
|
174
|
-
return failResult({
|
|
175
|
-
command: "init",
|
|
176
|
-
human: error.message,
|
|
177
|
-
data: {
|
|
178
|
-
code: error.code,
|
|
179
|
-
...error.details,
|
|
180
|
-
},
|
|
181
|
-
error: {
|
|
182
|
-
code: error.code,
|
|
183
|
-
message: error.message,
|
|
184
|
-
},
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
159
|
return unexpectedFailureResult(error, {
|
|
189
160
|
command: "init",
|
|
190
161
|
human: "Unexpected init command failure",
|
|
@@ -32,7 +32,7 @@ const QUICKSTART_TEXT = [
|
|
|
32
32
|
" stop and fix the setup before continuing.",
|
|
33
33
|
"",
|
|
34
34
|
" Manual bootstrap (step by step):",
|
|
35
|
-
" trekoon --toon init",
|
|
35
|
+
" trekoon --toon init # creates .trekoon/ shared storage (no board assets copied)",
|
|
36
36
|
" trekoon --toon sync status",
|
|
37
37
|
" trekoon --toon task next",
|
|
38
38
|
"",
|
package/src/commands/skills.ts
CHANGED
|
@@ -10,11 +10,11 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
|
10
10
|
|
|
11
11
|
const SKILLS_USAGE = [
|
|
12
12
|
"Usage:",
|
|
13
|
-
" trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
|
|
14
|
-
" trekoon skills install -g|--global [--editor opencode|claude|pi]",
|
|
13
|
+
" trekoon skills install [--link --editor opencode|claude|codex|pi] [--to <path>] [--allow-outside-repo]",
|
|
14
|
+
" trekoon skills install -g|--global [--editor opencode|claude|codex|pi]",
|
|
15
15
|
" trekoon skills update",
|
|
16
16
|
].join("\n");
|
|
17
|
-
const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
|
|
17
|
+
const EDITOR_NAMES = ["opencode", "claude", "codex", "pi"] as const;
|
|
18
18
|
const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
|
|
19
19
|
|
|
20
20
|
type EditorName = (typeof EDITOR_NAMES)[number];
|
|
@@ -88,6 +88,10 @@ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | u
|
|
|
88
88
|
return join(cwd, ".claude", "skills");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
if (editor === "codex") {
|
|
92
|
+
return join(cwd, ".codex", "skills");
|
|
93
|
+
}
|
|
94
|
+
|
|
91
95
|
return join(cwd, ".pi", "skills");
|
|
92
96
|
}
|
|
93
97
|
|
|
@@ -222,6 +226,10 @@ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
|
|
|
222
226
|
return join(cwd, ".claude");
|
|
223
227
|
}
|
|
224
228
|
|
|
229
|
+
if (editor === "codex") {
|
|
230
|
+
return join(cwd, ".codex");
|
|
231
|
+
}
|
|
232
|
+
|
|
225
233
|
return join(cwd, ".pi");
|
|
226
234
|
}
|
|
227
235
|
|
|
@@ -235,6 +243,10 @@ function resolveGlobalEditorSkillsDir(editor: EditorName): string {
|
|
|
235
243
|
return join(home, ".claude", "skills");
|
|
236
244
|
}
|
|
237
245
|
|
|
246
|
+
if (editor === "codex") {
|
|
247
|
+
return join(home, ".codex", "skills");
|
|
248
|
+
}
|
|
249
|
+
|
|
238
250
|
return join(home, ".pi", "skills");
|
|
239
251
|
}
|
|
240
252
|
|
|
@@ -542,7 +554,7 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
542
554
|
|
|
543
555
|
// Validate editor early (shared by both modes).
|
|
544
556
|
if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
|
|
545
|
-
return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, pi", {
|
|
557
|
+
return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, codex, pi", {
|
|
546
558
|
editor: rawEditor,
|
|
547
559
|
allowedEditors: EDITOR_NAMES,
|
|
548
560
|
});
|
|
@@ -588,7 +600,7 @@ function runSkillsInstall(context: CliContext): CliResult {
|
|
|
588
600
|
}
|
|
589
601
|
|
|
590
602
|
if (wantsLink && rawEditor === undefined) {
|
|
591
|
-
return invalidArgs("skills install --link requires --editor opencode|claude|pi.");
|
|
603
|
+
return invalidArgs("skills install --link requires --editor opencode|claude|codex|pi.");
|
|
592
604
|
}
|
|
593
605
|
|
|
594
606
|
const editor: EditorName | undefined = rawEditor as EditorName | undefined;
|
|
@@ -71,32 +71,32 @@ function normalizeOwnerInput(owner: string | null | undefined): string | null |
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Thrown by the *WithIfMatch CAS variants when the supplied `If-Match`
|
|
74
|
-
*
|
|
74
|
+
* version token does not match the row currently in the database.
|
|
75
75
|
*
|
|
76
76
|
* The error is **not** a `DomainError` so the generic `toBoardRouteError`
|
|
77
77
|
* fall-through doesn't accidentally surface it as a 400 — route handlers
|
|
78
78
|
* catch it explicitly and emit the canonical 409 `precondition_failed`
|
|
79
|
-
* payload (with `
|
|
79
|
+
* payload (with `currentVersion` fetched inside the same transaction
|
|
80
80
|
* that observed the mismatch).
|
|
81
81
|
*/
|
|
82
82
|
export class PreconditionFailedError extends Error {
|
|
83
83
|
readonly entityKind: "epic" | "task" | "subtask";
|
|
84
84
|
readonly entityId: string;
|
|
85
|
-
readonly
|
|
86
|
-
readonly
|
|
85
|
+
readonly currentVersion: number;
|
|
86
|
+
readonly providedVersion: number;
|
|
87
87
|
|
|
88
88
|
constructor(input: {
|
|
89
89
|
entityKind: "epic" | "task" | "subtask";
|
|
90
90
|
entityId: string;
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
currentVersion: number;
|
|
92
|
+
providedVersion: number;
|
|
93
93
|
}) {
|
|
94
|
-
super("If-Match version does not match current
|
|
94
|
+
super("If-Match version does not match current version");
|
|
95
95
|
this.name = "PreconditionFailedError";
|
|
96
96
|
this.entityKind = input.entityKind;
|
|
97
97
|
this.entityId = input.entityId;
|
|
98
|
-
this.
|
|
99
|
-
this.
|
|
98
|
+
this.currentVersion = input.currentVersion;
|
|
99
|
+
this.providedVersion = input.providedVersion;
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -132,11 +132,9 @@ const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
132
132
|
// than keeps up with expiration.
|
|
133
133
|
const BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 1000;
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// `__resetIdempotencyPruneThrottleForTests`.
|
|
139
|
-
let lastIdempotencyPruneAt = 0;
|
|
135
|
+
const idempotencyPruneByDatabase = new Map<string, number>();
|
|
136
|
+
let memoryDatabasePruneKeys = new WeakMap<Database, string>();
|
|
137
|
+
let nextMemoryDatabasePruneId = 0;
|
|
140
138
|
|
|
141
139
|
/**
|
|
142
140
|
* Test hook: resets the module-level prune throttle so a fresh test run
|
|
@@ -144,7 +142,26 @@ let lastIdempotencyPruneAt = 0;
|
|
|
144
142
|
* never invoke this.
|
|
145
143
|
*/
|
|
146
144
|
export function __resetIdempotencyPruneThrottleForTests(): void {
|
|
147
|
-
|
|
145
|
+
idempotencyPruneByDatabase.clear();
|
|
146
|
+
memoryDatabasePruneKeys = new WeakMap<Database, string>();
|
|
147
|
+
nextMemoryDatabasePruneId = 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function idempotencyPruneKeyForDatabase(db: Database): string {
|
|
151
|
+
const rows = db.query("PRAGMA database_list;").all() as Array<{ name: string; file: string }>;
|
|
152
|
+
const main = rows.find((row) => row.name === "main");
|
|
153
|
+
if (main?.file) {
|
|
154
|
+
return main.file;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const existing = memoryDatabasePruneKeys.get(db);
|
|
158
|
+
if (existing) {
|
|
159
|
+
return existing;
|
|
160
|
+
}
|
|
161
|
+
nextMemoryDatabasePruneId += 1;
|
|
162
|
+
const next = `:memory:${nextMemoryDatabasePruneId}`;
|
|
163
|
+
memoryDatabasePruneKeys.set(db, next);
|
|
164
|
+
return next;
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
interface ScopeReplacementResult {
|
|
@@ -296,10 +313,10 @@ export class MutationService {
|
|
|
296
313
|
* Atomic If-Match CAS variant of {@link updateEpic}.
|
|
297
314
|
*
|
|
298
315
|
* The `If-Match` precondition is enforced INSIDE the write transaction
|
|
299
|
-
* via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND
|
|
316
|
+
* via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND version = ?`).
|
|
300
317
|
* If zero rows are affected we determine whether the row is missing
|
|
301
318
|
* (→ `DomainError(not_found)`) or merely stale (→ {@link PreconditionFailedError}
|
|
302
|
-
* with the freshly-fetched `
|
|
319
|
+
* with the freshly-fetched `currentVersion`).
|
|
303
320
|
*
|
|
304
321
|
* This eliminates the read-check-then-write race the previous route-level
|
|
305
322
|
* check had: a concurrent writer could land between `parseIfMatchHeader`'s
|
|
@@ -312,7 +329,7 @@ export class MutationService {
|
|
|
312
329
|
*/
|
|
313
330
|
updateEpicWithIfMatch(
|
|
314
331
|
id: string,
|
|
315
|
-
|
|
332
|
+
ifMatchVersion: number,
|
|
316
333
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
317
334
|
): EpicRecord {
|
|
318
335
|
return this.#writeTransaction((): EpicRecord => {
|
|
@@ -343,22 +360,22 @@ export class MutationService {
|
|
|
343
360
|
`UPDATE epics
|
|
344
361
|
SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1
|
|
345
362
|
WHERE id = ?
|
|
346
|
-
AND
|
|
363
|
+
AND version = ?
|
|
347
364
|
RETURNING id`,
|
|
348
365
|
)
|
|
349
|
-
.get(nextTitle, nextDescription, nextStatus, now, id,
|
|
366
|
+
.get(nextTitle, nextDescription, nextStatus, now, id, ifMatchVersion) as { id: string } | null;
|
|
350
367
|
|
|
351
368
|
if (result === null) {
|
|
352
369
|
// Zero rows changed. We already proved the row exists via
|
|
353
370
|
// getEpicOrThrow, so the only remaining failure mode is a stale
|
|
354
|
-
// precondition. Re-fetch
|
|
371
|
+
// precondition. Re-fetch version inside the same tx so the
|
|
355
372
|
// caller's 409 carries the freshest value.
|
|
356
373
|
const current = this.#domain.getEpicOrThrow(id);
|
|
357
374
|
throw new PreconditionFailedError({
|
|
358
375
|
entityKind: "epic",
|
|
359
376
|
entityId: id,
|
|
360
|
-
|
|
361
|
-
|
|
377
|
+
currentVersion: current.version,
|
|
378
|
+
providedVersion: ifMatchVersion,
|
|
362
379
|
});
|
|
363
380
|
}
|
|
364
381
|
|
|
@@ -389,17 +406,17 @@ export class MutationService {
|
|
|
389
406
|
*/
|
|
390
407
|
updateEpicStatusCascadeWithIfMatch(
|
|
391
408
|
id: string,
|
|
392
|
-
|
|
409
|
+
ifMatchVersion: number,
|
|
393
410
|
status: string,
|
|
394
411
|
): StatusCascadePlan {
|
|
395
412
|
return this.#writeTransaction((): StatusCascadePlan => {
|
|
396
413
|
const existing = this.#domain.getEpicOrThrow(id);
|
|
397
|
-
if (existing.
|
|
414
|
+
if (existing.version !== ifMatchVersion) {
|
|
398
415
|
throw new PreconditionFailedError({
|
|
399
416
|
entityKind: "epic",
|
|
400
417
|
entityId: id,
|
|
401
|
-
|
|
402
|
-
|
|
418
|
+
currentVersion: existing.version,
|
|
419
|
+
providedVersion: ifMatchVersion,
|
|
403
420
|
});
|
|
404
421
|
}
|
|
405
422
|
const plan = this.#domain.planStatusCascade("epic", id, status);
|
|
@@ -513,7 +530,7 @@ export class MutationService {
|
|
|
513
530
|
*/
|
|
514
531
|
updateTaskWithIfMatch(
|
|
515
532
|
id: string,
|
|
516
|
-
|
|
533
|
+
ifMatchVersion: number,
|
|
517
534
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
518
535
|
): TaskRecord {
|
|
519
536
|
return this.#writeTransaction((): TaskRecord => {
|
|
@@ -546,18 +563,18 @@ export class MutationService {
|
|
|
546
563
|
`UPDATE tasks
|
|
547
564
|
SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
|
|
548
565
|
WHERE id = ?
|
|
549
|
-
AND
|
|
566
|
+
AND version = ?
|
|
550
567
|
RETURNING id`,
|
|
551
568
|
)
|
|
552
|
-
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id,
|
|
569
|
+
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchVersion) as { id: string } | null;
|
|
553
570
|
|
|
554
571
|
if (result === null) {
|
|
555
572
|
const current = this.#domain.getTaskOrThrow(id);
|
|
556
573
|
throw new PreconditionFailedError({
|
|
557
574
|
entityKind: "task",
|
|
558
575
|
entityId: id,
|
|
559
|
-
|
|
560
|
-
|
|
576
|
+
currentVersion: current.version,
|
|
577
|
+
providedVersion: ifMatchVersion,
|
|
561
578
|
});
|
|
562
579
|
}
|
|
563
580
|
|
|
@@ -686,7 +703,7 @@ export class MutationService {
|
|
|
686
703
|
const nextStatus = input.status ?? existing.status;
|
|
687
704
|
this.#db
|
|
688
705
|
.query(
|
|
689
|
-
"UPDATE epics SET description = description || ?, status = ?, updated_at =
|
|
706
|
+
"UPDATE epics SET description = description || ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
|
|
690
707
|
)
|
|
691
708
|
.run(separator + input.append, nextStatus, now, input.epicId);
|
|
692
709
|
const epic = this.#domain.getEpicOrThrow(input.epicId);
|
|
@@ -1032,7 +1049,7 @@ export class MutationService {
|
|
|
1032
1049
|
*/
|
|
1033
1050
|
updateSubtaskWithIfMatch(
|
|
1034
1051
|
id: string,
|
|
1035
|
-
|
|
1052
|
+
ifMatchVersion: number,
|
|
1036
1053
|
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
1037
1054
|
): SubtaskRecord {
|
|
1038
1055
|
return this.#writeTransaction((): SubtaskRecord => {
|
|
@@ -1063,18 +1080,18 @@ export class MutationService {
|
|
|
1063
1080
|
`UPDATE subtasks
|
|
1064
1081
|
SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
|
|
1065
1082
|
WHERE id = ?
|
|
1066
|
-
AND
|
|
1083
|
+
AND version = ?
|
|
1067
1084
|
RETURNING id`,
|
|
1068
1085
|
)
|
|
1069
|
-
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id,
|
|
1086
|
+
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchVersion) as { id: string } | null;
|
|
1070
1087
|
|
|
1071
1088
|
if (result === null) {
|
|
1072
1089
|
const current = this.#domain.getSubtaskOrThrow(id);
|
|
1073
1090
|
throw new PreconditionFailedError({
|
|
1074
1091
|
entityKind: "subtask",
|
|
1075
1092
|
entityId: id,
|
|
1076
|
-
|
|
1077
|
-
|
|
1093
|
+
currentVersion: current.version,
|
|
1094
|
+
providedVersion: ifMatchVersion,
|
|
1078
1095
|
});
|
|
1079
1096
|
}
|
|
1080
1097
|
|
|
@@ -1581,12 +1598,13 @@ export class MutationService {
|
|
|
1581
1598
|
|
|
1582
1599
|
#pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
|
|
1583
1600
|
// Skip if we swept recently — see BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS
|
|
1584
|
-
// for rationale.
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1601
|
+
// for rationale. The throttle is per database file so daemon processes
|
|
1602
|
+
// serving several workspaces do not suppress each other's cleanup.
|
|
1603
|
+
const pruneKey = idempotencyPruneKeyForDatabase(this.#db);
|
|
1604
|
+
const lastPrunedAt = idempotencyPruneByDatabase.get(pruneKey) ?? 0;
|
|
1605
|
+
if (now - lastPrunedAt < BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS) {
|
|
1587
1606
|
return;
|
|
1588
1607
|
}
|
|
1589
|
-
lastIdempotencyPruneAt = now;
|
|
1590
1608
|
|
|
1591
1609
|
const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
|
|
1592
1610
|
this.#db.query(
|
|
@@ -1596,6 +1614,7 @@ export class MutationService {
|
|
|
1596
1614
|
AND created_at < ?;
|
|
1597
1615
|
`,
|
|
1598
1616
|
).run(cutoff);
|
|
1617
|
+
idempotencyPruneByDatabase.set(pruneKey, now);
|
|
1599
1618
|
}
|
|
1600
1619
|
|
|
1601
1620
|
#previewScopeReplacement(
|
|
@@ -56,6 +56,7 @@ interface EpicRow {
|
|
|
56
56
|
status: string;
|
|
57
57
|
created_at: number;
|
|
58
58
|
updated_at: number;
|
|
59
|
+
version: number;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
interface TaskRow extends EpicRow {
|
|
@@ -208,6 +209,7 @@ function mapEpic(row: EpicRow): EpicRecord {
|
|
|
208
209
|
status: row.status,
|
|
209
210
|
createdAt: row.created_at,
|
|
210
211
|
updatedAt: row.updated_at,
|
|
212
|
+
version: row.version,
|
|
211
213
|
};
|
|
212
214
|
}
|
|
213
215
|
|
|
@@ -221,6 +223,7 @@ function mapTask(row: TaskRow): TaskRecord {
|
|
|
221
223
|
owner: row.owner ?? null,
|
|
222
224
|
createdAt: row.created_at,
|
|
223
225
|
updatedAt: row.updated_at,
|
|
226
|
+
version: row.version,
|
|
224
227
|
};
|
|
225
228
|
}
|
|
226
229
|
|
|
@@ -234,6 +237,7 @@ function mapSubtask(row: SubtaskRow): SubtaskRecord {
|
|
|
234
237
|
owner: row.owner ?? null,
|
|
235
238
|
createdAt: row.created_at,
|
|
236
239
|
updatedAt: row.updated_at,
|
|
240
|
+
version: row.version,
|
|
237
241
|
};
|
|
238
242
|
}
|
|
239
243
|
|
|
@@ -299,7 +303,7 @@ export class TrackerDomain {
|
|
|
299
303
|
|
|
300
304
|
listEpics(): readonly EpicRecord[] {
|
|
301
305
|
const rows = this.#db
|
|
302
|
-
.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;")
|
|
303
307
|
.all() as EpicRow[];
|
|
304
308
|
return rows.map(mapEpic);
|
|
305
309
|
}
|
|
@@ -326,7 +330,7 @@ export class TrackerDomain {
|
|
|
326
330
|
findActiveEpic(): EpicRecord | null {
|
|
327
331
|
const inProgress = this.#db
|
|
328
332
|
.query(
|
|
329
|
-
"SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'in_progress' LIMIT 1;",
|
|
333
|
+
"SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE status = 'in_progress' LIMIT 1;",
|
|
330
334
|
)
|
|
331
335
|
.get() as EpicRow | null;
|
|
332
336
|
if (inProgress) {
|
|
@@ -335,7 +339,7 @@ export class TrackerDomain {
|
|
|
335
339
|
|
|
336
340
|
const todo = this.#db
|
|
337
341
|
.query(
|
|
338
|
-
"SELECT id, title, description, status, created_at, updated_at FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
|
|
342
|
+
"SELECT id, title, description, status, created_at, updated_at, version FROM epics WHERE status = 'todo' ORDER BY updated_at DESC LIMIT 1;",
|
|
339
343
|
)
|
|
340
344
|
.get() as EpicRow | null;
|
|
341
345
|
if (todo) {
|
|
@@ -345,7 +349,7 @@ export class TrackerDomain {
|
|
|
345
349
|
// Fallback: oldest epic regardless of status (mirrors epics[0] from listEpics).
|
|
346
350
|
const oldest = this.#db
|
|
347
351
|
.query(
|
|
348
|
-
"SELECT id, title, description, status, created_at, updated_at FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
|
|
352
|
+
"SELECT id, title, description, status, created_at, updated_at, version FROM epics ORDER BY created_at ASC, id ASC LIMIT 1;",
|
|
349
353
|
)
|
|
350
354
|
.get() as EpicRow | null;
|
|
351
355
|
return oldest ? mapEpic(oldest) : null;
|
|
@@ -353,7 +357,7 @@ export class TrackerDomain {
|
|
|
353
357
|
|
|
354
358
|
getEpic(id: string): EpicRecord | null {
|
|
355
359
|
const row = this.#db
|
|
356
|
-
.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 = ?;")
|
|
357
361
|
.get(id) as EpicRow | null;
|
|
358
362
|
return row ? mapEpic(row) : null;
|
|
359
363
|
}
|
|
@@ -469,7 +473,7 @@ export class TrackerDomain {
|
|
|
469
473
|
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
470
474
|
const chunkRows = this.#db
|
|
471
475
|
.query(
|
|
472
|
-
`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});`,
|
|
473
477
|
)
|
|
474
478
|
.all(...chunkIds) as TaskRow[];
|
|
475
479
|
fetchedRows.push(...chunkRows);
|
|
@@ -501,21 +505,21 @@ export class TrackerDomain {
|
|
|
501
505
|
this.getEpicOrThrow(epicId);
|
|
502
506
|
const rows = this.#db
|
|
503
507
|
.query(
|
|
504
|
-
"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;",
|
|
505
509
|
)
|
|
506
510
|
.all(epicId) as TaskRow[];
|
|
507
511
|
return rows.map(mapTask);
|
|
508
512
|
}
|
|
509
513
|
|
|
510
514
|
const rows = this.#db
|
|
511
|
-
.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;")
|
|
512
516
|
.all() as TaskRow[];
|
|
513
517
|
return rows.map(mapTask);
|
|
514
518
|
}
|
|
515
519
|
|
|
516
520
|
getTask(id: string): TaskRecord | null {
|
|
517
521
|
const row = this.#db
|
|
518
|
-
.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 = ?;")
|
|
519
523
|
.get(id) as TaskRow | null;
|
|
520
524
|
return row ? mapTask(row) : null;
|
|
521
525
|
}
|
|
@@ -661,7 +665,7 @@ export class TrackerDomain {
|
|
|
661
665
|
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
662
666
|
const chunkRows = this.#db
|
|
663
667
|
.query(
|
|
664
|
-
`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});`,
|
|
665
669
|
)
|
|
666
670
|
.all(...chunkIds) as SubtaskRow[];
|
|
667
671
|
fetchedRows.push(...chunkRows);
|
|
@@ -737,7 +741,7 @@ export class TrackerDomain {
|
|
|
737
741
|
this.getTaskOrThrow(taskId);
|
|
738
742
|
const rows = this.#db
|
|
739
743
|
.query(
|
|
740
|
-
"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;",
|
|
741
745
|
)
|
|
742
746
|
.all(taskId) as SubtaskRow[];
|
|
743
747
|
return rows.map(mapSubtask);
|
|
@@ -745,7 +749,7 @@ export class TrackerDomain {
|
|
|
745
749
|
|
|
746
750
|
const rows = this.#db
|
|
747
751
|
.query(
|
|
748
|
-
"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;",
|
|
749
753
|
)
|
|
750
754
|
.all() as SubtaskRow[];
|
|
751
755
|
return rows.map(mapSubtask);
|
|
@@ -755,7 +759,7 @@ export class TrackerDomain {
|
|
|
755
759
|
this.getTaskOrThrow(taskId);
|
|
756
760
|
const rows = this.#db
|
|
757
761
|
.query(
|
|
758
|
-
"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;",
|
|
759
763
|
)
|
|
760
764
|
.all(taskId) as SubtaskRow[];
|
|
761
765
|
return rows.map(mapSubtask);
|
|
@@ -763,7 +767,7 @@ export class TrackerDomain {
|
|
|
763
767
|
|
|
764
768
|
getSubtask(id: string): SubtaskRecord | null {
|
|
765
769
|
const row = this.#db
|
|
766
|
-
.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 = ?;")
|
|
767
771
|
.get(id) as SubtaskRow | null;
|
|
768
772
|
return row ? mapSubtask(row) : null;
|
|
769
773
|
}
|
|
@@ -862,7 +866,7 @@ export class TrackerDomain {
|
|
|
862
866
|
const taskIds = new Set(tasks.map((task) => task.id));
|
|
863
867
|
const subtasks = this.#db
|
|
864
868
|
.query(
|
|
865
|
-
"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;",
|
|
866
870
|
)
|
|
867
871
|
.all(epicId) as SubtaskRow[];
|
|
868
872
|
|
|
@@ -1266,7 +1270,7 @@ export class TrackerDomain {
|
|
|
1266
1270
|
const inPlaceholders: string = chunkIds.map(() => "?").join(", ");
|
|
1267
1271
|
const rows = this.#db
|
|
1268
1272
|
.query(
|
|
1269
|
-
`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
|
|
1270
1274
|
FROM subtasks
|
|
1271
1275
|
WHERE task_id IN (${inPlaceholders})
|
|
1272
1276
|
ORDER BY created_at ASC, id ASC;`,
|
package/src/domain/types.ts
CHANGED
|
@@ -97,6 +97,7 @@ export interface EpicRecord {
|
|
|
97
97
|
readonly status: string;
|
|
98
98
|
readonly createdAt: number;
|
|
99
99
|
readonly updatedAt: number;
|
|
100
|
+
readonly version: number;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
export interface TaskRecord {
|
|
@@ -108,6 +109,7 @@ export interface TaskRecord {
|
|
|
108
109
|
readonly owner: string | null;
|
|
109
110
|
readonly createdAt: number;
|
|
110
111
|
readonly updatedAt: number;
|
|
112
|
+
readonly version: number;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
export interface SubtaskRecord {
|
|
@@ -119,6 +121,7 @@ export interface SubtaskRecord {
|
|
|
119
121
|
readonly owner: string | null;
|
|
120
122
|
readonly createdAt: number;
|
|
121
123
|
readonly updatedAt: number;
|
|
124
|
+
readonly version: number;
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
export interface DependencyRecord {
|
|
@@ -124,8 +124,7 @@ function renderTaskIndex(lines: string[], bundle: ExportBundle): void {
|
|
|
124
124
|
lines.push("| # | Title | Status | Subtasks |");
|
|
125
125
|
lines.push("|---|-------|--------|----------|");
|
|
126
126
|
|
|
127
|
-
for (
|
|
128
|
-
const task = bundle.tasks[i];
|
|
127
|
+
for (const [i, task] of bundle.tasks.entries()) {
|
|
129
128
|
const subtaskCount = bundle.subtasks.filter((s) => s.taskId === task.id).length;
|
|
130
129
|
const anchor = taskAnchor(task);
|
|
131
130
|
lines.push(`| ${i + 1} | [${escapeTableCell(escapeInlineText(task.title))}](#${anchor}) | ${task.status} | ${subtaskCount} |`);
|