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
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { type Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
3
|
import { writeTransaction } from "../storage/database";
|
|
4
|
-
import { appendEventWithGitContext,
|
|
4
|
+
import { appendEventWithGitContext, withTransactionEventContext } from "../sync/event-writes";
|
|
5
|
+
import { resolveGitContext } from "../sync/git-context";
|
|
5
6
|
import { ENTITY_OPERATIONS } from "./mutation-operations";
|
|
7
|
+
import {
|
|
8
|
+
buildMatchSnippet,
|
|
9
|
+
buildReplacementSnippet,
|
|
10
|
+
countMatches,
|
|
11
|
+
replaceMatches,
|
|
12
|
+
summarizeMatches,
|
|
13
|
+
} from "./search";
|
|
6
14
|
import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
|
|
7
15
|
import {
|
|
8
16
|
type CompactEpicCreateResult,
|
|
@@ -26,6 +34,72 @@ import {
|
|
|
26
34
|
DomainError,
|
|
27
35
|
} from "./types";
|
|
28
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Local mirror of `assertNonEmpty` from tracker-domain (which is
|
|
39
|
+
* file-local). Used by the *WithIfMatch CAS variants which build their
|
|
40
|
+
* UPDATE row directly rather than going through `domain.updateX`, so they
|
|
41
|
+
* must enforce the same non-empty-string contract on caller-provided
|
|
42
|
+
* fields.
|
|
43
|
+
*/
|
|
44
|
+
function assertNonEmptyField(field: string, value: string): string {
|
|
45
|
+
const normalized = value.trim();
|
|
46
|
+
if (!normalized) {
|
|
47
|
+
throw new DomainError({
|
|
48
|
+
code: "invalid_input",
|
|
49
|
+
message: `${field} must be a non-empty string`,
|
|
50
|
+
details: { field },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Local mirror of `normalizeOwner` from tracker-domain. `undefined`
|
|
58
|
+
* preserves the existing owner; `null` or blank-after-trim clears it;
|
|
59
|
+
* any other string is trimmed and stored.
|
|
60
|
+
*/
|
|
61
|
+
function normalizeOwnerInput(owner: string | null | undefined): string | null | undefined {
|
|
62
|
+
if (owner === undefined) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (owner === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const trimmed = owner.trim();
|
|
69
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Thrown by the *WithIfMatch CAS variants when the supplied `If-Match`
|
|
74
|
+
* version token does not match the row currently in the database.
|
|
75
|
+
*
|
|
76
|
+
* The error is **not** a `DomainError` so the generic `toBoardRouteError`
|
|
77
|
+
* fall-through doesn't accidentally surface it as a 400 — route handlers
|
|
78
|
+
* catch it explicitly and emit the canonical 409 `precondition_failed`
|
|
79
|
+
* payload (with `currentVersion` fetched inside the same transaction
|
|
80
|
+
* that observed the mismatch).
|
|
81
|
+
*/
|
|
82
|
+
export class PreconditionFailedError extends Error {
|
|
83
|
+
readonly entityKind: "epic" | "task" | "subtask";
|
|
84
|
+
readonly entityId: string;
|
|
85
|
+
readonly currentVersion: number;
|
|
86
|
+
readonly providedVersion: number;
|
|
87
|
+
|
|
88
|
+
constructor(input: {
|
|
89
|
+
entityKind: "epic" | "task" | "subtask";
|
|
90
|
+
entityId: string;
|
|
91
|
+
currentVersion: number;
|
|
92
|
+
providedVersion: number;
|
|
93
|
+
}) {
|
|
94
|
+
super("If-Match version does not match current version");
|
|
95
|
+
this.name = "PreconditionFailedError";
|
|
96
|
+
this.entityKind = input.entityKind;
|
|
97
|
+
this.entityId = input.entityId;
|
|
98
|
+
this.currentVersion = input.currentVersion;
|
|
99
|
+
this.providedVersion = input.providedVersion;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
29
103
|
interface AtomicIdempotencyClaim {
|
|
30
104
|
readonly scope: "subtask" | "dependency" | "deleted_subtask" | "deleted_dependency";
|
|
31
105
|
readonly idempotencyKey: string;
|
|
@@ -51,66 +125,43 @@ type AtomicIdempotentMutationResult =
|
|
|
51
125
|
|
|
52
126
|
const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
53
127
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
128
|
+
// Throttle window for idempotency-key garbage collection. The previous
|
|
129
|
+
// implementation ran a DELETE on every claim which wasted IO under bursts
|
|
130
|
+
// (System Hardening 0.4.2, finding 27). Keys live for
|
|
131
|
+
// BOARD_IDEMPOTENCY_RETENTION_MS (7 days), so a once-per-minute sweep more
|
|
132
|
+
// than keeps up with expiration.
|
|
133
|
+
const BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 1000;
|
|
134
|
+
|
|
135
|
+
const idempotencyPruneByDatabase = new Map<string, number>();
|
|
136
|
+
let memoryDatabasePruneKeys = new WeakMap<Database, string>();
|
|
137
|
+
let nextMemoryDatabasePruneId = 0;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Test hook: resets the module-level prune throttle so a fresh test run
|
|
141
|
+
* always observes the first call as un-throttled. Production code paths
|
|
142
|
+
* never invoke this.
|
|
143
|
+
*/
|
|
144
|
+
export function __resetIdempotencyPruneThrottleForTests(): void {
|
|
145
|
+
idempotencyPruneByDatabase.clear();
|
|
146
|
+
memoryDatabasePruneKeys = new WeakMap<Database, string>();
|
|
147
|
+
nextMemoryDatabasePruneId = 0;
|
|
72
148
|
}
|
|
73
149
|
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (searchText.length === 0) {
|
|
80
|
-
return "";
|
|
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;
|
|
81
155
|
}
|
|
82
156
|
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
return
|
|
157
|
+
const existing = memoryDatabasePruneKeys.get(db);
|
|
158
|
+
if (existing) {
|
|
159
|
+
return existing;
|
|
86
160
|
}
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const prefix = start > 0 ? "…" : "";
|
|
92
|
-
const suffix = end < value.length ? "…" : "";
|
|
93
|
-
return `${prefix}${rawSnippet}${suffix}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function buildReplacementSnippet(value: string, replacementIndex: number, replacementLength: number, contextSize = 24): string {
|
|
97
|
-
const start = Math.max(0, replacementIndex - contextSize);
|
|
98
|
-
const end = Math.min(value.length, replacementIndex + replacementLength + contextSize);
|
|
99
|
-
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
100
|
-
const prefix = start > 0 ? "…" : "";
|
|
101
|
-
const suffix = end < value.length ? "…" : "";
|
|
102
|
-
return `${prefix}${rawSnippet}${suffix}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
|
|
106
|
-
return {
|
|
107
|
-
matchedEntities: matches.length,
|
|
108
|
-
matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
|
|
109
|
-
totalMatches: matches.reduce(
|
|
110
|
-
(total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
|
|
111
|
-
0,
|
|
112
|
-
),
|
|
113
|
-
};
|
|
161
|
+
nextMemoryDatabasePruneId += 1;
|
|
162
|
+
const next = `:memory:${nextMemoryDatabasePruneId}`;
|
|
163
|
+
memoryDatabasePruneKeys.set(db, next);
|
|
164
|
+
return next;
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
interface ScopeReplacementResult {
|
|
@@ -130,8 +181,16 @@ export class MutationService {
|
|
|
130
181
|
}
|
|
131
182
|
|
|
132
183
|
#writeTransaction<T>(fn: () => T): T {
|
|
133
|
-
|
|
134
|
-
|
|
184
|
+
// Resolve git context BEFORE acquiring the SQLite write lock. Cold-cache
|
|
185
|
+
// resolution spawns `git branch` / `git rev-parse` subprocesses; doing
|
|
186
|
+
// that inside BEGIN IMMEDIATE would serialize concurrent writers behind
|
|
187
|
+
// git invocations rather than just the lock-promotion itself.
|
|
188
|
+
//
|
|
189
|
+
// withTransactionEventContext still computes the event timestamp lazily
|
|
190
|
+
// AFTER BEGIN IMMEDIATE is issued by writeTransaction so concurrent
|
|
191
|
+
// writers cannot collide on (created_at, id).
|
|
192
|
+
const git = resolveGitContext(this.#cwd);
|
|
193
|
+
return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, git, fn));
|
|
135
194
|
}
|
|
136
195
|
|
|
137
196
|
#dependencyEventEntityId(input: {
|
|
@@ -178,11 +237,7 @@ export class MutationService {
|
|
|
178
237
|
createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
|
|
179
238
|
return this.#writeTransaction((): EpicRecord => {
|
|
180
239
|
const epic = this.#domain.createEpic(input);
|
|
181
|
-
this.#
|
|
182
|
-
title: epic.title,
|
|
183
|
-
description: epic.description,
|
|
184
|
-
status: epic.status,
|
|
185
|
-
});
|
|
240
|
+
this.#emitEpicCreated(epic);
|
|
186
241
|
return epic;
|
|
187
242
|
});
|
|
188
243
|
}
|
|
@@ -204,28 +259,14 @@ export class MutationService {
|
|
|
204
259
|
dependencySpecs: input.dependencySpecs,
|
|
205
260
|
});
|
|
206
261
|
|
|
207
|
-
this.#
|
|
208
|
-
title: epic.title,
|
|
209
|
-
description: epic.description,
|
|
210
|
-
status: epic.status,
|
|
211
|
-
});
|
|
262
|
+
this.#emitEpicCreated(epic);
|
|
212
263
|
|
|
213
264
|
for (const task of created.tasks) {
|
|
214
|
-
this.#
|
|
215
|
-
epic_id: task.epicId,
|
|
216
|
-
title: task.title,
|
|
217
|
-
description: task.description,
|
|
218
|
-
status: task.status,
|
|
219
|
-
});
|
|
265
|
+
this.#emitTaskCreated(task);
|
|
220
266
|
}
|
|
221
267
|
|
|
222
268
|
for (const subtask of created.subtasks) {
|
|
223
|
-
this.#
|
|
224
|
-
task_id: subtask.taskId,
|
|
225
|
-
title: subtask.title,
|
|
226
|
-
description: subtask.description,
|
|
227
|
-
status: subtask.status,
|
|
228
|
-
});
|
|
269
|
+
this.#emitSubtaskCreated(subtask);
|
|
229
270
|
}
|
|
230
271
|
|
|
231
272
|
for (const dependency of created.dependencies) {
|
|
@@ -263,11 +304,83 @@ export class MutationService {
|
|
|
263
304
|
validateStatusTransition(existing.status, input.status, "epic", id);
|
|
264
305
|
}
|
|
265
306
|
const epic = this.#domain.updateEpic(id, input);
|
|
266
|
-
this.#
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
307
|
+
this.#emitEpicUpdated(epic);
|
|
308
|
+
return epic;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Atomic If-Match CAS variant of {@link updateEpic}.
|
|
314
|
+
*
|
|
315
|
+
* The `If-Match` precondition is enforced INSIDE the write transaction
|
|
316
|
+
* via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND version = ?`).
|
|
317
|
+
* If zero rows are affected we determine whether the row is missing
|
|
318
|
+
* (→ `DomainError(not_found)`) or merely stale (→ {@link PreconditionFailedError}
|
|
319
|
+
* with the freshly-fetched `currentVersion`).
|
|
320
|
+
*
|
|
321
|
+
* This eliminates the read-check-then-write race the previous route-level
|
|
322
|
+
* check had: a concurrent writer could land between `parseIfMatchHeader`'s
|
|
323
|
+
* read and `mutations.updateEpic`'s BEGIN IMMEDIATE, allowing the second
|
|
324
|
+
* PATCH to silently overwrite the first.
|
|
325
|
+
*
|
|
326
|
+
* Input validation (non-empty / status transition) mirrors
|
|
327
|
+
* `domain.updateEpic` — it runs inside the same transaction so a
|
|
328
|
+
* malformed PATCH never observes the CAS branch.
|
|
329
|
+
*/
|
|
330
|
+
updateEpicWithIfMatch(
|
|
331
|
+
id: string,
|
|
332
|
+
ifMatchVersion: number,
|
|
333
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
334
|
+
): EpicRecord {
|
|
335
|
+
return this.#writeTransaction((): EpicRecord => {
|
|
336
|
+
// Resolve the current row first inside the tx so we can (1) surface
|
|
337
|
+
// `not_found` before the CAS and (2) materialise the per-field
|
|
338
|
+
// defaults that domain.updateEpic computes from the current row.
|
|
339
|
+
// Holding the BEGIN IMMEDIATE write lock guarantees no other writer
|
|
340
|
+
// mutates the row between this read and the CAS UPDATE.
|
|
341
|
+
const existing = this.#domain.getEpicOrThrow(id);
|
|
342
|
+
|
|
343
|
+
const nextTitle = input.title !== undefined
|
|
344
|
+
? assertNonEmptyField("title", input.title)
|
|
345
|
+
: existing.title;
|
|
346
|
+
const nextDescription = input.description !== undefined
|
|
347
|
+
? assertNonEmptyField("description", input.description)
|
|
348
|
+
: existing.description;
|
|
349
|
+
const nextStatus = input.status !== undefined
|
|
350
|
+
? assertNonEmptyField("status", input.status)
|
|
351
|
+
: existing.status;
|
|
352
|
+
|
|
353
|
+
if (input.status !== undefined) {
|
|
354
|
+
validateStatusTransition(existing.status, nextStatus, "epic", id);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const now: number = Date.now();
|
|
358
|
+
const result = this.#db
|
|
359
|
+
.query(
|
|
360
|
+
`UPDATE epics
|
|
361
|
+
SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1
|
|
362
|
+
WHERE id = ?
|
|
363
|
+
AND version = ?
|
|
364
|
+
RETURNING id`,
|
|
365
|
+
)
|
|
366
|
+
.get(nextTitle, nextDescription, nextStatus, now, id, ifMatchVersion) as { id: string } | null;
|
|
367
|
+
|
|
368
|
+
if (result === null) {
|
|
369
|
+
// Zero rows changed. We already proved the row exists via
|
|
370
|
+
// getEpicOrThrow, so the only remaining failure mode is a stale
|
|
371
|
+
// precondition. Re-fetch version inside the same tx so the
|
|
372
|
+
// caller's 409 carries the freshest value.
|
|
373
|
+
const current = this.#domain.getEpicOrThrow(id);
|
|
374
|
+
throw new PreconditionFailedError({
|
|
375
|
+
entityKind: "epic",
|
|
376
|
+
entityId: id,
|
|
377
|
+
currentVersion: current.version,
|
|
378
|
+
providedVersion: ifMatchVersion,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const epic = this.#domain.getEpicOrThrow(id);
|
|
383
|
+
this.#emitEpicUpdated(epic);
|
|
271
384
|
return epic;
|
|
272
385
|
});
|
|
273
386
|
}
|
|
@@ -281,22 +394,69 @@ export class MutationService {
|
|
|
281
394
|
});
|
|
282
395
|
}
|
|
283
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Atomic If-Match CAS variant of {@link updateEpicStatusCascade}.
|
|
399
|
+
*
|
|
400
|
+
* Cascades touch many rows so a row-level SQL CAS isn't a natural fit;
|
|
401
|
+
* instead the precondition is enforced inside the same writeTransaction
|
|
402
|
+
* as the cascade plan. The BEGIN IMMEDIATE write lock guarantees no
|
|
403
|
+
* other writer mutates the epic between the precondition read and the
|
|
404
|
+
* cascade application — eliminating the same race the per-row CAS
|
|
405
|
+
* variants close.
|
|
406
|
+
*/
|
|
407
|
+
updateEpicStatusCascadeWithIfMatch(
|
|
408
|
+
id: string,
|
|
409
|
+
ifMatchVersion: number,
|
|
410
|
+
status: string,
|
|
411
|
+
): StatusCascadePlan {
|
|
412
|
+
return this.#writeTransaction((): StatusCascadePlan => {
|
|
413
|
+
const existing = this.#domain.getEpicOrThrow(id);
|
|
414
|
+
if (existing.version !== ifMatchVersion) {
|
|
415
|
+
throw new PreconditionFailedError({
|
|
416
|
+
entityKind: "epic",
|
|
417
|
+
entityId: id,
|
|
418
|
+
currentVersion: existing.version,
|
|
419
|
+
providedVersion: ifMatchVersion,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const plan = this.#domain.planStatusCascade("epic", id, status);
|
|
423
|
+
this.#assertCascadeNotBlocked(plan);
|
|
424
|
+
this.#applyStatusCascadePlan(plan);
|
|
425
|
+
return plan;
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
284
429
|
deleteEpic(id: string): void {
|
|
285
430
|
this.#writeTransaction((): void => {
|
|
431
|
+
const tasks = this.#domain.listTasks(id);
|
|
432
|
+
const taskIds = tasks.map((task) => task.id);
|
|
433
|
+
const subtasksByTaskId = taskIds.length > 0
|
|
434
|
+
? this.#domain.listSubtasksByTaskIds(taskIds)
|
|
435
|
+
: new Map<string, readonly SubtaskRecord[]>();
|
|
436
|
+
|
|
286
437
|
this.#domain.deleteEpic(id);
|
|
287
|
-
|
|
438
|
+
|
|
439
|
+
const epicDeleteEventId = this.#emitEpicDeleted(id);
|
|
440
|
+
|
|
441
|
+
for (const task of tasks) {
|
|
442
|
+
// Stamp cascaded task.deleted events with the parent epic-delete event
|
|
443
|
+
// id so peer worktrees can suppress the per-task __delete__ conflict
|
|
444
|
+
// when an epic-level conflict is already pending. Without this, a peer
|
|
445
|
+
// with edits on the epic's tasks gets N+1 conflicts (epic + one per
|
|
446
|
+
// task) instead of the single epic-level conflict.
|
|
447
|
+
const taskDeleteEventId = this.#emitTaskDeleted(task.id, { sourceEventId: epicDeleteEventId });
|
|
448
|
+
const subtasks = subtasksByTaskId.get(task.id) ?? [];
|
|
449
|
+
for (const subtask of subtasks) {
|
|
450
|
+
this.#emitSubtaskDeleted(subtask.id, { taskId: task.id, sourceEventId: taskDeleteEventId });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
288
453
|
});
|
|
289
454
|
}
|
|
290
455
|
|
|
291
456
|
createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
|
|
292
457
|
return this.#writeTransaction((): TaskRecord => {
|
|
293
458
|
const task = this.#domain.createTask(input);
|
|
294
|
-
this.#
|
|
295
|
-
epic_id: task.epicId,
|
|
296
|
-
title: task.title,
|
|
297
|
-
description: task.description,
|
|
298
|
-
status: task.status,
|
|
299
|
-
});
|
|
459
|
+
this.#emitTaskCreated(task);
|
|
300
460
|
return task;
|
|
301
461
|
});
|
|
302
462
|
}
|
|
@@ -305,12 +465,7 @@ export class MutationService {
|
|
|
305
465
|
return this.#writeTransaction((): CompactTaskBatchCreateResult => {
|
|
306
466
|
const created = this.#domain.createTaskBatch(input);
|
|
307
467
|
for (const task of created.tasks) {
|
|
308
|
-
this.#
|
|
309
|
-
epic_id: task.epicId,
|
|
310
|
-
title: task.title,
|
|
311
|
-
description: task.description,
|
|
312
|
-
status: task.status,
|
|
313
|
-
});
|
|
468
|
+
this.#emitTaskCreated(task);
|
|
314
469
|
}
|
|
315
470
|
return created;
|
|
316
471
|
});
|
|
@@ -325,21 +480,11 @@ export class MutationService {
|
|
|
325
480
|
return this.#writeTransaction((): CompactEpicExpandResult => {
|
|
326
481
|
const created = this.#domain.expandEpic(input);
|
|
327
482
|
for (const task of created.tasks) {
|
|
328
|
-
this.#
|
|
329
|
-
epic_id: task.epicId,
|
|
330
|
-
title: task.title,
|
|
331
|
-
description: task.description,
|
|
332
|
-
status: task.status,
|
|
333
|
-
});
|
|
483
|
+
this.#emitTaskCreated(task);
|
|
334
484
|
}
|
|
335
485
|
|
|
336
486
|
for (const subtask of created.subtasks) {
|
|
337
|
-
this.#
|
|
338
|
-
task_id: subtask.taskId,
|
|
339
|
-
title: subtask.title,
|
|
340
|
-
description: subtask.description,
|
|
341
|
-
status: subtask.status,
|
|
342
|
-
});
|
|
487
|
+
this.#emitSubtaskCreated(subtask);
|
|
343
488
|
}
|
|
344
489
|
|
|
345
490
|
for (const dependency of created.dependencies) {
|
|
@@ -371,17 +516,433 @@ export class MutationService {
|
|
|
371
516
|
validateStatusTransition(existing.status, input.status, "task", id);
|
|
372
517
|
}
|
|
373
518
|
const task = this.#domain.updateTask(id, input);
|
|
374
|
-
this.#
|
|
375
|
-
epic_id: task.epicId,
|
|
376
|
-
title: task.title,
|
|
377
|
-
description: task.description,
|
|
378
|
-
status: task.status,
|
|
379
|
-
owner: task.owner,
|
|
380
|
-
});
|
|
519
|
+
this.#emitTaskUpdated(task);
|
|
381
520
|
return task;
|
|
382
521
|
});
|
|
383
522
|
}
|
|
384
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Atomic If-Match CAS variant of {@link updateTask}. See
|
|
526
|
+
* {@link updateEpicWithIfMatch} for the rationale; the key difference is
|
|
527
|
+
* that tasks also enforce dependency-gating via
|
|
528
|
+
* `assertNoUnresolvedDependenciesForStatusTransition` for status
|
|
529
|
+
* transitions and may set/clear `owner`.
|
|
530
|
+
*/
|
|
531
|
+
updateTaskWithIfMatch(
|
|
532
|
+
id: string,
|
|
533
|
+
ifMatchVersion: number,
|
|
534
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
535
|
+
): TaskRecord {
|
|
536
|
+
return this.#writeTransaction((): TaskRecord => {
|
|
537
|
+
const existing = this.#domain.getTaskOrThrow(id);
|
|
538
|
+
|
|
539
|
+
const nextTitle = input.title !== undefined
|
|
540
|
+
? assertNonEmptyField("title", input.title)
|
|
541
|
+
: existing.title;
|
|
542
|
+
const nextDescription = input.description !== undefined
|
|
543
|
+
? assertNonEmptyField("description", input.description)
|
|
544
|
+
: existing.description;
|
|
545
|
+
const nextStatus = input.status !== undefined
|
|
546
|
+
? assertNonEmptyField("status", input.status)
|
|
547
|
+
: existing.status;
|
|
548
|
+
const normalizedOwner = normalizeOwnerInput(input.owner);
|
|
549
|
+
const nextOwner = normalizedOwner === undefined ? existing.owner : normalizedOwner;
|
|
550
|
+
|
|
551
|
+
if (input.status !== undefined) {
|
|
552
|
+
validateStatusTransition(existing.status, nextStatus, "task", id);
|
|
553
|
+
}
|
|
554
|
+
// Dependency gating mirrors domain.updateTask. Even when the status
|
|
555
|
+
// is unchanged this is a no-op because
|
|
556
|
+
// assertNoUnresolvedDependenciesForStatusTransition short-circuits
|
|
557
|
+
// when from === to.
|
|
558
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
|
|
559
|
+
|
|
560
|
+
const now: number = Date.now();
|
|
561
|
+
const result = this.#db
|
|
562
|
+
.query(
|
|
563
|
+
`UPDATE tasks
|
|
564
|
+
SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
|
|
565
|
+
WHERE id = ?
|
|
566
|
+
AND version = ?
|
|
567
|
+
RETURNING id`,
|
|
568
|
+
)
|
|
569
|
+
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchVersion) as { id: string } | null;
|
|
570
|
+
|
|
571
|
+
if (result === null) {
|
|
572
|
+
const current = this.#domain.getTaskOrThrow(id);
|
|
573
|
+
throw new PreconditionFailedError({
|
|
574
|
+
entityKind: "task",
|
|
575
|
+
entityId: id,
|
|
576
|
+
currentVersion: current.version,
|
|
577
|
+
providedVersion: ifMatchVersion,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const task = this.#domain.getTaskOrThrow(id);
|
|
582
|
+
this.#emitTaskUpdated(task);
|
|
583
|
+
return task;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Atomically append text to a task's description using a single SQL
|
|
589
|
+
* `description = description || ?` expression inside a write transaction.
|
|
590
|
+
*
|
|
591
|
+
* This eliminates the read-modify-write TOCTOU race that existed when
|
|
592
|
+
* callers read the description, computed the new value in application code,
|
|
593
|
+
* then issued a separate update write. Two concurrent appends targeting
|
|
594
|
+
* the same task will each hold their own BEGIN IMMEDIATE lock in turn and
|
|
595
|
+
* see the other's text already committed, so neither write is lost.
|
|
596
|
+
*
|
|
597
|
+
* The separator matches the `appendLine` helper used across the CLI:
|
|
598
|
+
* an empty description gets the text directly, a non-empty description
|
|
599
|
+
* gets a `\n` prefix on the appended text.
|
|
600
|
+
*
|
|
601
|
+
* Optional `status` is validated through the normal status-machine checker
|
|
602
|
+
* and applied atomically in the same statement so combined
|
|
603
|
+
* `--append --status` flows remain a single write.
|
|
604
|
+
*/
|
|
605
|
+
appendToTaskDescription(input: {
|
|
606
|
+
taskId: string;
|
|
607
|
+
append: string;
|
|
608
|
+
status?: string | undefined;
|
|
609
|
+
owner?: string | null | undefined;
|
|
610
|
+
}): TaskRecord {
|
|
611
|
+
return this.#writeTransaction((): TaskRecord => {
|
|
612
|
+
const existing = this.#domain.getTaskOrThrow(input.taskId);
|
|
613
|
+
if (input.status !== undefined) {
|
|
614
|
+
validateStatusTransition(existing.status, input.status, "task", input.taskId);
|
|
615
|
+
// Enforce dependency gating BEFORE the direct UPDATE bypass below.
|
|
616
|
+
// The combined append+status path issues its own SQL UPDATE rather
|
|
617
|
+
// than going through `domain.updateTask`, so without this call a
|
|
618
|
+
// blocked task with unresolved upstream deps could be flipped into
|
|
619
|
+
// a gated status (in_progress/done) — defeating the
|
|
620
|
+
// dependency_blocked contract. Symmetric with claimTask /
|
|
621
|
+
// markTaskDoneAtomically gating sites.
|
|
622
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
623
|
+
input.taskId,
|
|
624
|
+
"task",
|
|
625
|
+
existing.status,
|
|
626
|
+
input.status,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const separator = existing.description.length > 0 ? "\n" : "";
|
|
630
|
+
const now = Date.now();
|
|
631
|
+
const nextStatus = input.status ?? existing.status;
|
|
632
|
+
const nextOwner = input.owner !== undefined
|
|
633
|
+
? (input.owner ?? null)
|
|
634
|
+
: existing.owner;
|
|
635
|
+
this.#db
|
|
636
|
+
.query(
|
|
637
|
+
"UPDATE tasks SET description = description || ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
|
|
638
|
+
)
|
|
639
|
+
.run(separator + input.append, nextStatus, nextOwner, now, input.taskId);
|
|
640
|
+
const task = this.#domain.getTaskOrThrow(input.taskId);
|
|
641
|
+
this.#emitTaskUpdated(task);
|
|
642
|
+
return task;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Atomically append text to a subtask's description.
|
|
648
|
+
* Same semantics as `appendToTaskDescription`.
|
|
649
|
+
*/
|
|
650
|
+
appendToSubtaskDescription(input: {
|
|
651
|
+
subtaskId: string;
|
|
652
|
+
append: string;
|
|
653
|
+
status?: string | undefined;
|
|
654
|
+
owner?: string | null | undefined;
|
|
655
|
+
}): SubtaskRecord {
|
|
656
|
+
return this.#writeTransaction((): SubtaskRecord => {
|
|
657
|
+
const existing = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
658
|
+
if (input.status !== undefined) {
|
|
659
|
+
validateStatusTransition(existing.status, input.status, "subtask", input.subtaskId);
|
|
660
|
+
// Mirror of appendToTaskDescription: gate the combined
|
|
661
|
+
// append+status path through assertNoUnresolvedDependenciesForStatusTransition
|
|
662
|
+
// so subtasks cannot bypass dependency resolution via the bypass UPDATE.
|
|
663
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
664
|
+
input.subtaskId,
|
|
665
|
+
"subtask",
|
|
666
|
+
existing.status,
|
|
667
|
+
input.status,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
const separator = existing.description.length > 0 ? "\n" : "";
|
|
671
|
+
const now = Date.now();
|
|
672
|
+
const nextStatus = input.status ?? existing.status;
|
|
673
|
+
const nextOwner = input.owner !== undefined
|
|
674
|
+
? (input.owner ?? null)
|
|
675
|
+
: existing.owner;
|
|
676
|
+
this.#db
|
|
677
|
+
.query(
|
|
678
|
+
"UPDATE subtasks SET description = description || ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
|
|
679
|
+
)
|
|
680
|
+
.run(separator + input.append, nextStatus, nextOwner, now, input.subtaskId);
|
|
681
|
+
const subtask = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
682
|
+
this.#emitSubtaskUpdated(subtask);
|
|
683
|
+
return subtask;
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Atomically append text to an epic's description.
|
|
689
|
+
* Same semantics as `appendToTaskDescription`.
|
|
690
|
+
*/
|
|
691
|
+
appendToEpicDescription(input: {
|
|
692
|
+
epicId: string;
|
|
693
|
+
append: string;
|
|
694
|
+
status?: string | undefined;
|
|
695
|
+
}): EpicRecord {
|
|
696
|
+
return this.#writeTransaction((): EpicRecord => {
|
|
697
|
+
const existing = this.#domain.getEpicOrThrow(input.epicId);
|
|
698
|
+
if (input.status !== undefined) {
|
|
699
|
+
validateStatusTransition(existing.status, input.status, "epic", input.epicId);
|
|
700
|
+
}
|
|
701
|
+
const separator = existing.description.length > 0 ? "\n" : "";
|
|
702
|
+
const now = Date.now();
|
|
703
|
+
const nextStatus = input.status ?? existing.status;
|
|
704
|
+
this.#db
|
|
705
|
+
.query(
|
|
706
|
+
"UPDATE epics SET description = description || ?, status = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
|
|
707
|
+
)
|
|
708
|
+
.run(separator + input.append, nextStatus, now, input.epicId);
|
|
709
|
+
const epic = this.#domain.getEpicOrThrow(input.epicId);
|
|
710
|
+
this.#emitEpicUpdated(epic);
|
|
711
|
+
return epic;
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Mark a task `done` atomically in a single write transaction.
|
|
717
|
+
*
|
|
718
|
+
* Background (Trekoon task 4a0111c4-6400-4a77-b4f3-d9ad863e47db / system
|
|
719
|
+
* hardening): the legacy `task done` handler issued two separate
|
|
720
|
+
* `updateTask` mutations whenever the task was in `todo` or `blocked`
|
|
721
|
+
* (auto-stepping through `in_progress` to satisfy the public status-machine
|
|
722
|
+
* checker). Each `updateTask` ran in its own `BEGIN IMMEDIATE` transaction,
|
|
723
|
+
* so a crash, kill, or thrown exception between the two writes could leave
|
|
724
|
+
* the task wedged in `in_progress` forever — even though the user had
|
|
725
|
+
* asked to mark it done.
|
|
726
|
+
*
|
|
727
|
+
* This method consolidates the operation into one transaction and bypasses
|
|
728
|
+
* `validateStatusTransition`. THIS IS THE ONE INTENTIONAL DIRECT-STATUS-WRITE
|
|
729
|
+
* EXCEPTION in the codebase: every other status mutation MUST go through the
|
|
730
|
+
* public transition checker. The bypass is safe here because the resulting
|
|
731
|
+
* status (`done`) is a documented terminal target reachable from every
|
|
732
|
+
* non-`done` status (`todo`, `blocked`, `in_progress`) in the status
|
|
733
|
+
* machine. See docs/machine-contracts.md for the canonical exceptions list.
|
|
734
|
+
*
|
|
735
|
+
* Contract:
|
|
736
|
+
* - On success: task row is `done`; exactly one `task.updated` event is
|
|
737
|
+
* emitted (no intermediate `in_progress` event); pre-blocked reverse
|
|
738
|
+
* deps are captured inside the same transaction so the unblocked-array
|
|
739
|
+
* computed by the caller is consistent with the post-COMMIT snapshot.
|
|
740
|
+
* - On failure (throw): `ROLLBACK` restores the original status — task is
|
|
741
|
+
* NEVER observable in `in_progress` due to a partial done.
|
|
742
|
+
*
|
|
743
|
+
* The caller supplies `computeSnapshot` which runs inside the transaction
|
|
744
|
+
* AFTER the row has been flipped to `done`. This is where the command
|
|
745
|
+
* layer computes readiness / unblocked / next without leaking
|
|
746
|
+
* `buildTaskReadiness` into the domain layer.
|
|
747
|
+
*/
|
|
748
|
+
markTaskDoneAtomically<T>(input: {
|
|
749
|
+
taskId: string;
|
|
750
|
+
computeSnapshot: (params: {
|
|
751
|
+
domain: TrackerDomain;
|
|
752
|
+
completed: TaskRecord;
|
|
753
|
+
preBlockedReverseDepIds: readonly string[];
|
|
754
|
+
}) => T;
|
|
755
|
+
}): T {
|
|
756
|
+
return this.#writeTransaction((): T => {
|
|
757
|
+
const existing = this.#domain.getTaskOrThrow(input.taskId);
|
|
758
|
+
|
|
759
|
+
// Positive allowlist of acceptable source statuses for the atomic done
|
|
760
|
+
// bypass. Any future terminal status (e.g. `cancelled`, `archived`) MUST
|
|
761
|
+
// be explicitly added here before it can be auto-flipped to `done`;
|
|
762
|
+
// otherwise it falls through to this guard and surfaces as
|
|
763
|
+
// `already_done`. This is safer than the legacy
|
|
764
|
+
// `existing.status === "done"` negative check, which silently accepted
|
|
765
|
+
// any new terminal status as "still allowed to transition to done".
|
|
766
|
+
if (existing.status !== "todo" && existing.status !== "blocked" && existing.status !== "in_progress") {
|
|
767
|
+
throw new DomainError({
|
|
768
|
+
code: "already_done",
|
|
769
|
+
message: "Task is already done",
|
|
770
|
+
details: { id: input.taskId },
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Enforce dependency gating BEFORE the direct UPDATE bypass. The atomic
|
|
775
|
+
// done flow skips `validateStatusTransition`, so without this call a
|
|
776
|
+
// blocked task with unresolved upstream deps would be silently flipped
|
|
777
|
+
// to `done` — defeating the dependency_blocked contract.
|
|
778
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
779
|
+
input.taskId,
|
|
780
|
+
"task",
|
|
781
|
+
existing.status,
|
|
782
|
+
"done",
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
// Snapshot direct task-level reverse-dep blockers BEFORE the status
|
|
786
|
+
// flip so the post-write snapshot can diff "newly-unblocked" tasks.
|
|
787
|
+
const reverseDeps = this.#domain.listReverseDependencies(input.taskId);
|
|
788
|
+
const directRevDepTaskIds = reverseDeps
|
|
789
|
+
.filter((rd) => rd.isDirect && rd.kind === "task")
|
|
790
|
+
.map((rd) => rd.id);
|
|
791
|
+
const preDepStatuses = this.#domain.batchResolveDependencyStatuses(directRevDepTaskIds);
|
|
792
|
+
const preBlockedReverseDepIds = directRevDepTaskIds.filter((id) => {
|
|
793
|
+
const resolved = preDepStatuses.get(id);
|
|
794
|
+
return resolved !== undefined && resolved.blockers.length > 0;
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// Direct UPDATE bypassing validateStatusTransition. See method-doc
|
|
798
|
+
// comment above for the rationale: this is the ONLY allowed direct
|
|
799
|
+
// status write in the codebase; do not copy this pattern elsewhere.
|
|
800
|
+
const now: number = Date.now();
|
|
801
|
+
this.#db
|
|
802
|
+
.query("UPDATE tasks SET status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
803
|
+
.run("done", now, input.taskId);
|
|
804
|
+
|
|
805
|
+
const completed = this.#domain.getTaskOrThrow(input.taskId);
|
|
806
|
+
|
|
807
|
+
// Emit exactly one task.updated event. Payload shape matches the
|
|
808
|
+
// canonical #emitTaskUpdated contract — sync consumers see a single
|
|
809
|
+
// logical "task became done" transition, never a phantom intermediate
|
|
810
|
+
// in_progress step.
|
|
811
|
+
this.#emitTaskUpdated(completed);
|
|
812
|
+
|
|
813
|
+
return input.computeSnapshot({
|
|
814
|
+
domain: this.#domain,
|
|
815
|
+
completed,
|
|
816
|
+
preBlockedReverseDepIds,
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Atomically claim a task for an owner using a SQL compare-and-swap.
|
|
823
|
+
*
|
|
824
|
+
* The UPDATE predicate ensures:
|
|
825
|
+
* - Only `todo` or `blocked` tasks can be claimed (not `done` or
|
|
826
|
+
* already-in_progress tasks owned by someone else).
|
|
827
|
+
* - An owner can re-claim their own in-progress task (idempotent).
|
|
828
|
+
* - Exactly one caller gets `claimed: true` when two concurrent calls race.
|
|
829
|
+
*
|
|
830
|
+
* Returns `{ claimed, currentOwner, currentStatus }`.
|
|
831
|
+
* When `claimed` is true the returned `task` is the post-update record.
|
|
832
|
+
*/
|
|
833
|
+
claimTask(input: { taskId: string; owner: string }): {
|
|
834
|
+
claimed: boolean;
|
|
835
|
+
currentOwner: string | null;
|
|
836
|
+
currentStatus: string;
|
|
837
|
+
task?: TaskRecord;
|
|
838
|
+
} {
|
|
839
|
+
return this.#writeTransaction(() => {
|
|
840
|
+
// Enforce dependency gating BEFORE the CAS so a blocked-by-unresolved-dep
|
|
841
|
+
// task cannot be silently flipped into `in_progress`. Symmetric with
|
|
842
|
+
// markTaskDoneAtomically: both "forward-progress" terminal/active
|
|
843
|
+
// transitions go through the same gating call. A pre-existing in_progress
|
|
844
|
+
// or done row is short-circuited because its existing status equals the
|
|
845
|
+
// next status (or is non-gated terminal) and the gating helper returns
|
|
846
|
+
// early.
|
|
847
|
+
const existing = this.#domain.getTask(input.taskId);
|
|
848
|
+
if (existing && (existing.status === "todo" || existing.status === "blocked")) {
|
|
849
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
850
|
+
input.taskId,
|
|
851
|
+
"task",
|
|
852
|
+
existing.status,
|
|
853
|
+
"in_progress",
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const now = Date.now();
|
|
858
|
+
const result = this.#db
|
|
859
|
+
.query(
|
|
860
|
+
`UPDATE tasks
|
|
861
|
+
SET status = 'in_progress', owner = ?, updated_at = ?, version = version + 1
|
|
862
|
+
WHERE id = ?
|
|
863
|
+
AND status IN ('todo', 'blocked')
|
|
864
|
+
AND (owner IS NULL OR owner = ?)
|
|
865
|
+
RETURNING id`,
|
|
866
|
+
)
|
|
867
|
+
.get(input.owner, now, input.taskId, input.owner) as { id: string } | null;
|
|
868
|
+
|
|
869
|
+
if (result !== null) {
|
|
870
|
+
const task = this.#domain.getTaskOrThrow(input.taskId);
|
|
871
|
+
this.#emitTaskUpdated(task);
|
|
872
|
+
return {
|
|
873
|
+
claimed: true,
|
|
874
|
+
currentOwner: input.owner,
|
|
875
|
+
currentStatus: "in_progress",
|
|
876
|
+
task,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// CAS failed — fetch current state for the caller
|
|
881
|
+
const current = this.#domain.getTaskOrThrow(input.taskId);
|
|
882
|
+
return {
|
|
883
|
+
claimed: false,
|
|
884
|
+
currentOwner: current.owner,
|
|
885
|
+
currentStatus: current.status,
|
|
886
|
+
};
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Atomically claim a subtask for an owner using a SQL compare-and-swap.
|
|
892
|
+
* Same semantics as `claimTask`.
|
|
893
|
+
*/
|
|
894
|
+
claimSubtask(input: { subtaskId: string; owner: string }): {
|
|
895
|
+
claimed: boolean;
|
|
896
|
+
currentOwner: string | null;
|
|
897
|
+
currentStatus: string;
|
|
898
|
+
subtask?: SubtaskRecord;
|
|
899
|
+
} {
|
|
900
|
+
return this.#writeTransaction(() => {
|
|
901
|
+
// Mirror of claimTask: gate the todo/blocked → in_progress transition
|
|
902
|
+
// through assertNoUnresolvedDependenciesForStatusTransition so subtask
|
|
903
|
+
// claims cannot bypass dependency resolution.
|
|
904
|
+
const existing = this.#domain.getSubtask(input.subtaskId);
|
|
905
|
+
if (existing && (existing.status === "todo" || existing.status === "blocked")) {
|
|
906
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
907
|
+
input.subtaskId,
|
|
908
|
+
"subtask",
|
|
909
|
+
existing.status,
|
|
910
|
+
"in_progress",
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const now = Date.now();
|
|
915
|
+
const result = this.#db
|
|
916
|
+
.query(
|
|
917
|
+
`UPDATE subtasks
|
|
918
|
+
SET status = 'in_progress', owner = ?, updated_at = ?, version = version + 1
|
|
919
|
+
WHERE id = ?
|
|
920
|
+
AND status IN ('todo', 'blocked')
|
|
921
|
+
AND (owner IS NULL OR owner = ?)
|
|
922
|
+
RETURNING id`,
|
|
923
|
+
)
|
|
924
|
+
.get(input.owner, now, input.subtaskId, input.owner) as { id: string } | null;
|
|
925
|
+
|
|
926
|
+
if (result !== null) {
|
|
927
|
+
const subtask = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
928
|
+
this.#emitSubtaskUpdated(subtask);
|
|
929
|
+
return {
|
|
930
|
+
claimed: true,
|
|
931
|
+
currentOwner: input.owner,
|
|
932
|
+
currentStatus: "in_progress",
|
|
933
|
+
subtask,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const current = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
938
|
+
return {
|
|
939
|
+
claimed: false,
|
|
940
|
+
currentOwner: current.owner,
|
|
941
|
+
currentStatus: current.status,
|
|
942
|
+
};
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
385
946
|
updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
|
|
386
947
|
return this.#writeTransaction((): StatusCascadePlan => {
|
|
387
948
|
const plan = this.#domain.planStatusCascade("task", id, status);
|
|
@@ -395,13 +956,10 @@ export class MutationService {
|
|
|
395
956
|
return this.#writeTransaction((): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } => {
|
|
396
957
|
const plan = this.#domain.planTaskDeletion(id);
|
|
397
958
|
this.#domain.deleteTask(id);
|
|
398
|
-
const taskDeleteEventId = this.#
|
|
959
|
+
const taskDeleteEventId = this.#emitTaskDeleted(id);
|
|
399
960
|
|
|
400
961
|
for (const subtaskId of plan.subtaskIds) {
|
|
401
|
-
this.#
|
|
402
|
-
task_id: id,
|
|
403
|
-
source_event_id: taskDeleteEventId,
|
|
404
|
-
});
|
|
962
|
+
this.#emitSubtaskDeleted(subtaskId, { taskId: id, sourceEventId: taskDeleteEventId });
|
|
405
963
|
}
|
|
406
964
|
|
|
407
965
|
for (const dependency of plan.touchingDependencies) {
|
|
@@ -435,12 +993,7 @@ export class MutationService {
|
|
|
435
993
|
}): SubtaskRecord {
|
|
436
994
|
return this.#writeTransaction((): SubtaskRecord => {
|
|
437
995
|
const subtask = this.#domain.createSubtask(input);
|
|
438
|
-
this.#
|
|
439
|
-
task_id: subtask.taskId,
|
|
440
|
-
title: subtask.title,
|
|
441
|
-
description: subtask.description,
|
|
442
|
-
status: subtask.status,
|
|
443
|
-
});
|
|
996
|
+
this.#emitSubtaskCreated(subtask);
|
|
444
997
|
return subtask;
|
|
445
998
|
});
|
|
446
999
|
}
|
|
@@ -455,12 +1008,7 @@ export class MutationService {
|
|
|
455
1008
|
}): AtomicIdempotentMutationResult {
|
|
456
1009
|
return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
|
|
457
1010
|
const subtask = this.#domain.createSubtask(input);
|
|
458
|
-
this.#
|
|
459
|
-
task_id: subtask.taskId,
|
|
460
|
-
title: subtask.title,
|
|
461
|
-
description: subtask.description,
|
|
462
|
-
status: subtask.status,
|
|
463
|
-
});
|
|
1011
|
+
this.#emitSubtaskCreated(subtask);
|
|
464
1012
|
return {
|
|
465
1013
|
state: "completed",
|
|
466
1014
|
status: 201,
|
|
@@ -473,12 +1021,7 @@ export class MutationService {
|
|
|
473
1021
|
return this.#writeTransaction((): CompactSubtaskBatchCreateResult => {
|
|
474
1022
|
const created = this.#domain.createSubtaskBatch(input);
|
|
475
1023
|
for (const subtask of created.subtasks) {
|
|
476
|
-
this.#
|
|
477
|
-
task_id: subtask.taskId,
|
|
478
|
-
title: subtask.title,
|
|
479
|
-
description: subtask.description,
|
|
480
|
-
status: subtask.status,
|
|
481
|
-
});
|
|
1024
|
+
this.#emitSubtaskCreated(subtask);
|
|
482
1025
|
}
|
|
483
1026
|
return created;
|
|
484
1027
|
});
|
|
@@ -494,13 +1037,66 @@ export class MutationService {
|
|
|
494
1037
|
validateStatusTransition(existing.status, input.status, "subtask", id);
|
|
495
1038
|
}
|
|
496
1039
|
const subtask = this.#domain.updateSubtask(id, input);
|
|
497
|
-
this.#
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
1040
|
+
this.#emitSubtaskUpdated(subtask);
|
|
1041
|
+
return subtask;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Atomic If-Match CAS variant of {@link updateSubtask}. Mirrors
|
|
1047
|
+
* {@link updateTaskWithIfMatch} except subtask `description` may be
|
|
1048
|
+
* empty (matches `normalizeSubtaskDescription` semantics).
|
|
1049
|
+
*/
|
|
1050
|
+
updateSubtaskWithIfMatch(
|
|
1051
|
+
id: string,
|
|
1052
|
+
ifMatchVersion: number,
|
|
1053
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
1054
|
+
): SubtaskRecord {
|
|
1055
|
+
return this.#writeTransaction((): SubtaskRecord => {
|
|
1056
|
+
const existing = this.#domain.getSubtaskOrThrow(id);
|
|
1057
|
+
|
|
1058
|
+
const nextTitle = input.title !== undefined
|
|
1059
|
+
? assertNonEmptyField("title", input.title)
|
|
1060
|
+
: existing.title;
|
|
1061
|
+
// Subtask description allows empty strings — mirror
|
|
1062
|
+
// normalizeSubtaskDescription rather than asserting non-empty.
|
|
1063
|
+
const nextDescription = input.description !== undefined
|
|
1064
|
+
? input.description.trim()
|
|
1065
|
+
: existing.description;
|
|
1066
|
+
const nextStatus = input.status !== undefined
|
|
1067
|
+
? assertNonEmptyField("status", input.status)
|
|
1068
|
+
: existing.status;
|
|
1069
|
+
const normalizedOwner = normalizeOwnerInput(input.owner);
|
|
1070
|
+
const nextOwner = normalizedOwner === undefined ? existing.owner : normalizedOwner;
|
|
1071
|
+
|
|
1072
|
+
if (input.status !== undefined) {
|
|
1073
|
+
validateStatusTransition(existing.status, nextStatus, "subtask", id);
|
|
1074
|
+
}
|
|
1075
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
|
|
1076
|
+
|
|
1077
|
+
const now: number = Date.now();
|
|
1078
|
+
const result = this.#db
|
|
1079
|
+
.query(
|
|
1080
|
+
`UPDATE subtasks
|
|
1081
|
+
SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
|
|
1082
|
+
WHERE id = ?
|
|
1083
|
+
AND version = ?
|
|
1084
|
+
RETURNING id`,
|
|
1085
|
+
)
|
|
1086
|
+
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchVersion) as { id: string } | null;
|
|
1087
|
+
|
|
1088
|
+
if (result === null) {
|
|
1089
|
+
const current = this.#domain.getSubtaskOrThrow(id);
|
|
1090
|
+
throw new PreconditionFailedError({
|
|
1091
|
+
entityKind: "subtask",
|
|
1092
|
+
entityId: id,
|
|
1093
|
+
currentVersion: current.version,
|
|
1094
|
+
providedVersion: ifMatchVersion,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const subtask = this.#domain.getSubtaskOrThrow(id);
|
|
1099
|
+
this.#emitSubtaskUpdated(subtask);
|
|
504
1100
|
return subtask;
|
|
505
1101
|
});
|
|
506
1102
|
}
|
|
@@ -509,7 +1105,7 @@ export class MutationService {
|
|
|
509
1105
|
return this.#writeTransaction((): { deletedDependencyIds: string[] } => {
|
|
510
1106
|
const touchingDependencies = this.#domain.listDependenciesTouchingNode(id);
|
|
511
1107
|
this.#domain.deleteSubtask(id);
|
|
512
|
-
const subtaskDeleteEventId = this.#
|
|
1108
|
+
const subtaskDeleteEventId = this.#emitSubtaskDeleted(id);
|
|
513
1109
|
for (const dependency of touchingDependencies) {
|
|
514
1110
|
this.#appendEntityEvent(
|
|
515
1111
|
"dependency",
|
|
@@ -547,7 +1143,7 @@ export class MutationService {
|
|
|
547
1143
|
const task = this.#domain.getTaskOrThrow(existingSubtask.taskId);
|
|
548
1144
|
const touchingDependencies = this.#domain.listDependenciesTouchingNode(input.id);
|
|
549
1145
|
this.#domain.deleteSubtask(input.id);
|
|
550
|
-
const subtaskDeleteEventId = this.#
|
|
1146
|
+
const subtaskDeleteEventId = this.#emitSubtaskDeleted(input.id);
|
|
551
1147
|
for (const dependency of touchingDependencies) {
|
|
552
1148
|
this.#appendEntityEvent(
|
|
553
1149
|
"dependency",
|
|
@@ -838,6 +1434,87 @@ export class MutationService {
|
|
|
838
1434
|
});
|
|
839
1435
|
}
|
|
840
1436
|
|
|
1437
|
+
// -- Centralized event-emission helpers ------------------------------------
|
|
1438
|
+
// Each helper builds the payload for a single (entity, op) pair from the
|
|
1439
|
+
// entity record. Payload shapes here MUST match the historical inline
|
|
1440
|
+
// construction byte-for-byte: sync correctness depends on it.
|
|
1441
|
+
|
|
1442
|
+
#emitEpicCreated(epic: EpicRecord): string {
|
|
1443
|
+
return this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
|
|
1444
|
+
title: epic.title,
|
|
1445
|
+
description: epic.description,
|
|
1446
|
+
status: epic.status,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
#emitEpicUpdated(epic: EpicRecord): string {
|
|
1451
|
+
return this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
|
|
1452
|
+
title: epic.title,
|
|
1453
|
+
description: epic.description,
|
|
1454
|
+
status: epic.status,
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
#emitEpicDeleted(epicId: string): string {
|
|
1459
|
+
return this.#appendEntityEvent("epic", epicId, ENTITY_OPERATIONS.epic.deleted, {});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
#emitTaskCreated(task: TaskRecord): string {
|
|
1463
|
+
return this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
|
|
1464
|
+
epic_id: task.epicId,
|
|
1465
|
+
title: task.title,
|
|
1466
|
+
description: task.description,
|
|
1467
|
+
status: task.status,
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
#emitTaskUpdated(task: TaskRecord): string {
|
|
1472
|
+
return this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
|
|
1473
|
+
epic_id: task.epicId,
|
|
1474
|
+
title: task.title,
|
|
1475
|
+
description: task.description,
|
|
1476
|
+
status: task.status,
|
|
1477
|
+
owner: task.owner,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
#emitTaskDeleted(
|
|
1482
|
+
taskId: string,
|
|
1483
|
+
cascade?: { sourceEventId: string } | undefined,
|
|
1484
|
+
): string {
|
|
1485
|
+
const fields: Record<string, unknown> = cascade ? { source_event_id: cascade.sourceEventId } : {};
|
|
1486
|
+
return this.#appendEntityEvent("task", taskId, ENTITY_OPERATIONS.task.deleted, fields);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
#emitSubtaskCreated(subtask: SubtaskRecord): string {
|
|
1490
|
+
return this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
|
|
1491
|
+
task_id: subtask.taskId,
|
|
1492
|
+
title: subtask.title,
|
|
1493
|
+
description: subtask.description,
|
|
1494
|
+
status: subtask.status,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
#emitSubtaskUpdated(subtask: SubtaskRecord): string {
|
|
1499
|
+
return this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
|
|
1500
|
+
task_id: subtask.taskId,
|
|
1501
|
+
title: subtask.title,
|
|
1502
|
+
description: subtask.description,
|
|
1503
|
+
status: subtask.status,
|
|
1504
|
+
owner: subtask.owner,
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
#emitSubtaskDeleted(
|
|
1509
|
+
subtaskId: string,
|
|
1510
|
+
cascade?: { taskId: string; sourceEventId: string } | undefined,
|
|
1511
|
+
): string {
|
|
1512
|
+
const fields: Record<string, unknown> = cascade
|
|
1513
|
+
? { task_id: cascade.taskId, source_event_id: cascade.sourceEventId }
|
|
1514
|
+
: {};
|
|
1515
|
+
return this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, fields);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
841
1518
|
#completeAtomicIdempotentMutation(
|
|
842
1519
|
claim: AtomicIdempotencyClaim,
|
|
843
1520
|
mutate: () => AtomicIdempotencyCompletedResult,
|
|
@@ -920,6 +1597,15 @@ export class MutationService {
|
|
|
920
1597
|
}
|
|
921
1598
|
|
|
922
1599
|
#pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
|
|
1600
|
+
// Skip if we swept recently — see BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS
|
|
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) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
923
1609
|
const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
|
|
924
1610
|
this.#db.query(
|
|
925
1611
|
`
|
|
@@ -928,6 +1614,7 @@ export class MutationService {
|
|
|
928
1614
|
AND created_at < ?;
|
|
929
1615
|
`,
|
|
930
1616
|
).run(cutoff);
|
|
1617
|
+
idempotencyPruneByDatabase.set(pruneKey, now);
|
|
931
1618
|
}
|
|
932
1619
|
|
|
933
1620
|
#previewScopeReplacement(
|
|
@@ -966,34 +1653,18 @@ export class MutationService {
|
|
|
966
1653
|
for (const change of plan.orderedChanges) {
|
|
967
1654
|
if (change.kind === "epic") {
|
|
968
1655
|
const epic = this.#domain.updateEpic(change.id, { status: change.nextStatus });
|
|
969
|
-
this.#
|
|
970
|
-
title: epic.title,
|
|
971
|
-
description: epic.description,
|
|
972
|
-
status: epic.status,
|
|
973
|
-
});
|
|
1656
|
+
this.#emitEpicUpdated(epic);
|
|
974
1657
|
continue;
|
|
975
1658
|
}
|
|
976
1659
|
|
|
977
1660
|
if (change.kind === "task") {
|
|
978
1661
|
const task = this.#domain.updateTask(change.id, { status: change.nextStatus });
|
|
979
|
-
this.#
|
|
980
|
-
epic_id: task.epicId,
|
|
981
|
-
title: task.title,
|
|
982
|
-
description: task.description,
|
|
983
|
-
status: task.status,
|
|
984
|
-
owner: task.owner,
|
|
985
|
-
});
|
|
1662
|
+
this.#emitTaskUpdated(task);
|
|
986
1663
|
continue;
|
|
987
1664
|
}
|
|
988
1665
|
|
|
989
1666
|
const subtask = this.#domain.updateSubtask(change.id, { status: change.nextStatus });
|
|
990
|
-
this.#
|
|
991
|
-
task_id: subtask.taskId,
|
|
992
|
-
title: subtask.title,
|
|
993
|
-
description: subtask.description,
|
|
994
|
-
status: subtask.status,
|
|
995
|
-
owner: subtask.owner,
|
|
996
|
-
});
|
|
1667
|
+
this.#emitSubtaskUpdated(subtask);
|
|
997
1668
|
}
|
|
998
1669
|
}
|
|
999
1670
|
|
|
@@ -1018,34 +1689,18 @@ export class MutationService {
|
|
|
1018
1689
|
|
|
1019
1690
|
if (node.kind === "epic") {
|
|
1020
1691
|
const epic = this.#domain.updateEpic(node.id, { title: nextTitle, description: nextDescription });
|
|
1021
|
-
this.#
|
|
1022
|
-
title: epic.title,
|
|
1023
|
-
description: epic.description,
|
|
1024
|
-
status: epic.status,
|
|
1025
|
-
});
|
|
1692
|
+
this.#emitEpicUpdated(epic);
|
|
1026
1693
|
continue;
|
|
1027
1694
|
}
|
|
1028
1695
|
|
|
1029
1696
|
if (node.kind === "task") {
|
|
1030
1697
|
const task = this.#domain.updateTask(node.id, { title: nextTitle, description: nextDescription });
|
|
1031
|
-
this.#
|
|
1032
|
-
epic_id: task.epicId,
|
|
1033
|
-
title: task.title,
|
|
1034
|
-
description: task.description,
|
|
1035
|
-
status: task.status,
|
|
1036
|
-
owner: task.owner,
|
|
1037
|
-
});
|
|
1698
|
+
this.#emitTaskUpdated(task);
|
|
1038
1699
|
continue;
|
|
1039
1700
|
}
|
|
1040
1701
|
|
|
1041
1702
|
const subtask = this.#domain.updateSubtask(node.id, { title: nextTitle, description: nextDescription });
|
|
1042
|
-
this.#
|
|
1043
|
-
task_id: subtask.taskId,
|
|
1044
|
-
title: subtask.title,
|
|
1045
|
-
description: subtask.description,
|
|
1046
|
-
status: subtask.status,
|
|
1047
|
-
owner: subtask.owner,
|
|
1048
|
-
});
|
|
1703
|
+
this.#emitSubtaskUpdated(subtask);
|
|
1049
1704
|
}
|
|
1050
1705
|
});
|
|
1051
1706
|
|