trekoon 0.4.1 → 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.
Files changed (54) hide show
  1. package/.agents/skills/trekoon/SKILL.md +20 -577
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
  3. package/.agents/skills/trekoon/reference/execution.md +246 -7
  4. package/.agents/skills/trekoon/reference/planning.md +138 -1
  5. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  6. package/.agents/skills/trekoon/reference/sync.md +129 -0
  7. package/README.md +8 -1
  8. package/docs/ai-agents.md +17 -2
  9. package/docs/commands.md +147 -3
  10. package/docs/machine-contracts.md +123 -0
  11. package/docs/quickstart.md +52 -0
  12. package/package.json +1 -1
  13. package/src/board/assets/app.js +45 -13
  14. package/src/board/assets/components/Component.js +22 -8
  15. package/src/board/assets/components/Workspace.js +9 -3
  16. package/src/board/assets/components/helpers.js +4 -0
  17. package/src/board/assets/runtime/delegation.js +8 -0
  18. package/src/board/assets/runtime/focus-trap.js +48 -0
  19. package/src/board/assets/state/actions.js +42 -4
  20. package/src/board/assets/state/api.js +284 -11
  21. package/src/board/assets/state/store.js +79 -11
  22. package/src/board/assets/state/url.js +10 -0
  23. package/src/board/assets/state/utils.js +2 -1
  24. package/src/board/event-bus.ts +72 -0
  25. package/src/board/routes.ts +412 -33
  26. package/src/board/server.ts +77 -8
  27. package/src/board/wal-watcher.ts +302 -0
  28. package/src/commands/board.ts +52 -17
  29. package/src/commands/epic.ts +7 -9
  30. package/src/commands/error-utils.ts +54 -1
  31. package/src/commands/help.ts +69 -4
  32. package/src/commands/migrate.ts +153 -24
  33. package/src/commands/quickstart.ts +7 -0
  34. package/src/commands/subtask.ts +71 -10
  35. package/src/commands/suggest.ts +6 -13
  36. package/src/commands/task.ts +137 -88
  37. package/src/domain/batch-validation.ts +329 -0
  38. package/src/domain/cascade-planner.ts +412 -0
  39. package/src/domain/dependency-rules.ts +15 -0
  40. package/src/domain/mutation-service.ts +828 -192
  41. package/src/domain/search.ts +113 -0
  42. package/src/domain/tracker-domain.ts +150 -680
  43. package/src/domain/types.ts +53 -2
  44. package/src/index.ts +37 -0
  45. package/src/runtime/cli-shell.ts +44 -0
  46. package/src/runtime/daemon.ts +639 -0
  47. package/src/storage/backup.ts +166 -0
  48. package/src/storage/database.ts +261 -4
  49. package/src/storage/migrations.ts +422 -20
  50. package/src/storage/path.ts +8 -0
  51. package/src/storage/schema.ts +5 -1
  52. package/src/sync/event-writes.ts +38 -11
  53. package/src/sync/git-context.ts +226 -8
  54. package/src/sync/service.ts +650 -147
@@ -1,8 +1,16 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
3
  import { writeTransaction } from "../storage/database";
4
- import { appendEventWithGitContext, prepareEventWriteContext, withTransactionEventContext } from "../sync/event-writes";
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
+ * `updatedAt` 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 `currentUpdatedAt` 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 currentUpdatedAt: number;
86
+ readonly providedUpdatedAt: number;
87
+
88
+ constructor(input: {
89
+ entityKind: "epic" | "task" | "subtask";
90
+ entityId: string;
91
+ currentUpdatedAt: number;
92
+ providedUpdatedAt: number;
93
+ }) {
94
+ super("If-Match version does not match current updatedAt");
95
+ this.name = "PreconditionFailedError";
96
+ this.entityKind = input.entityKind;
97
+ this.entityId = input.entityId;
98
+ this.currentUpdatedAt = input.currentUpdatedAt;
99
+ this.providedUpdatedAt = input.providedUpdatedAt;
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,26 @@ type AtomicIdempotentMutationResult =
51
125
 
52
126
  const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
53
127
 
54
- function countMatches(value: string, searchText: string): number {
55
- if (searchText.length === 0) {
56
- return 0;
57
- }
58
-
59
- let count = 0;
60
- let offset = 0;
61
- while (offset <= value.length - searchText.length) {
62
- const nextIndex = value.indexOf(searchText, offset);
63
- if (nextIndex === -1) {
64
- return count;
65
- }
66
-
67
- count += 1;
68
- offset = nextIndex + searchText.length;
69
- }
70
-
71
- return count;
72
- }
73
-
74
- function replaceMatches(value: string, searchText: string, replacement: string): string {
75
- return searchText.length === 0 ? value : value.split(searchText).join(replacement);
76
- }
77
-
78
- function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
79
- if (searchText.length === 0) {
80
- return "";
81
- }
82
-
83
- const matchIndex = value.indexOf(searchText);
84
- if (matchIndex === -1) {
85
- return "";
86
- }
87
-
88
- const start = Math.max(0, matchIndex - contextSize);
89
- const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
90
- const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
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
- };
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
+ // Module-level timestamp guards the prune sweep. Per-process (not
136
+ // per-MutationService) so that a long-lived process making frequent claims
137
+ // does not re-run the DELETE on every transaction. Reset to 0 by tests via
138
+ // `__resetIdempotencyPruneThrottleForTests`.
139
+ let lastIdempotencyPruneAt = 0;
140
+
141
+ /**
142
+ * Test hook: resets the module-level prune throttle so a fresh test run
143
+ * always observes the first call as un-throttled. Production code paths
144
+ * never invoke this.
145
+ */
146
+ export function __resetIdempotencyPruneThrottleForTests(): void {
147
+ lastIdempotencyPruneAt = 0;
114
148
  }
115
149
 
