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,9 +1,10 @@
1
1
  import { type Database } from "bun:sqlite";
2
2
 
3
- import { safeErrorMessage } from "../commands/error-utils";
4
- import { MutationService } from "../domain/mutation-service";
3
+ import { redactSensitive, safeErrorMessage } from "../commands/error-utils";
4
+ import { MutationService, PreconditionFailedError } from "../domain/mutation-service";
5
5
  import { TrackerDomain } from "../domain/tracker-domain";
6
6
  import { DomainError } from "../domain/types";
7
+ import { type BoardEventBus } from "./event-bus";
7
8
  import { buildBoardSnapshot, buildBoardSnapshotDelta } from "./snapshot";
8
9
 
9
10
  interface SnapshotDeltaSelection {
@@ -19,6 +20,7 @@ interface BoardRouteContext {
19
20
  readonly db: Database;
20
21
  readonly cwd: string;
21
22
  readonly token: string;
23
+ readonly eventBus?: BoardEventBus;
22
24
  }
23
25
 
24
26
  interface BoardRouteError {
@@ -106,6 +108,23 @@ function isSqliteBusyMessage(message: string): boolean {
106
108
  return normalized.includes("database is locked") || normalized.includes("database schema is locked");
107
109
  }
108
110
 
111
+ function redactDetailLeaves(value: unknown): unknown {
112
+ if (typeof value === "string") {
113
+ return redactSensitive(value);
114
+ }
115
+ if (Array.isArray(value)) {
116
+ return value.map((item) => redactDetailLeaves(item));
117
+ }
118
+ if (value !== null && typeof value === "object") {
119
+ const out: Record<string, unknown> = {};
120
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
121
+ out[key] = redactDetailLeaves(child);
122
+ }
123
+ return out;
124
+ }
125
+ return value;
126
+ }
127
+
109
128
  function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteError {
110
129
  if (error instanceof DomainError) {
111
130
  const status =
@@ -116,11 +135,20 @@ function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteErro
116
135
  : error.code === "invalid_dependency" || error.code === "dependency_blocked"
117
136
  ? 409
118
137
  : 400;
138
+ // Secrets occasionally ride DomainError when an upstream layer interpolates
139
+ // a request body or header into the message/details (P1 finding 10).
140
+ // Run the canonical redactor over the message and recursively over every
141
+ // string-valued leaf of `details` before serialising to the wire so we
142
+ // never leak Bearer/Basic credentials, JWTs, or keyed `token=...` shapes.
143
+ const redactedMessage = redactSensitive(error.message);
144
+ const redactedDetails = error.details === undefined
145
+ ? undefined
146
+ : (redactDetailLeaves(error.details) as Record<string, unknown>);
119
147
  return {
120
148
  status,
121
149
  code: error.code,
122
- message: error.message,
123
- ...(error.details === undefined ? {} : { details: error.details }),
150
+ message: redactedMessage,
151
+ ...(redactedDetails === undefined ? {} : { details: redactedDetails }),
124
152
  };
125
153
  }
126
154
 
@@ -153,12 +181,240 @@ function describeBoardError(mutations: MutationService, error: unknown, requestL
153
181
  return routeError;
154
182
  }
155
183
 
184
+ // describeError can echo user-supplied values straight from a DomainError
185
+ // (status_transition_invalid returns error.message verbatim, dependency
186
+ // descriptions interpolate ids). Run the same redact pass over the
187
+ // friendlier message so secrets don't slip past the wire-level sanitiser.
156
188
  return {
157
189
  ...routeError,
158
- message: readableMessage,
190
+ message: redactSensitive(readableMessage),
159
191
  };
160
192
  }
161
193
 
194
+ function publishSnapshotDeltaIfPresent(
195
+ eventBus: BoardEventBus | undefined,
196
+ data: Record<string, unknown>,
197
+ ): void {
198
+ if (!eventBus) {
199
+ return;
200
+ }
201
+
202
+ const delta = readSnapshotDelta(data);
203
+ if (delta) {
204
+ eventBus.publishSnapshotDelta(delta);
205
+ }
206
+ }
207
+
208
+ function formatSseEvent(eventName: string, data: unknown, id?: number): string {
209
+ const idLine = id === undefined ? "" : `id: ${id}\n`;
210
+ return `${idLine}event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
211
+ }
212
+
213
+ // SSE backpressure thresholds (P1 finding 5).
214
+ // A client that connects but never reads can otherwise grow the per-stream
215
+ // queue without bound — every snapshotDelta the bus publishes is encoded
216
+ // and pushed, retaining the bytes in `controller`'s internal queue. We
217
+ // guard against OOM by tracking unflushed bytes and the time since the
218
+ // consumer last pulled, and tearing the connection down once either limit
219
+ // is exceeded.
220
+ const SSE_MAX_QUEUED_BYTES = 1_000_000; // 1 MB hard cap → drop connection.
221
+ const SSE_COALESCE_BYTES = 256_000; // 256 KB soft cap → coalesce deltas.
222
+ const SSE_STALL_MS = 30_000; // 30 s without consumption → drop.
223
+ const SSE_BACKPRESSURE_CHECK_MS = 1_000;
224
+
225
+ function openSnapshotStream(
226
+ request: Request,
227
+ domain: TrackerDomain,
228
+ eventBus: BoardEventBus | undefined,
229
+ ): Response {
230
+ if (!eventBus) {
231
+ return jsonResponse(503, {
232
+ ok: false,
233
+ error: {
234
+ code: "stream_unavailable",
235
+ message: "Snapshot stream is not available on this server",
236
+ },
237
+ });
238
+ }
239
+
240
+ const encoder = new TextEncoder();
241
+ const initialSnapshot = buildBoardSnapshot(domain);
242
+ let unsubscribe: (() => void) | null = null;
243
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
244
+ let backpressureTimer: ReturnType<typeof setInterval> | null = null;
245
+
246
+ // Bytes enqueued but not yet consumed via `pull`. Each enqueue adds the
247
+ // chunk's byte length; each `pull` decrements by the next pending chunk's
248
+ // size (FIFO; we don't peek into the controller's internal queue).
249
+ let queuedBytes = 0;
250
+ const pendingChunkSizes: number[] = [];
251
+ let lastConsumeAt = Date.now();
252
+ let closed = false;
253
+ // When over the soft threshold we coalesce snapshotDeltas: only the latest
254
+ // is retained, dropping superseded deltas. The cached delta is flushed
255
+ // once the queue drains below the soft limit on the next `pull`.
256
+ let pendingDelta: { id: number; snapshotDelta: Record<string, unknown> } | null = null;
257
+
258
+ const cleanupTimers = (): void => {
259
+ if (unsubscribe) {
260
+ unsubscribe();
261
+ unsubscribe = null;
262
+ }
263
+ if (heartbeatTimer) {
264
+ clearInterval(heartbeatTimer);
265
+ heartbeatTimer = null;
266
+ }
267
+ if (backpressureTimer) {
268
+ clearInterval(backpressureTimer);
269
+ backpressureTimer = null;
270
+ }
271
+ };
272
+
273
+ const stream = new ReadableStream<Uint8Array>({
274
+ start(controller): void {
275
+ const enqueueRaw = (chunk: string): void => {
276
+ if (closed) {
277
+ return;
278
+ }
279
+ try {
280
+ const bytes = encoder.encode(chunk);
281
+ controller.enqueue(bytes);
282
+ queuedBytes += bytes.byteLength;
283
+ pendingChunkSizes.push(bytes.byteLength);
284
+ } catch {
285
+ // Controller closed; cleanup happens via cancel.
286
+ }
287
+ };
288
+
289
+ const flushPendingDelta = (): void => {
290
+ if (!pendingDelta || closed) {
291
+ return;
292
+ }
293
+ const delta = pendingDelta;
294
+ pendingDelta = null;
295
+ enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta: delta.snapshotDelta }, delta.id));
296
+ };
297
+
298
+ const closeWithError = (reason: string): void => {
299
+ if (closed) {
300
+ return;
301
+ }
302
+ closed = true;
303
+ try {
304
+ const errorFrame = formatSseEvent("stream_error", { code: "backpressure", reason });
305
+ controller.enqueue(encoder.encode(errorFrame));
306
+ } catch {
307
+ // Already closed.
308
+ }
309
+ cleanupTimers();
310
+ try {
311
+ controller.close();
312
+ } catch {
313
+ // Already closed.
314
+ }
315
+ };
316
+
317
+ const handleSnapshotDelta = (id: number, snapshotDelta: Record<string, unknown>): void => {
318
+ if (closed) {
319
+ return;
320
+ }
321
+ if (queuedBytes >= SSE_MAX_QUEUED_BYTES) {
322
+ closeWithError("queued bytes exceeded 1MB hard limit");
323
+ return;
324
+ }
325
+ if (queuedBytes >= SSE_COALESCE_BYTES) {
326
+ // Slow consumer: coalesce. The board snapshot is cumulative; the
327
+ // newest delta carries the freshest causally-ordered state for
328
+ // every entity it touches, so dropping superseded deltas is safe.
329
+ pendingDelta = { id, snapshotDelta };
330
+ return;
331
+ }
332
+ // Fast path: flush any coalesced delta first to preserve ordering,
333
+ // then enqueue the new one.
334
+ if (pendingDelta) {
335
+ flushPendingDelta();
336
+ }
337
+ enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta }, id));
338
+ };
339
+
340
+ // Initial snapshot so late-joining tabs converge immediately.
341
+ enqueueRaw(formatSseEvent("snapshot", { snapshot: initialSnapshot }));
342
+
343
+ unsubscribe = eventBus.subscribe((event) => {
344
+ if (event.type === "snapshotDelta") {
345
+ handleSnapshotDelta(event.id, event.snapshotDelta);
346
+ }
347
+ });
348
+
349
+ // Heartbeats keep proxies and stale-connection detectors happy.
350
+ heartbeatTimer = setInterval(() => {
351
+ enqueueRaw(": heartbeat\n\n");
352
+ }, 15000);
353
+
354
+ // Backpressure watchdog: if the consumer has not pulled within
355
+ // SSE_STALL_MS while pending data is sitting in the queue, treat the
356
+ // client as dead and drop the connection so we don't pin memory.
357
+ backpressureTimer = setInterval(() => {
358
+ if (closed) {
359
+ return;
360
+ }
361
+ if (queuedBytes >= SSE_MAX_QUEUED_BYTES) {
362
+ closeWithError("queued bytes exceeded 1MB hard limit");
363
+ return;
364
+ }
365
+ const stalled = queuedBytes > 0 && Date.now() - lastConsumeAt > SSE_STALL_MS;
366
+ if (stalled) {
367
+ closeWithError(`no consumer pull within ${SSE_STALL_MS}ms`);
368
+ }
369
+ }, SSE_BACKPRESSURE_CHECK_MS);
370
+
371
+ const onAbort = (): void => {
372
+ if (closed) {
373
+ return;
374
+ }
375
+ closed = true;
376
+ cleanupTimers();
377
+ try {
378
+ controller.close();
379
+ } catch {
380
+ // Already closed.
381
+ }
382
+ };
383
+
384
+ if (request.signal.aborted) {
385
+ onAbort();
386
+ return;
387
+ }
388
+ request.signal.addEventListener("abort", onAbort);
389
+ },
390
+ pull(): void {
391
+ // A pull means the consumer drained the next chunk from the queue.
392
+ const consumed = pendingChunkSizes.shift();
393
+ if (typeof consumed === "number") {
394
+ queuedBytes = Math.max(0, queuedBytes - consumed);
395
+ }
396
+ lastConsumeAt = Date.now();
397
+ // Outer-scope flush: re-walk via enqueueRaw on the active controller is
398
+ // unnecessary because the controller is only valid inside start();
399
+ // instead the next snapshotDelta arrival will see queuedBytes below the
400
+ // soft limit and flush pendingDelta automatically.
401
+ },
402
+ cancel(): void {
403
+ closed = true;
404
+ cleanupTimers();
405
+ },
406
+ });
407
+
408
+ return new Response(stream, {
409
+ status: 200,
410
+ headers: {
411
+ "cache-control": "no-store",
412
+ "content-type": "text/event-stream; charset=utf-8",
413
+ "x-accel-buffering": "no",
414
+ },
415
+ });
416
+ }
417
+
162
418
  function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
163
419
  return jsonResponse(status, {
164
420
  ok: true,
@@ -301,6 +557,61 @@ function readOptionalNullableString(body: Record<string, unknown>, field: string
301
557
  return value;
302
558
  }
303
559
 
560
+ function parseIfMatchHeader(request: Request): number | null {
561
+ const raw = request.headers.get("if-match");
562
+ if (raw === null) {
563
+ return null;
564
+ }
565
+
566
+ // RFC 7232 §3.1 allows a strong ETag (`"<value>"`) or a weak ETag
567
+ // (`W/"<value>"`). Trekoon does not differentiate strong from weak
568
+ // semantics — a millisecond updatedAt is exact either way — so we
569
+ // accept both shapes. The wildcard `*` is intentionally NOT supported:
570
+ // it would mean "any current representation matches" and would defeat
571
+ // the whole purpose of the optimistic-concurrency check, so we surface
572
+ // it as 400 invalid_input below rather than treating it as a no-op.
573
+ const stripped = raw.trim().replace(/^W\//iu, "");
574
+ const trimmed = stripped.replace(/^"+|"+$/g, "");
575
+ if (trimmed.length === 0) {
576
+ return null;
577
+ }
578
+
579
+ const parsed = Number(trimmed);
580
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
581
+ throw new DomainError({
582
+ code: "invalid_input",
583
+ message:
584
+ "If-Match header must be an integer updatedAt millisecond timestamp (RFC 7232 strong or W/-prefixed weak ETag); the `*` wildcard is not supported",
585
+ details: { header: "If-Match", value: raw },
586
+ });
587
+ }
588
+
589
+ return parsed;
590
+ }
591
+
592
+ interface PreconditionFailedDetails {
593
+ readonly entityKind: "epic" | "task" | "subtask";
594
+ readonly entityId: string;
595
+ readonly currentUpdatedAt: number;
596
+ readonly providedUpdatedAt: number;
597
+ }
598
+
599
+ function preconditionFailedResponse(details: PreconditionFailedDetails): Response {
600
+ return jsonResponse(409, {
601
+ ok: false,
602
+ error: {
603
+ code: "precondition_failed",
604
+ message: "If-Match version does not match current updatedAt",
605
+ details: {
606
+ entityKind: details.entityKind,
607
+ entityId: details.entityId,
608
+ currentUpdatedAt: details.currentUpdatedAt,
609
+ providedUpdatedAt: details.providedUpdatedAt,
610
+ },
611
+ },
612
+ });
613
+ }
614
+
304
615
  function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
305
616
  const headerKey = request.headers.get("x-trekoon-idempotency-key");
306
617
  if (typeof headerKey === "string" && headerKey.trim().length > 0) {
@@ -320,6 +631,15 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
320
631
  const url = new URL(request.url);
321
632
  const requestLabel = `${request.method} ${url.pathname}`;
322
633
  const requestToken = extractToken(request, url);
634
+ // Plain `!==` instead of a constant-time compare is a deliberate choice
635
+ // (System Hardening 0.4.2, finding 32). The board server only binds to
636
+ // 127.0.0.1, the session token is a 256-bit cryptographically-random
637
+ // value rotated per board-server lifetime, and the comparison happens
638
+ // against an in-memory string — there is no remote-timing side-channel
639
+ // realistic enough to attack. Adopting `crypto.timingSafeEqual` would
640
+ // also require handling length-mismatch as a separate non-leaking case.
641
+ // Re-evaluate this decision if the board server ever listens on a
642
+ // non-loopback interface or uses a low-entropy / static token.
323
643
  if (requestToken !== context.token) {
324
644
  return jsonResponse(401, {
325
645
  ok: false,
@@ -332,6 +652,27 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
332
652
 
333
653
  const domain = new TrackerDomain(context.db);
334
654
  const mutations = new MutationService(context.db, context.cwd);
655
+ const eventBus = context.eventBus;
656
+
657
+ const respondWithMutation = (
658
+ domainArg: TrackerDomain,
659
+ data: Record<string, unknown>,
660
+ status = 200,
661
+ ): Response => {
662
+ publishSnapshotDeltaIfPresent(eventBus, data);
663
+ return buildMutationResponse(domainArg, data, status);
664
+ };
665
+
666
+ const respondWithMutationDelta = (
667
+ domainArg: TrackerDomain,
668
+ data: Record<string, unknown>,
669
+ selection: SnapshotDeltaSelection,
670
+ status = 200,
671
+ ): Response => {
672
+ const enrichedData = { ...data, snapshotDelta: buildSnapshotDelta(domainArg, selection) };
673
+ publishSnapshotDeltaIfPresent(eventBus, enrichedData);
674
+ return buildMutationResponse(domainArg, enrichedData, status);
675
+ };
335
676
 
336
677
  try {
337
678
  if (request.method === "GET" && url.pathname === "/api/snapshot") {
@@ -343,15 +684,26 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
343
684
  });
344
685
  }
345
686
 
687
+ if (request.method === "GET" && url.pathname === "/api/snapshot/stream") {
688
+ return openSnapshotStream(request, domain, eventBus);
689
+ }
690
+
346
691
  const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
347
692
  if (epicCascadeMatch) {
693
+ const epicId = epicCascadeMatch[1] ?? "";
348
694
  const body = await parseJsonBody(request);
349
695
  const status = readRequiredString(body, "status");
350
- const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
351
- return buildMutationDeltaResponse(domain, {
696
+ const ifMatch = parseIfMatchHeader(request);
697
+ // CAS path: precondition is enforced inside the write transaction
698
+ // (see PreconditionFailedError catch below). Missing-header path
699
+ // preserves back-compat with clients that don't send If-Match.
700
+ const plan = ifMatch !== null
701
+ ? mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status)
702
+ : mutations.updateEpicStatusCascade(epicId, status);
703
+ return respondWithMutationDelta(domain, {
352
704
  plan,
353
705
  }, {
354
- epicIds: [epicCascadeMatch[1] ?? ""],
706
+ epicIds: [epicId],
355
707
  taskIds: plan.orderedChanges.filter((change) => change.kind === "task").map((change) => change.id),
356
708
  subtaskIds: plan.orderedChanges.filter((change) => change.kind === "subtask").map((change) => change.id),
357
709
  });
@@ -359,38 +711,53 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
359
711
 
360
712
  const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
361
713
  if (epicMatch) {
714
+ const epicId = epicMatch[1] ?? "";
362
715
  const body = await parseJsonBody(request);
363
- const epic = mutations.updateEpic(epicMatch[1] ?? "", {
716
+ const ifMatch = parseIfMatchHeader(request);
717
+ const epicInput = {
364
718
  title: readOptionalString(body, "title"),
365
719
  description: readOptionalString(body, "description"),
366
720
  status: readOptionalString(body, "status"),
367
- });
368
- return buildMutationDeltaResponse(domain, { epic }, { epicIds: [epic.id] });
721
+ };
722
+ const epic = ifMatch !== null
723
+ ? mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput)
724
+ : mutations.updateEpic(epicId, epicInput);
725
+ return respondWithMutationDelta(domain, { epic }, { epicIds: [epic.id] });
369
726
  }
370
727
 
371
728
  const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
372
729
  if (taskMatch) {
730
+ const taskId = taskMatch[1] ?? "";
373
731
  const body = await parseJsonBody(request);
374
- const task = mutations.updateTask(taskMatch[1] ?? "", {
375
- title: readOptionalString(body, "title"),
376
- description: readOptionalString(body, "description"),
377
- status: readOptionalString(body, "status"),
378
- owner: readOptionalNullableString(body, "owner"),
379
- });
380
- return buildMutationDeltaResponse(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
732
+ const ifMatch = parseIfMatchHeader(request);
733
+ const taskInput = {
734
+ title: readOptionalString(body, "title"),
735
+ description: readOptionalString(body, "description"),
736
+ status: readOptionalString(body, "status"),
737
+ owner: readOptionalNullableString(body, "owner"),
738
+ };
739
+ const task = ifMatch !== null
740
+ ? mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput)
741
+ : mutations.updateTask(taskId, taskInput);
742
+ return respondWithMutationDelta(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
381
743
  }
382
744
 
383
745
  const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
384
746
  if (subtaskMatch) {
747
+ const subtaskId = subtaskMatch[1] ?? "";
385
748
  const body = await parseJsonBody(request);
386
- const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
387
- title: readOptionalString(body, "title"),
388
- description: readOptionalString(body, "description"),
389
- status: readOptionalString(body, "status"),
390
- owner: readOptionalNullableString(body, "owner"),
391
- });
749
+ const ifMatch = parseIfMatchHeader(request);
750
+ const subtaskInput = {
751
+ title: readOptionalString(body, "title"),
752
+ description: readOptionalString(body, "description"),
753
+ status: readOptionalString(body, "status"),
754
+ owner: readOptionalNullableString(body, "owner"),
755
+ };
756
+ const subtask = ifMatch !== null
757
+ ? mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput)
758
+ : mutations.updateSubtask(subtaskId, subtaskInput);
392
759
  const task = domain.getTaskOrThrow(subtask.taskId);
393
- return buildMutationDeltaResponse(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
760
+ return respondWithMutationDelta(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
394
761
  }
395
762
 
396
763
  if (request.method === "POST" && url.pathname === "/api/subtasks") {
@@ -413,7 +780,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
413
780
  subtaskIds: [subtask.id],
414
781
  }),
415
782
  };
416
- return buildMutationResponse(domain, responseData, 201);
783
+ return respondWithMutation(domain, responseData, 201);
417
784
  }
418
785
 
419
786
  const result = mutations.createSubtaskAtomicallyWithIdempotency({
@@ -442,7 +809,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
442
809
  const replaySubtaskId = readRecordId(result.responseData.subtask);
443
810
  const replayTaskId = replaySubtaskId ? domain.getSubtask(replaySubtaskId)?.taskId ?? null : null;
444
811
  const replayEpicId = replayTaskId ? domain.getTask(replayTaskId)?.epicId ?? null : null;
445
- return buildMutationResponse(domain, result.state === "replay"
812
+ return respondWithMutation(domain, result.state === "replay"
446
813
  ? withFreshReplaySnapshotDelta(domain, result.responseData, {
447
814
  epicIds: replayEpicId ? [replayEpicId] : [],
448
815
  taskIds: replayTaskId ? [replayTaskId] : [],
@@ -470,7 +837,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
470
837
  deletedDependencyIds,
471
838
  }),
472
839
  };
473
- return buildMutationResponse(domain, responseData, 200);
840
+ return respondWithMutation(domain, responseData, 200);
474
841
  }
475
842
 
476
843
  const result = mutations.deleteSubtaskAtomicallyWithIdempotency({
@@ -493,7 +860,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
493
860
  }),
494
861
  });
495
862
  const replaySnapshotDelta = readSnapshotDelta(result.responseData);
496
- return buildMutationResponse(domain, result.state === "replay"
863
+ return respondWithMutation(domain, result.state === "replay"
497
864
  ? withFreshReplaySnapshotDelta(domain, result.responseData, {
498
865
  epicIds: readRecordIds(replaySnapshotDelta?.epics),
499
866
  taskIds: readRecordIds(replaySnapshotDelta?.tasks),
@@ -519,7 +886,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
519
886
  dependencyIds: [dependency.id],
520
887
  }),
521
888
  };
522
- return buildMutationResponse(domain, responseData, 201);
889
+ return respondWithMutation(domain, responseData, 201);
523
890
  }
524
891
 
525
892
  const result = mutations.addDependencyAtomicallyWithIdempotency({
@@ -563,7 +930,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
563
930
  dependencyIds: replayDependencyId ? [replayDependencyId] : [],
564
931
  }
565
932
  : { dependencyIds: [] };
566
- return buildMutationResponse(domain, result.state === "replay"
933
+ return respondWithMutation(domain, result.state === "replay"
567
934
  ? withFreshReplaySnapshotDelta(domain, result.responseData, replaySelection)
568
935
  : result.responseData, result.status);
569
936
  }
@@ -598,7 +965,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
598
965
  deletedDependencyIds: existingDependencyIds,
599
966
  }),
600
967
  };
601
- return buildMutationResponse(domain, responseData, 200);
968
+ return respondWithMutation(domain, responseData, 200);
602
969
  }
603
970
 
604
971
  const result = mutations.removeDependencyAtomicallyWithIdempotency({
@@ -622,7 +989,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
622
989
  }),
623
990
  });
624
991
  const replaySnapshotDelta = readSnapshotDelta(result.responseData);
625
- return buildMutationResponse(domain, result.state === "replay"
992
+ return respondWithMutation(domain, result.state === "replay"
626
993
  ? withFreshReplaySnapshotDelta(domain, result.responseData, {
627
994
  taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
628
995
  subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
@@ -639,6 +1006,18 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
639
1006
  },
640
1007
  });
641
1008
  } catch (error: unknown) {
1009
+ // PreconditionFailedError is a typed signal from the *WithIfMatch
1010
+ // CAS variants. It carries the freshly-fetched currentUpdatedAt so
1011
+ // the 409 payload is always consistent with the post-rollback state.
1012
+ if (error instanceof PreconditionFailedError) {
1013
+ return preconditionFailedResponse({
1014
+ entityKind: error.entityKind,
1015
+ entityId: error.entityId,
1016
+ currentUpdatedAt: error.currentUpdatedAt,
1017
+ providedUpdatedAt: error.providedUpdatedAt,
1018
+ });
1019
+ }
1020
+
642
1021
  const routeError = describeBoardError(mutations, error, requestLabel);
643
1022
  return jsonResponse(routeError.status, {
644
1023
  ok: false,