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.
Files changed (58) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -765
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
  3. package/.agents/skills/trekoon/reference/execution.md +188 -159
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +213 -213
  6. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  7. package/.agents/skills/trekoon/reference/sync.md +82 -0
  8. package/README.md +29 -8
  9. package/docs/ai-agents.md +65 -6
  10. package/docs/commands.md +149 -5
  11. package/docs/machine-contracts.md +123 -0
  12. package/docs/quickstart.md +55 -3
  13. package/package.json +1 -1
  14. package/src/board/assets/app.js +47 -13
  15. package/src/board/assets/components/Component.js +20 -8
  16. package/src/board/assets/components/Workspace.js +9 -3
  17. package/src/board/assets/components/helpers.js +4 -0
  18. package/src/board/assets/runtime/delegation.js +8 -0
  19. package/src/board/assets/runtime/focus-trap.js +48 -0
  20. package/src/board/assets/state/actions.js +45 -4
  21. package/src/board/assets/state/api.js +304 -17
  22. package/src/board/assets/state/store.js +82 -11
  23. package/src/board/assets/state/url.js +10 -0
  24. package/src/board/assets/state/utils.js +2 -1
  25. package/src/board/event-bus.ts +81 -0
  26. package/src/board/routes.ts +430 -40
  27. package/src/board/server.ts +86 -10
  28. package/src/board/snapshot.ts +6 -0
  29. package/src/board/wal-watcher.ts +313 -0
  30. package/src/commands/board.ts +52 -17
  31. package/src/commands/epic.ts +7 -9
  32. package/src/commands/error-utils.ts +54 -1
  33. package/src/commands/help.ts +75 -10
  34. package/src/commands/migrate.ts +153 -24
  35. package/src/commands/quickstart.ts +7 -0
  36. package/src/commands/skills.ts +17 -5
  37. package/src/commands/subtask.ts +71 -10
  38. package/src/commands/suggest.ts +6 -13
  39. package/src/commands/task.ts +137 -88
  40. package/src/domain/batch-validation.ts +329 -0
  41. package/src/domain/cascade-planner.ts +412 -0
  42. package/src/domain/dependency-rules.ts +15 -0
  43. package/src/domain/mutation-service.ts +842 -187
  44. package/src/domain/search.ts +113 -0
  45. package/src/domain/tracker-domain.ts +167 -693
  46. package/src/domain/types.ts +56 -2
  47. package/src/export/render-markdown.ts +1 -2
  48. package/src/index.ts +37 -0
  49. package/src/runtime/cli-shell.ts +44 -0
  50. package/src/runtime/daemon.ts +700 -0
  51. package/src/storage/backup.ts +166 -0
  52. package/src/storage/database.ts +268 -4
  53. package/src/storage/migrations.ts +441 -22
  54. package/src/storage/path.ts +8 -0
  55. package/src/storage/schema.ts +5 -1
  56. package/src/sync/event-writes.ts +38 -11
  57. package/src/sync/git-context.ts +226 -8
  58. 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, 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
+ * 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
- 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;
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 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 "";
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 matchIndex = value.indexOf(searchText);
84
- if (matchIndex === -1) {
85
- return "";
157
+ const existing = memoryDatabasePruneKeys.get(db);
158
+ if (existing) {
159
+ return existing;
86
160
  }
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
- };
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
- const eventContext = prepareEventWriteContext(this.#db, this.#cwd);
134
- return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, eventContext, fn));
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.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
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.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
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.#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
- });
265
+ this.#emitTaskCreated(task);
220
266
  }
221
267
 
222
268
  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
- });
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.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
267
- title: epic.title,
268
- description: epic.description,
269
- status: epic.status,
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
- this.#appendEntityEvent("epic", id, ENTITY_OPERATIONS.epic.deleted, {});
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.#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
- });
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.#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
- });
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.#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
- });
483
+ this.#emitTaskCreated(task);
334
484
  }
335
485
 
336
486
  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
- });
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.#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
- });
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.#appendEntityEvent("task", id, ENTITY_OPERATIONS.task.deleted, {});
959
+ const taskDeleteEventId = this.#emitTaskDeleted(id);
399
960
 
400
961
  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
- });
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.#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
- });
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.#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
- });
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.#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
- });
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.#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
- });
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.#appendEntityEvent("subtask", id, ENTITY_OPERATIONS.subtask.deleted, {});
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.#appendEntityEvent("subtask", input.id, ENTITY_OPERATIONS.subtask.deleted, {});
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.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
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.#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
- });
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.#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
- });
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.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
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.#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
- });
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.#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
- });
1703
+ this.#emitSubtaskUpdated(subtask);
1049
1704
  }
1050
1705
  });
1051
1706