116
150
  interface ScopeReplacementResult {
@@ -130,8 +164,16 @@ export class MutationService {
130
164
  }
131
165
 
132
166
  #writeTransaction<T>(fn: () => T): T {
133
- const eventContext = prepareEventWriteContext(this.#db, this.#cwd);
134
- return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, eventContext, fn));
167
+ // Resolve git context BEFORE acquiring the SQLite write lock. Cold-cache
168
+ // resolution spawns `git branch` / `git rev-parse` subprocesses; doing
169
+ // that inside BEGIN IMMEDIATE would serialize concurrent writers behind
170
+ // git invocations rather than just the lock-promotion itself.
171
+ //
172
+ // withTransactionEventContext still computes the event timestamp lazily
173
+ // AFTER BEGIN IMMEDIATE is issued by writeTransaction so concurrent
174
+ // writers cannot collide on (created_at, id).
175
+ const git = resolveGitContext(this.#cwd);
176
+ return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, git, fn));
135
177
  }
136
178
 
137
179
  #dependencyEventEntityId(input: {
@@ -178,11 +220,7 @@ export class MutationService {
178
220
  createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
179
221
  return this.#writeTransaction((): EpicRecord => {
180
222
  const epic = this.#domain.createEpic(input);
181
- this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
182
- title: epic.title,
183
- description: epic.description,
184
- status: epic.status,
185
- });
223
+ this.#emitEpicCreated(epic);
186
224
  return epic;
187
225
  });
188
226
  }
@@ -204,28 +242,14 @@ export class MutationService {
204
242
  dependencySpecs: input.dependencySpecs,
205
243
  });
206
244
 
207
- this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
208
- title: epic.title,
209
- description: epic.description,
210
- status: epic.status,
211
- });
245
+ this.#emitEpicCreated(epic);
212
246
 
213
247
  for (const task of created.tasks) {
214
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
215
- epic_id: task.epicId,
216
- title: task.title,
217
- description: task.description,
218
- status: task.status,
219
- });
248
+ this.#emitTaskCreated(task);
220
249
  }
221
250
 
222
251
  for (const subtask of created.subtasks) {
223
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
224
- task_id: subtask.taskId,
225
- title: subtask.title,
226
- description: subtask.description,
227
- status: subtask.status,
228
- });
252
+ this.#emitSubtaskCreated(subtask);
229
253
  }
230
254
 
231
255
  for (const dependency of created.dependencies) {
@@ -263,11 +287,83 @@ export class MutationService {
263
287
  validateStatusTransition(existing.status, input.status, "epic", id);
264
288
  }
265
289
  const epic = this.#domain.updateEpic(id, input);
266
- this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
267
- title: epic.title,
268
- description: epic.description,
269
- status: epic.status,
270
- });
290
+ this.#emitEpicUpdated(epic);
291
+ return epic;
292
+ });
293
+ }
294
+
295
+ /**
296
+ * Atomic If-Match CAS variant of {@link updateEpic}.
297
+ *
298
+ * The `If-Match` precondition is enforced INSIDE the write transaction
299
+ * via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND updated_at = ?`).
300
+ * If zero rows are affected we determine whether the row is missing
301
+ * (→ `DomainError(not_found)`) or merely stale (→ {@link PreconditionFailedError}
302
+ * with the freshly-fetched `currentUpdatedAt`).
303
+ *
304
+ * This eliminates the read-check-then-write race the previous route-level
305
+ * check had: a concurrent writer could land between `parseIfMatchHeader`'s
306
+ * read and `mutations.updateEpic`'s BEGIN IMMEDIATE, allowing the second
307
+ * PATCH to silently overwrite the first.
308
+ *
309
+ * Input validation (non-empty / status transition) mirrors
310
+ * `domain.updateEpic` — it runs inside the same transaction so a
311
+ * malformed PATCH never observes the CAS branch.
312
+ */
313
+ updateEpicWithIfMatch(
314
+ id: string,
315
+ ifMatchUpdatedAt: number,
316
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
317
+ ): EpicRecord {
318
+ return this.#writeTransaction((): EpicRecord => {
319
+ // Resolve the current row first inside the tx so we can (1) surface
320
+ // `not_found` before the CAS and (2) materialise the per-field
321
+ // defaults that domain.updateEpic computes from the current row.
322
+ // Holding the BEGIN IMMEDIATE write lock guarantees no other writer
323
+ // mutates the row between this read and the CAS UPDATE.
324
+ const existing = this.#domain.getEpicOrThrow(id);
325
+
326
+ const nextTitle = input.title !== undefined
327
+ ? assertNonEmptyField("title", input.title)
328
+ : existing.title;
329
+ const nextDescription = input.description !== undefined
330
+ ? assertNonEmptyField("description", input.description)
331
+ : existing.description;
332
+ const nextStatus = input.status !== undefined
333
+ ? assertNonEmptyField("status", input.status)
334
+ : existing.status;
335
+
336
+ if (input.status !== undefined) {
337
+ validateStatusTransition(existing.status, nextStatus, "epic", id);
338
+ }
339
+
340
+ const now: number = Date.now();
341
+ const result = this.#db
342
+ .query(
343
+ `UPDATE epics
344
+ SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1
345
+ WHERE id = ?
346
+ AND updated_at = ?
347
+ RETURNING id`,
348
+ )
349
+ .get(nextTitle, nextDescription, nextStatus, now, id, ifMatchUpdatedAt) as { id: string } | null;
350
+
351
+ if (result === null) {
352
+ // Zero rows changed. We already proved the row exists via
353
+ // getEpicOrThrow, so the only remaining failure mode is a stale
354
+ // precondition. Re-fetch updatedAt inside the same tx so the
355
+ // caller's 409 carries the freshest value.
356
+ const current = this.#domain.getEpicOrThrow(id);
357
+ throw new PreconditionFailedError({
358
+ entityKind: "epic",
359
+ entityId: id,
360
+ currentUpdatedAt: current.updatedAt,
361
+ providedUpdatedAt: ifMatchUpdatedAt,
362
+ });
363
+ }
364
+
365
+ const epic = this.#domain.getEpicOrThrow(id);
366
+ this.#emitEpicUpdated(epic);
271
367
  return epic;
272
368
  });
273
369
  }
@@ -281,22 +377,69 @@ export class MutationService {
281
377
  });
282
378
  }
283
379
 
380
+ /**
381
+ * Atomic If-Match CAS variant of {@link updateEpicStatusCascade}.
382
+ *
383
+ * Cascades touch many rows so a row-level SQL CAS isn't a natural fit;
384
+ * instead the precondition is enforced inside the same writeTransaction
385
+ * as the cascade plan. The BEGIN IMMEDIATE write lock guarantees no
386
+ * other writer mutates the epic between the precondition read and the
387
+ * cascade application — eliminating the same race the per-row CAS
388
+ * variants close.
389
+ */
390
+ updateEpicStatusCascadeWithIfMatch(
391
+ id: string,
392
+ ifMatchUpdatedAt: number,
393
+ status: string,
394
+ ): StatusCascadePlan {
395
+ return this.#writeTransaction((): StatusCascadePlan => {
396
+ const existing = this.#domain.getEpicOrThrow(id);
397
+ if (existing.updatedAt !== ifMatchUpdatedAt) {
398
+ throw new PreconditionFailedError({
399
+ entityKind: "epic",
400
+ entityId: id,
401
+ currentUpdatedAt: existing.updatedAt,
402
+ providedUpdatedAt: ifMatchUpdatedAt,
403
+ });
404
+ }
405
+ const plan = this.#domain.planStatusCascade("epic", id, status);
406
+ this.#assertCascadeNotBlocked(plan);
407
+ this.#applyStatusCascadePlan(plan);
408
+ return plan;
409
+ });
410
+ }
411
+
284
412
  deleteEpic(id: string): void {
285
413
  this.#writeTransaction((): void => {
414
+ const tasks = this.#domain.listTasks(id);
415
+ const taskIds = tasks.map((task) => task.id);
416
+ const subtasksByTaskId = taskIds.length > 0
417
+ ? this.#domain.listSubtasksByTaskIds(taskIds)
418
+ : new Map<string, readonly SubtaskRecord[]>();
419
+
286
420
  this.#domain.deleteEpic(id);
287
- this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
421
+
422
+ const epicDeleteEventId = this.#emitEpicDeleted(id);
423
+
424
+ for (const task of tasks) {
425
+ // Stamp cascaded task.deleted events with the parent epic-delete event
426
+ // id so peer worktrees can suppress the per-task __delete__ conflict
427
+ // when an epic-level conflict is already pending. Without this, a peer
428
+ // with edits on the epic's tasks gets N+1 conflicts (epic + one per
429
+ // task) instead of the single epic-level conflict.
430
+ const taskDeleteEventId = this.#emitTaskDeleted(task.id, { sourceEventId: epicDeleteEventId });
431
+ const subtasks = subtasksByTaskId.get(task.id) ?? [];
432
+ for (const subtask of subtasks) {
433
+ this.#emitSubtaskDeleted(subtask.id, { taskId: task.id, sourceEventId: taskDeleteEventId });
434
+ }
435
+ }
288
436
  });
289
437
  }
290
438
 
291
439
  createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
292
440
  return this.#writeTransaction((): TaskRecord => {
293
441
  const task = this.#domain.createTask(input);
294
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
295
- epic_id: task.epicId,
296
- title: task.title,
297
- description: task.description,
298
- status: task.status,
299
- });
442
+ this.#emitTaskCreated(task);
300
443
  return task;
301
444
  });
302
445
  }
@@ -305,12 +448,7 @@ export class MutationService {
305
448
  return this.#writeTransaction((): CompactTaskBatchCreateResult => {
306
449
  const created = this.#domain.createTaskBatch(input);
307
450
  for (const task of created.tasks) {
308
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
309
- epic_id: task.epicId,
310
- title: task.title,
311
- description: task.description,
312
- status: task.status,
313
- });
451
+ this.#emitTaskCreated(task);
314
452
  }
315
453
  return created;
316
454
  });
@@ -325,21 +463,11 @@ export class MutationService {
325
463
  return this.#writeTransaction((): CompactEpicExpandResult => {
326
464
  const created = this.#domain.expandEpic(input);
327
465
  for (const task of created.tasks) {
328
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
329
- epic_id: task.epicId,
330
- title: task.title,
331
- description: task.description,
332
- status: task.status,
333
- });
466
+ this.#emitTaskCreated(task);
334
467
  }
335
468
 
336
469
  for (const subtask of created.subtasks) {
337
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
338
- task_id: subtask.taskId,
339
- title: subtask.title,
340
- description: subtask.description,
341
- status: subtask.status,
342
- });
470
+ this.#emitSubtaskCreated(subtask);
343
471
  }
344
472
 
345
473
  for (const dependency of created.dependencies) {
@@ -371,17 +499,433 @@ export class MutationService {
371
499
  validateStatusTransition(existing.status, input.status, "task", id);
372
500
  }
373
501
  const task = this.#domain.updateTask(id, input);
374
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
375
- epic_id: task.epicId,
376
- title: task.title,
377
- description: task.description,
378
- status: task.status,
379
- owner: task.owner,
380
- });
502
+ this.#emitTaskUpdated(task);
503
+ return task;
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Atomic If-Match CAS variant of {@link updateTask}. See
509
+ * {@link updateEpicWithIfMatch} for the rationale; the key difference is
510
+ * that tasks also enforce dependency-gating via
511
+ * `assertNoUnresolvedDependenciesForStatusTransition` for status
512
+ * transitions and may set/clear `owner`.
513
+ */
514
+ updateTaskWithIfMatch(
515
+ id: string,
516
+ ifMatchUpdatedAt: number,
517
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
518
+ ): TaskRecord {
519
+ return this.#writeTransaction((): TaskRecord => {
520
+ const existing = this.#domain.getTaskOrThrow(id);
521
+
522
+ const nextTitle = input.title !== undefined
523
+ ? assertNonEmptyField("title", input.title)
524
+ : existing.title;
525
+ const nextDescription = input.description !== undefined
526
+ ? assertNonEmptyField("description", input.description)
527
+ : existing.description;
528
+ const nextStatus = input.status !== undefined
529
+ ? assertNonEmptyField("status", input.status)
530
+ : existing.status;
531
+ const normalizedOwner = normalizeOwnerInput(input.owner);
532
+ const nextOwner = normalizedOwner === undefined ? existing.owner : normalizedOwner;
533
+
534
+ if (input.status !== undefined) {
535
+ validateStatusTransition(existing.status, nextStatus, "task", id);
536
+ }
537
+ // Dependency gating mirrors domain.updateTask. Even when the status
538
+ // is unchanged this is a no-op because
539
+ // assertNoUnresolvedDependenciesForStatusTransition short-circuits
540
+ // when from === to.
541
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
542
+
543
+ const now: number = Date.now();
544
+ const result = this.#db
545
+ .query(
546
+ `UPDATE tasks
547
+ SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
548
+ WHERE id = ?
549
+ AND updated_at = ?
550
+ RETURNING id`,
551
+ )
552
+ .get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchUpdatedAt) as { id: string } | null;
553
+
554
+ if (result === null) {
555
+ const current = this.#domain.getTaskOrThrow(id);
556
+ throw new PreconditionFailedError({
557
+ entityKind: "task",
558
+ entityId: id,
559
+ currentUpdatedAt: current.updatedAt,
560
+ providedUpdatedAt: ifMatchUpdatedAt,
561
+ });
562
+ }
563
+
564
+ const task = this.#domain.getTaskOrThrow(id);
565
+ this.#emitTaskUpdated(task);
566
+ return task;
567
+ });
568
+ }
569
+
570
+ /**
571
+ * Atomically append text to a task's description using a single SQL
572
+ * `description = description || ?` expression inside a write transaction.
573
+ *
574
+ * This eliminates the read-modify-write TOCTOU race that existed when
575
+ * callers read the description, computed the new value in application code,
576
+ * then issued a separate update write. Two concurrent appends targeting
577
+ * the same task will each hold their own BEGIN IMMEDIATE lock in turn and
578
+ * see the other's text already committed, so neither write is lost.
579
+ *
580
+ * The separator matches the `appendLine` helper used across the CLI:
581
+ * an empty description gets the text directly, a non-empty description
582
+ * gets a `\n` prefix on the appended text.
583
+ *
584
+ * Optional `status` is validated through the normal status-machine checker
585
+ * and applied atomically in the same statement so combined
586
+ * `--append --status` flows remain a single write.
587
+ */
588
+ appendToTaskDescription(input: {
589
+ taskId: string;
590
+ append: string;
591
+ status?: string | undefined;
592
+ owner?: string | null | undefined;
593
+ }): TaskRecord {
594
+ return this.#writeTransaction((): TaskRecord => {
595
+ const existing = this.#domain.getTaskOrThrow(input.taskId);
596
+ if (input.status !== undefined) {
597
+ validateStatusTransition(existing.status, input.status, "task", input.taskId);
598
+ // Enforce dependency gating BEFORE the direct UPDATE bypass below.
599
+ // The combined append+status path issues its own SQL UPDATE rather
600
+ // than going through `domain.updateTask`, so without this call a
601
+ // blocked task with unresolved upstream deps could be flipped into
602
+ // a gated status (in_progress/done) — defeating the
603
+ // dependency_blocked contract. Symmetric with claimTask /
604
+ // markTaskDoneAtomically gating sites.
605
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
606
+ input.taskId,
607
+ "task",
608
+ existing.status,
609
+ input.status,
610
+ );
611
+ }
612
+ const separator = existing.description.length > 0 ? "\n" : "";
613
+ const now = Date.now();
614
+ const nextStatus = input.status ?? existing.status;
615
+ const nextOwner = input.owner !== undefined
616
+ ? (input.owner ?? null)
617
+ : existing.owner;
618
+ this.#db
619
+ .query(
620
+ "UPDATE tasks SET description = description || ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
621
+ )
622
+ .run(separator + input.append, nextStatus, nextOwner, now, input.taskId);
623
+ const task = this.#domain.getTaskOrThrow(input.taskId);
624
+ this.#emitTaskUpdated(task);
381
625
  return task;
382
626
  });
383
627
  }
384
628
 
629
+ /**
630
+ * Atomically append text to a subtask's description.
631
+ * Same semantics as `appendToTaskDescription`.
632
+ */
633
+ appendToSubtaskDescription(input: {
634
+ subtaskId: string;
635
+ append: string;
636
+ status?: string | undefined;
637
+ owner?: string | null | undefined;
638
+ }): SubtaskRecord {
639
+ return this.#writeTransaction((): SubtaskRecord => {
640
+ const existing = this.#domain.getSubtaskOrThrow(input.subtaskId);
641
+ if (input.status !== undefined) {
642
+ validateStatusTransition(existing.status, input.status, "subtask", input.subtaskId);
643
+ // Mirror of appendToTaskDescription: gate the combined
644
+ // append+status path through assertNoUnresolvedDependenciesForStatusTransition
645
+ // so subtasks cannot bypass dependency resolution via the bypass UPDATE.
646
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
647
+ input.subtaskId,
648
+ "subtask",
649
+ existing.status,
650
+ input.status,
651
+ );
652
+ }
653
+ const separator = existing.description.length > 0 ? "\n" : "";
654
+ const now = Date.now();
655
+ const nextStatus = input.status ?? existing.status;
656
+ const nextOwner = input.owner !== undefined
657
+ ? (input.owner ?? null)
658
+ : existing.owner;
659
+ this.#db
660
+ .query(
661
+ "UPDATE subtasks SET description = description || ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
662
+ )
663
+ .run(separator + input.append, nextStatus, nextOwner, now, input.subtaskId);
664
+ const subtask = this.#domain.getSubtaskOrThrow(input.subtaskId);
665
+ this.#emitSubtaskUpdated(subtask);
666
+ return subtask;
667
+ });
668
+ }
669
+
670
+ /**
671
+ * Atomically append text to an epic's description.
672
+ * Same semantics as `appendToTaskDescription`.
673
+ */
674
+ appendToEpicDescription(input: {
675
+ epicId: string;
676
+ append: string;
677
+ status?: string | undefined;
678
+ }): EpicRecord {
679
+ return this.#writeTransaction((): EpicRecord => {
680
+ const existing = this.#domain.getEpicOrThrow(input.epicId);
681
+ if (input.status !== undefined) {
682
+ validateStatusTransition(existing.status, input.status, "epic", input.epicId);
683
+ }
684
+ const separator = existing.description.length > 0 ? "\n" : "";
685
+ const now = Date.now();
686
+ const nextStatus = input.status ?? existing.status;
687
+ this.#db
688
+ .query(
689
+ "UPDATE epics SET description = description || ?, status = ?, updated_at = ? WHERE id = ?;",
690
+ )
691
+ .run(separator + input.append, nextStatus, now, input.epicId);
692
+ const epic = this.#domain.getEpicOrThrow(input.epicId);
693
+ this.#emitEpicUpdated(epic);
694
+ return epic;
695
+ });
696
+ }
697
+
698
+ /**
699
+ * Mark a task `done` atomically in a single write transaction.
700
+ *
701
+ * Background (Trekoon task 4a0111c4-6400-4a77-b4f3-d9ad863e47db / system
702
+ * hardening): the legacy `task done` handler issued two separate
703
+ * `updateTask` mutations whenever the task was in `todo` or `blocked`
704
+ * (auto-stepping through `in_progress` to satisfy the public status-machine
705
+ * checker). Each `updateTask` ran in its own `BEGIN IMMEDIATE` transaction,
706
+ * so a crash, kill, or thrown exception between the two writes could leave
707
+ * the task wedged in `in_progress` forever — even though the user had
708
+ * asked to mark it done.
709
+ *
710
+ * This method consolidates the operation into one transaction and bypasses
711
+ * `validateStatusTransition`. THIS IS THE ONE INTENTIONAL DIRECT-STATUS-WRITE
712
+ * EXCEPTION in the codebase: every other status mutation MUST go through the
713
+ * public transition checker. The bypass is safe here because the resulting
714
+ * status (`done`) is a documented terminal target reachable from every
715
+ * non-`done` status (`todo`, `blocked`, `in_progress`) in the status
716
+ * machine. See docs/machine-contracts.md for the canonical exceptions list.
717
+ *
718
+ * Contract:
719
+ * - On success: task row is `done`; exactly one `task.updated` event is
720
+ * emitted (no intermediate `in_progress` event); pre-blocked reverse
721
+ * deps are captured inside the same transaction so the unblocked-array
722
+ * computed by the caller is consistent with the post-COMMIT snapshot.
723
+ * - On failure (throw): `ROLLBACK` restores the original status — task is
724
+ * NEVER observable in `in_progress` due to a partial done.
725
+ *
726
+ * The caller supplies `computeSnapshot` which runs inside the transaction
727
+ * AFTER the row has been flipped to `done`. This is where the command
728
+ * layer computes readiness / unblocked / next without leaking
729
+ * `buildTaskReadiness` into the domain layer.
730
+ */
731
+ markTaskDoneAtomically<T>(input: {
732
+ taskId: string;
733
+ computeSnapshot: (params: {
734
+ domain: TrackerDomain;
735
+ completed: TaskRecord;
736
+ preBlockedReverseDepIds: readonly string[];
737
+ }) => T;
738
+ }): T {
739
+ return this.#writeTransaction((): T => {
740
+ const existing = this.#domain.getTaskOrThrow(input.taskId);
741
+
742
+ // Positive allowlist of acceptable source statuses for the atomic done
743
+ // bypass. Any future terminal status (e.g. `cancelled`, `archived`) MUST
744
+ // be explicitly added here before it can be auto-flipped to `done`;
745
+ // otherwise it falls through to this guard and surfaces as
746
+ // `already_done`. This is safer than the legacy
747
+ // `existing.status === "done"` negative check, which silently accepted
748
+ // any new terminal status as "still allowed to transition to done".
749
+ if (existing.status !== "todo" && existing.status !== "blocked" && existing.status !== "in_progress") {
750
+ throw new DomainError({
751
+ code: "already_done",
752
+ message: "Task is already done",
753
+ details: { id: input.taskId },
754
+ });
755
+ }
756
+
757
+ // Enforce dependency gating BEFORE the direct UPDATE bypass. The atomic
758
+ // done flow skips `validateStatusTransition`, so without this call a
759
+ // blocked task with unresolved upstream deps would be silently flipped
760
+ // to `done` — defeating the dependency_blocked contract.
761
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
762
+ input.taskId,
763
+ "task",
764
+ existing.status,
765
+ "done",
766
+ );
767
+
768
+ // Snapshot direct task-level reverse-dep blockers BEFORE the status
769
+ // flip so the post-write snapshot can diff "newly-unblocked" tasks.
770
+ const reverseDeps = this.#domain.listReverseDependencies(input.taskId);
771
+ const directRevDepTaskIds = reverseDeps
772
+ .filter((rd) => rd.isDirect && rd.kind === "task")
773
+ .map((rd) => rd.id);
774
+ const preDepStatuses = this.#domain.batchResolveDependencyStatuses(directRevDepTaskIds);
775
+ const preBlockedReverseDepIds = directRevDepTaskIds.filter((id) => {
776
+ const resolved = preDepStatuses.get(id);
777
+ return resolved !== undefined && resolved.blockers.length > 0;
778
+ });
779
+
780
+ // Direct UPDATE bypassing validateStatusTransition. See method-doc
781
+ // comment above for the rationale: this is the ONLY allowed direct
782
+ // status write in the codebase; do not copy this pattern elsewhere.
783
+ const now: number = Date.now();
784
+ this.#db
785
+ .query("UPDATE tasks SET status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
786
+ .run("done", now, input.taskId);
787
+
788
+ const completed = this.#domain.getTaskOrThrow(input.taskId);
789
+
790
+ // Emit exactly one task.updated event. Payload shape matches the
791
+ // canonical #emitTaskUpdated contract — sync consumers see a single
792
+ // logical "task became done" transition, never a phantom intermediate
793
+ // in_progress step.
794
+ this.#emitTaskUpdated(completed);
795
+
796
+ return input.computeSnapshot({
797
+ domain: this.#domain,
798
+ completed,
799
+ preBlockedReverseDepIds,
800
+ });
801
+ });
802
+ }
803
+
804
+ /**
805
+ * Atomically claim a task for an owner using a SQL compare-and-swap.
806
+ *
807
+ * The UPDATE predicate ensures:
808
+ * - Only `todo` or `blocked` tasks can be claimed (not `done` or
809
+ * already-in_progress tasks owned by someone else).
810
+ * - An owner can re-claim their own in-progress task (idempotent).
811
+ * - Exactly one caller gets `claimed: true` when two concurrent calls race.
812
+ *
813
+ * Returns `{ claimed, currentOwner, currentStatus }`.
814
+ * When `claimed` is true the returned `task` is the post-update record.
815
+ */
816
+ claimTask(input: { taskId: string; owner: string }): {
817
+ claimed: boolean;
818
+ currentOwner: string | null;
819
+ currentStatus: string;
820
+ task?: TaskRecord;
821
+ } {
822
+ return this.#writeTransaction(() => {
823
+ // Enforce dependency gating BEFORE the CAS so a blocked-by-unresolved-dep
824
+ // task cannot be silently flipped into `in_progress`. Symmetric with
825
+ // markTaskDoneAtomically: both "forward-progress" terminal/active
826
+ // transitions go through the same gating call. A pre-existing in_progress
827
+ // or done row is short-circuited because its existing status equals the
828
+ // next status (or is non-gated terminal) and the gating helper returns
829
+ // early.
830
+ const existing = this.#domain.getTask(input.taskId);
831
+ if (existing && (existing.status === "todo" || existing.status === "blocked")) {
832
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
833
+ input.taskId,
834
+ "task",
835
+ existing.status,
836
+ "in_progress",
837
+ );
838
+ }
839
+
840
+ const now = Date.now();
841
+ const result = this.#db
842
+ .query(
843
+ `UPDATE tasks
844
+ SET status = 'in_progress', owner = ?, updated_at = ?, version = version + 1
845
+ WHERE id = ?
846
+ AND status IN ('todo', 'blocked')
847
+ AND (owner IS NULL OR owner = ?)
848
+ RETURNING id`,
849
+ )
850
+ .get(input.owner, now, input.taskId, input.owner) as { id: string } | null;
851
+
852
+ if (result !== null) {
853
+ const task = this.#domain.getTaskOrThrow(input.taskId);
854
+ this.#emitTaskUpdated(task);
855
+ return {
856
+ claimed: true,
857
+ currentOwner: input.owner,
858
+ currentStatus: "in_progress",
859
+ task,
860
+ };
861
+ }
862
+
863
+ // CAS failed — fetch current state for the caller
864
+ const current = this.#domain.getTaskOrThrow(input.taskId);
865
+ return {
866
+ claimed: false,
867
+ currentOwner: current.owner,
868
+ currentStatus: current.status,
869
+ };
870
+ });
871
+ }
872
+
873
+ /**
874
+ * Atomically claim a subtask for an owner using a SQL compare-and-swap.
875
+ * Same semantics as `claimTask`.
876
+ */
877
+ claimSubtask(input: { subtaskId: string; owner: string }): {
878
+ claimed: boolean;
879
+ currentOwner: string | null;
880
+ currentStatus: string;
881
+ subtask?: SubtaskRecord;
882
+ } {
883
+ return this.#writeTransaction(() => {
884
+ // Mirror of claimTask: gate the todo/blocked → in_progress transition
885
+ // through assertNoUnresolvedDependenciesForStatusTransition so subtask
886
+ // claims cannot bypass dependency resolution.
887
+ const existing = this.#domain.getSubtask(input.subtaskId);
888
+ if (existing && (existing.status === "todo" || existing.status === "blocked")) {
889
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
890
+ input.subtaskId,
891
+ "subtask",
892
+ existing.status,
893
+ "in_progress",
894
+ );
895
+ }
896
+
897
+ const now = Date.now();
898
+ const result = this.#db
899
+ .query(
900
+ `UPDATE subtasks
901
+ SET status = 'in_progress', owner = ?, updated_at = ?, version = version + 1
902
+ WHERE id = ?
903
+ AND status IN ('todo', 'blocked')
904
+ AND (owner IS NULL OR owner = ?)
905
+ RETURNING id`,
906
+ )
907
+ .get(input.owner, now, input.subtaskId, input.owner) as { id: string } | null;
908
+
909
+ if (result !== null) {
910
+ const subtask = this.#domain.getSubtaskOrThrow(input.subtaskId);
911
+ this.#emitSubtaskUpdated(subtask);
912
+ return {
913
+ claimed: true,
914
+ currentOwner: input.owner,
915
+ currentStatus: "in_progress",
916
+ subtask,
917
+ };
918
+ }
919
+
920
+ const current = this.#domain.getSubtaskOrThrow(input.subtaskId);
921
+ return {
922
+ claimed: false,
923
+ currentOwner: current.owner,
924
+ currentStatus: current.status,
925
+ };
926
+ });
927
+ }
928
+
385
929
  updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
386
930
  return this.#writeTransaction((): StatusCascadePlan => {
387
931
  const plan = this.#domain.planStatusCascade("task", id, status);
@@ -395,13 +939,10 @@ export class MutationService {
395
939
  return this.#writeTransaction((): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } => {
396
940
  const plan = this.#domain.planTaskDeletion(id);
397
941
  this.#domain.deleteTask(id);
398
- const taskDeleteEventId = this.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
942
+ const taskDeleteEventId = this.#emitTaskDeleted(id);
399
943
 
400
944
  for (const subtaskId of plan.subtaskIds) {
401
- this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, {
402
- task_id: id,
403
- source_event_id: taskDeleteEventId,
404
- });
945
+ this.#emitSubtaskDeleted(subtaskId, { taskId: id, sourceEventId: taskDeleteEventId });
405
946
  }
406
947
 
407
948
  for (const dependency of plan.touchingDependencies) {
@@ -435,12 +976,7 @@ export class MutationService {
435
976
  }): SubtaskRecord {
436
977
  return this.#writeTransaction((): SubtaskRecord => {
437
978
  const subtask = this.#domain.createSubtask(input);
438
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
439
- task_id: subtask.taskId,
440
- title: subtask.title,
441
- description: subtask.description,
442
- status: subtask.status,
443
- });
979
+ this.#emitSubtaskCreated(subtask);
444
980
  return subtask;
445
981
  });
446
982
  }
@@ -455,12 +991,7 @@ export class MutationService {
455
991
  }): AtomicIdempotentMutationResult {
456
992
  return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
457
993
  const subtask = this.#domain.createSubtask(input);
458
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
459
- task_id: subtask.taskId,
460
- title: subtask.title,
461
- description: subtask.description,
462
- status: subtask.status,
463
- });
994
+ this.#emitSubtaskCreated(subtask);
464
995
  return {
465
996
  state: "completed",
466
997
  status: 201,
@@ -473,12 +1004,7 @@ export class MutationService {
473
1004
  return this.#writeTransaction((): CompactSubtaskBatchCreateResult => {
474
1005
  const created = this.#domain.createSubtaskBatch(input);
475
1006
  for (const subtask of created.subtasks) {
476
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
477
- task_id: subtask.taskId,
478
- title: subtask.title,
479
- description: subtask.description,
480
- status: subtask.status,
481
- });
1007
+ this.#emitSubtaskCreated(subtask);
482
1008
  }
483
1009
  return created;
484
1010
  });
@@ -494,13 +1020,66 @@ export class MutationService {
494
1020
  validateStatusTransition(existing.status, input.status, "subtask", id);
495
1021
  }
496
1022
  const subtask = this.#domain.updateSubtask(id, input);
497
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
498
- task_id: subtask.taskId,
499
- title: subtask.title,
500
- description: subtask.description,
501
- status: subtask.status,
502
- owner: subtask.owner,
503
- });
1023
+ this.#emitSubtaskUpdated(subtask);
1024
+ return subtask;
1025
+ });
1026
+ }
1027
+
1028
+ /**
1029
+ * Atomic If-Match CAS variant of {@link updateSubtask}. Mirrors
1030
+ * {@link updateTaskWithIfMatch} except subtask `description` may be
1031
+ * empty (matches `normalizeSubtaskDescription` semantics).
1032
+ */
1033
+ updateSubtaskWithIfMatch(
1034
+ id: string,
1035
+ ifMatchUpdatedAt: number,
1036
+ input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
1037
+ ): SubtaskRecord {
1038
+ return this.#writeTransaction((): SubtaskRecord => {
1039
+ const existing = this.#domain.getSubtaskOrThrow(id);
1040
+
1041
+ const nextTitle = input.title !== undefined
1042
+ ? assertNonEmptyField("title", input.title)
1043
+ : existing.title;
1044
+ // Subtask description allows empty strings — mirror
1045
+ // normalizeSubtaskDescription rather than asserting non-empty.
1046
+ const nextDescription = input.description !== undefined
1047
+ ? input.description.trim()
1048
+ : existing.description;
1049
+ const nextStatus = input.status !== undefined
1050
+ ? assertNonEmptyField("status", input.status)
1051
+ : existing.status;
1052
+ const normalizedOwner = normalizeOwnerInput(input.owner);
1053
+ const nextOwner = normalizedOwner === undefined ? existing.owner : normalizedOwner;
1054
+
1055
+ if (input.status !== undefined) {
1056
+ validateStatusTransition(existing.status, nextStatus, "subtask", id);
1057
+ }
1058
+ this.#domain.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
1059
+
1060
+ const now: number = Date.now();
1061
+ const result = this.#db
1062
+ .query(
1063
+ `UPDATE subtasks
1064
+ SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
1065
+ WHERE id = ?
1066
+ AND updated_at = ?
1067
+ RETURNING id`,
1068
+ )
1069
+ .get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchUpdatedAt) as { id: string } | null;
1070
+
1071
+ if (result === null) {
1072
+ const current = this.#domain.getSubtaskOrThrow(id);
1073
+ throw new PreconditionFailedError({
1074
+ entityKind: "subtask",
1075
+ entityId: id,
1076
+ currentUpdatedAt: current.updatedAt,
1077
+ providedUpdatedAt: ifMatchUpdatedAt,
1078
+ });
1079
+ }
1080
+
1081
+ const subtask = this.#domain.getSubtaskOrThrow(id);
1082
+ this.#emitSubtaskUpdated(subtask);
504
1083
  return subtask;
505
1084
  });
506
1085
  }
@@ -509,7 +1088,7 @@ export class MutationService {
509
1088
  return this.#writeTransaction((): { deletedDependencyIds: string[] } => {
510
1089
  const touchingDependencies = this.#domain.listDependenciesTouchingNode(id);
511
1090
  this.#domain.deleteSubtask(id);
512
- const subtaskDeleteEventId = this.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
1091
+ const subtaskDeleteEventId = this.#emitSubtaskDeleted(id);
513
1092
  for (const dependency of touchingDependencies) {
514
1093
  this.#appendEntityEvent(
515
1094
  "dependency",
@@ -547,7 +1126,7 @@ export class MutationService {
547
1126
  const task = this.#domain.getTaskOrThrow(existingSubtask.taskId);
548
1127
  const touchingDependencies = this.#domain.listDependenciesTouchingNode(input.id);
549
1128
  this.#domain.deleteSubtask(input.id);
550
- const subtaskDeleteEventId = this.#appendEntityEvent("subtask", input.id, ENTITY_OPERATIONS.subtask.deleted, {});
1129
+ const subtaskDeleteEventId = this.#emitSubtaskDeleted(input.id);
551
1130
  for (const dependency of touchingDependencies) {
552
1131
  this.#appendEntityEvent(
553
1132
  "dependency",
@@ -838,6 +1417,87 @@ export class MutationService {
838
1417
  });
839
1418
  }
840
1419
 
1420
+ // -- Centralized event-emission helpers ------------------------------------
1421
+ // Each helper builds the payload for a single (entity, op) pair from the
1422
+ // entity record. Payload shapes here MUST match the historical inline
1423
+ // construction byte-for-byte: sync correctness depends on it.
1424
+
1425
+ #emitEpicCreated(epic: EpicRecord): string {
1426
+ return this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
1427
+ title: epic.title,
1428
+ description: epic.description,
1429
+ status: epic.status,
1430
+ });
1431
+ }
1432
+
1433
+ #emitEpicUpdated(epic: EpicRecord): string {
1434
+ return this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
1435
+ title: epic.title,
1436
+ description: epic.description,
1437
+ status: epic.status,
1438
+ });
1439
+ }
1440
+
1441
+ #emitEpicDeleted(epicId: string): string {
1442
+ return this.#appendEntityEvent("epic", epicId, ENTITY_OPERATIONS.epic.deleted, {});
1443
+ }
1444
+
1445
+ #emitTaskCreated(task: TaskRecord): string {
1446
+ return this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
1447
+ epic_id: task.epicId,
1448
+ title: task.title,
1449
+ description: task.description,
1450
+ status: task.status,
1451
+ });
1452
+ }
1453
+
1454
+ #emitTaskUpdated(task: TaskRecord): string {
1455
+ return this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
1456
+ epic_id: task.epicId,
1457
+ title: task.title,
1458
+ description: task.description,
1459
+ status: task.status,
1460
+ owner: task.owner,
1461
+ });
1462
+ }
1463
+
1464
+ #emitTaskDeleted(
1465
+ taskId: string,
1466
+ cascade?: { sourceEventId: string } | undefined,
1467
+ ): string {
1468
+ const fields: Record<string, unknown> = cascade ? { source_event_id: cascade.sourceEventId } : {};
1469
+ return this.#appendEntityEvent("task", taskId, ENTITY_OPERATIONS.task.deleted, fields);
1470
+ }
1471
+
1472
+ #emitSubtaskCreated(subtask: SubtaskRecord): string {
1473
+ return this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
1474
+ task_id: subtask.taskId,
1475
+ title: subtask.title,
1476
+ description: subtask.description,
1477
+ status: subtask.status,
1478
+ });
1479
+ }
1480
+
1481
+ #emitSubtaskUpdated(subtask: SubtaskRecord): string {
1482
+ return this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
1483
+ task_id: subtask.taskId,
1484
+ title: subtask.title,
1485
+ description: subtask.description,
1486
+ status: subtask.status,
1487
+ owner: subtask.owner,
1488
+ });
1489
+ }
1490
+
1491
+ #emitSubtaskDeleted(
1492
+ subtaskId: string,
1493
+ cascade?: { taskId: string; sourceEventId: string } | undefined,
1494
+ ): string {
1495
+ const fields: Record<string, unknown> = cascade
1496
+ ? { task_id: cascade.taskId, source_event_id: cascade.sourceEventId }
1497
+ : {};
1498
+ return this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, fields);
1499
+ }
1500
+
841
1501
  #completeAtomicIdempotentMutation(
842
1502
  claim: AtomicIdempotencyClaim,
843
1503
  mutate: () => AtomicIdempotencyCompletedResult,
@@ -920,6 +1580,14 @@ export class MutationService {
920
1580
  }
921
1581
 
922
1582
  #pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
1583
+ // Skip if we swept recently — see BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS
1584
+ // for rationale. Module-level throttle so it applies per-process even
1585
+ // across MutationService instances.
1586
+ if (now - lastIdempotencyPruneAt < BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS) {
1587
+ return;
1588
+ }
1589
+ lastIdempotencyPruneAt = now;
1590
+
923
1591
  const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
924
1592
  this.#db.query(
925
1593
  `
@@ -966,34 +1634,18 @@ export class MutationService {
966
1634
  for (const change of plan.orderedChanges) {
967
1635
  if (change.kind === "epic") {
968
1636
  const epic = this.#domain.updateEpic(change.id, { status: change.nextStatus });
969
- this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
970
- title: epic.title,
971
- description: epic.description,
972
- status: epic.status,
973
- });
1637
+ this.#emitEpicUpdated(epic);
974
1638
  continue;
975
1639
  }
976
1640
 
977
1641
  if (change.kind === "task") {
978
1642
  const task = this.#domain.updateTask(change.id, { status: change.nextStatus });
979
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
980
- epic_id: task.epicId,
981
- title: task.title,
982
- description: task.description,
983
- status: task.status,
984
- owner: task.owner,
985
- });
1643
+ this.#emitTaskUpdated(task);
986
1644
  continue;
987
1645
  }
988
1646
 
989
1647
  const subtask = this.#domain.updateSubtask(change.id, { status: change.nextStatus });
990
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
991
- task_id: subtask.taskId,
992
- title: subtask.title,
993
- description: subtask.description,
994
- status: subtask.status,
995
- owner: subtask.owner,
996
- });
1648
+ this.#emitSubtaskUpdated(subtask);
997
1649
  }
998
1650
  }
999
1651
 
@@ -1018,34 +1670,18 @@ export class MutationService {
1018
1670
 
1019
1671
  if (node.kind === "epic") {
1020
1672
  const epic = this.#domain.updateEpic(node.id, { title: nextTitle, description: nextDescription });
1021
- this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
1022
- title: epic.title,
1023
- description: epic.description,
1024
- status: epic.status,
1025
- });
1673
+ this.#emitEpicUpdated(epic);
1026
1674
  continue;
1027
1675
  }
1028
1676
 
1029
1677
  if (node.kind === "task") {
1030
1678
  const task = this.#domain.updateTask(node.id, { title: nextTitle, description: nextDescription });
1031
- this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
1032
- epic_id: task.epicId,
1033
- title: task.title,
1034
- description: task.description,
1035
- status: task.status,
1036
- owner: task.owner,
1037
- });
1679
+ this.#emitTaskUpdated(task);
1038
1680
  continue;
1039
1681
  }
1040
1682
 
1041
1683
  const subtask = this.#domain.updateSubtask(node.id, { title: nextTitle, description: nextDescription });
1042
- this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
1043
- task_id: subtask.taskId,
1044
- title: subtask.title,
1045
- description: subtask.description,
1046
- status: subtask.status,
1047
- owner: subtask.owner,
1048
- });
1684
+ this.#emitSubtaskUpdated(subtask);
1049
1685
  }
1050
1686
  });
1051
1687