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,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 {
@@ -82,7 +84,7 @@ function readCookieToken(request: Request): string | null {
82
84
  return null;
83
85
  }
84
86
 
85
- function extractToken(request: Request, url: URL): string | null {
87
+ function extractHeaderOrCookieToken(request: Request): string | null {
86
88
  const authorization: string | null = request.headers.get("authorization");
87
89
  if (authorization?.startsWith("Bearer ")) {
88
90
  return authorization.slice("Bearer ".length).trim();
@@ -93,11 +95,6 @@ function extractToken(request: Request, url: URL): string | null {
93
95
  return headerToken.trim();
94
96
  }
95
97
 
96
- const queryToken: string | null = url.searchParams.get("token");
97
- if (queryToken && queryToken.trim().length > 0) {
98
- return queryToken.trim();
99
- }
100
-
101
98
  return readCookieToken(request);
102
99
  }
103
100
 
@@ -106,6 +103,40 @@ function isSqliteBusyMessage(message: string): boolean {
106
103
  return normalized.includes("database is locked") || normalized.includes("database schema is locked");
107
104
  }
108
105
 
106
+ const REDACT_DETAILS_MAX_DEPTH = 16;
107
+
108
+ export function redactDetailLeaves(
109
+ value: unknown,
110
+ seen: WeakSet<object> = new WeakSet<object>(),
111
+ depth = 0,
112
+ ): unknown {
113
+ if (typeof value === "string") {
114
+ return redactSensitive(value);
115
+ }
116
+ if (depth >= REDACT_DETAILS_MAX_DEPTH) {
117
+ return "[MaxDepth]";
118
+ }
119
+ if (Array.isArray(value)) {
120
+ if (seen.has(value)) {
121
+ return "[Circular]";
122
+ }
123
+ seen.add(value);
124
+ return value.map((item) => redactDetailLeaves(item, seen, depth + 1));
125
+ }
126
+ if (value !== null && typeof value === "object") {
127
+ if (seen.has(value)) {
128
+ return "[Circular]";
129
+ }
130
+ seen.add(value);
131
+ const out: Record<string, unknown> = {};
132
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
133
+ out[redactSensitive(key)] = redactDetailLeaves(child, seen, depth + 1);
134
+ }
135
+ return out;
136
+ }
137
+ return value;
138
+ }
139
+
109
140
  function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteError {
110
141
  if (error instanceof DomainError) {
111
142
  const status =
@@ -116,11 +147,20 @@ function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteErro
116
147
  : error.code === "invalid_dependency" || error.code === "dependency_blocked"
117
148
  ? 409
118
149
  : 400;
150
+ // Secrets occasionally ride DomainError when an upstream layer interpolates
151
+ // a request body or header into the message/details (P1 finding 10).
152
+ // Run the canonical redactor over the message and recursively over every
153
+ // string-valued leaf of `details` before serialising to the wire so we
154
+ // never leak Bearer/Basic credentials, JWTs, or keyed `token=...` shapes.
155
+ const redactedMessage = redactSensitive(error.message);
156
+ const redactedDetails = error.details === undefined
157
+ ? undefined
158
+ : (redactDetailLeaves(error.details) as Record<string, unknown>);
119
159
  return {
120
160
  status,
121
161
  code: error.code,
122
- message: error.message,
123
- ...(error.details === undefined ? {} : { details: error.details }),
162
+ message: redactedMessage,
163
+ ...(redactedDetails === undefined ? {} : { details: redactedDetails }),
124
164
  };
125
165
  }
126
166
 
@@ -153,12 +193,233 @@ function describeBoardError(mutations: MutationService, error: unknown, requestL
153
193
  return routeError;
154
194
  }
155
195
 
196
+ // describeError can echo user-supplied values straight from a DomainError
197
+ // (status_transition_invalid returns error.message verbatim, dependency
198
+ // descriptions interpolate ids). Run the same redact pass over the
199
+ // friendlier message so secrets don't slip past the wire-level sanitiser.
156
200
  return {
157
201
  ...routeError,
158
- message: readableMessage,
202
+ message: redactSensitive(readableMessage),
159
203
  };
160
204
  }
161
205
 
206
+ function publishSnapshotDeltaIfPresent(
207
+ eventBus: BoardEventBus | undefined,
208
+ data: Record<string, unknown>,
209
+ ): void {
210
+ if (!eventBus) {
211
+ return;
212
+ }
213
+
214
+ const delta = readSnapshotDelta(data);
215
+ if (delta) {
216
+ eventBus.publishSnapshotDelta(delta);
217
+ eventBus.markInProcessWrite();
218
+ }
219
+ }
220
+
221
+ function formatSseEvent(eventName: string, data: unknown, id?: number): string {
222
+ const idLine = id === undefined ? "" : `id: ${id}\n`;
223
+ return `${idLine}event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
224
+ }
225
+
226
+ // SSE backpressure thresholds (P1 finding 5).
227
+ // A client that connects but never reads can otherwise grow the per-stream
228
+ // queue without bound. The stream controller already exposes queue pressure
229
+ // through `desiredSize`; once it goes negative we stop enqueueing sparse
230
+ // deltas and retain one pending full-snapshot frame for the next drain.
231
+ const SSE_STALL_MS = 30_000; // 30 s without consumption under pressure → drop.
232
+ const SSE_BACKPRESSURE_CHECK_MS = 1_000;
233
+
234
+ function openSnapshotStream(
235
+ request: Request,
236
+ domain: TrackerDomain,
237
+ eventBus: BoardEventBus | undefined,
238
+ ): Response {
239
+ if (!eventBus) {
240
+ return jsonResponse(503, {
241
+ ok: false,
242
+ error: {
243
+ code: "stream_unavailable",
244
+ message: "Snapshot stream is not available on this server",
245
+ },
246
+ });
247
+ }
248
+
249
+ const encoder = new TextEncoder();
250
+ const initialSnapshot = buildBoardSnapshot(domain);
251
+ let unsubscribe: (() => void) | null = null;
252
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
253
+ let backpressureTimer: ReturnType<typeof setInterval> | null = null;
254
+
255
+ let lastConsumeAt = Date.now();
256
+ let closed = false;
257
+ let pendingFrame: string | null = null;
258
+ let onAbort: (() => void) | null = null;
259
+
260
+ const cleanupTimers = (): void => {
261
+ if (onAbort) {
262
+ request.signal.removeEventListener("abort", onAbort);
263
+ onAbort = null;
264
+ }
265
+ if (unsubscribe) {
266
+ unsubscribe();
267
+ unsubscribe = null;
268
+ }
269
+ if (heartbeatTimer) {
270
+ clearInterval(heartbeatTimer);
271
+ heartbeatTimer = null;
272
+ }
273
+ if (backpressureTimer) {
274
+ clearInterval(backpressureTimer);
275
+ backpressureTimer = null;
276
+ }
277
+ };
278
+
279
+ const stream = new ReadableStream<Uint8Array>({
280
+ start(controller): void {
281
+ const enqueueRaw = (chunk: string): void => {
282
+ if (closed) {
283
+ return;
284
+ }
285
+ try {
286
+ controller.enqueue(encoder.encode(chunk));
287
+ } catch {
288
+ // Controller closed; cleanup happens via cancel.
289
+ }
290
+ };
291
+
292
+ const isBackpressured = (): boolean => (controller.desiredSize ?? 1) < 0;
293
+
294
+ const queueFullSnapshot = (id: number): void => {
295
+ // Sparse per-mutation deltas are not cumulative, so merging them can
296
+ // silently drop changes. Under backpressure, retain a fresh full
297
+ // snapshot frame instead; EventSource clients can converge from it
298
+ // without relying on every skipped delta.
299
+ pendingFrame = formatSseEvent("snapshot", { snapshot: buildBoardSnapshot(domain) }, id);
300
+ };
301
+
302
+ const flushPendingFrame = (): void => {
303
+ if (!pendingFrame || closed || isBackpressured()) {
304
+ return;
305
+ }
306
+ const frame = pendingFrame;
307
+ pendingFrame = null;
308
+ enqueueRaw(frame);
309
+ };
310
+
311
+ const closeWithError = (reason: string): void => {
312
+ if (closed) {
313
+ return;
314
+ }
315
+ closed = true;
316
+ try {
317
+ const errorFrame = formatSseEvent("stream_error", { code: "backpressure", reason });
318
+ controller.enqueue(encoder.encode(errorFrame));
319
+ } catch {
320
+ // Already closed.
321
+ }
322
+ cleanupTimers();
323
+ try {
324
+ controller.close();
325
+ } catch {
326
+ // Already closed.
327
+ }
328
+ };
329
+
330
+ const handleSnapshotDelta = (id: number, snapshotDelta: Record<string, unknown>): void => {
331
+ if (closed) {
332
+ return;
333
+ }
334
+ if (isBackpressured()) {
335
+ queueFullSnapshot(id);
336
+ return;
337
+ }
338
+ flushPendingFrame();
339
+ if (isBackpressured()) {
340
+ queueFullSnapshot(id);
341
+ return;
342
+ }
343
+ enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta }, id));
344
+ };
345
+
346
+ // Initial snapshot so late-joining tabs converge immediately.
347
+ enqueueRaw(formatSseEvent("snapshot", { snapshot: initialSnapshot }));
348
+
349
+ unsubscribe = eventBus.subscribe((event) => {
350
+ if (event.type === "snapshotDelta") {
351
+ handleSnapshotDelta(event.id, event.snapshotDelta);
352
+ }
353
+ });
354
+
355
+ // Heartbeats keep proxies and stale-connection detectors happy.
356
+ heartbeatTimer = setInterval(() => {
357
+ if ((controller.desiredSize ?? 1) < 0) {
358
+ return;
359
+ }
360
+ enqueueRaw(": heartbeat\n\n");
361
+ }, 15000);
362
+
363
+ // Backpressure watchdog: if the consumer has not pulled within
364
+ // SSE_STALL_MS while the stream is under pressure, close with an
365
+ // error frame so EventSource reconnects and receives a fresh snapshot.
366
+ backpressureTimer = setInterval(() => {
367
+ if (closed) {
368
+ return;
369
+ }
370
+ const stalled = (controller.desiredSize ?? 1) < 0 && Date.now() - lastConsumeAt > SSE_STALL_MS;
371
+ if (stalled) {
372
+ closeWithError(`no consumer pull within ${SSE_STALL_MS}ms`);
373
+ }
374
+ }, SSE_BACKPRESSURE_CHECK_MS);
375
+
376
+ onAbort = (): void => {
377
+ if (closed) {
378
+ return;
379
+ }
380
+ closed = true;
381
+ cleanupTimers();
382
+ try {
383
+ controller.close();
384
+ } catch {
385
+ // Already closed.
386
+ }
387
+ };
388
+
389
+ if (request.signal.aborted) {
390
+ onAbort();
391
+ return;
392
+ }
393
+ request.signal.addEventListener("abort", onAbort);
394
+ },
395
+ pull(controller): void {
396
+ lastConsumeAt = Date.now();
397
+ if (pendingFrame && !closed && (controller.desiredSize ?? 1) >= 0) {
398
+ const frame = pendingFrame;
399
+ pendingFrame = null;
400
+ try {
401
+ controller.enqueue(encoder.encode(frame));
402
+ } catch {
403
+ // Controller closed.
404
+ }
405
+ }
406
+ },
407
+ cancel(): void {
408
+ closed = true;
409
+ cleanupTimers();
410
+ },
411
+ });
412
+
413
+ return new Response(stream, {
414
+ status: 200,
415
+ headers: {
416
+ "cache-control": "no-store",
417
+ "content-type": "text/event-stream; charset=utf-8",
418
+ "x-accel-buffering": "no",
419
+ },
420
+ });
421
+ }
422
+
162
423
  function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
163
424
  return jsonResponse(status, {
164
425
  ok: true,
@@ -301,6 +562,56 @@ function readOptionalNullableString(body: Record<string, unknown>, field: string
301
562
  return value;
302
563
  }
303
564
 
565
+ function parseIfMatchHeader(request: Request): number | null {
566
+ const raw = request.headers.get("if-match");
567
+ if (raw === null) {
568
+ return null;
569
+ }
570
+
571
+ // RFC 7232 §3.1 allows a strong ETag (`"<value>"`) or a weak ETag
572
+ // (`W/"<value>"`). Trekoon does not differentiate strong from weak
573
+ // semantics — the entity version token is exact either way — so we
574
+ // accept both shapes. The wildcard `*` is intentionally NOT supported:
575
+ // it would mean "any current representation matches" and would defeat
576
+ // the whole purpose of the optimistic-concurrency check, so we surface
577
+ // it as 400 invalid_input below rather than treating it as a no-op.
578
+ const stripped = raw.trim().replace(/^W\//iu, "");
579
+ const trimmed = stripped.replace(/^"+|"+$/g, "");
580
+ if (!/^(0|[1-9]\d*)$/u.test(trimmed)) {
581
+ throw new DomainError({
582
+ code: "invalid_input",
583
+ message:
584
+ "If-Match header must be a non-negative integer version token (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 Number(trimmed);
590
+ }
591
+
592
+ interface PreconditionFailedDetails {
593
+ readonly entityKind: "epic" | "task" | "subtask";
594
+ readonly entityId: string;
595
+ readonly currentVersion: number;
596
+ readonly providedVersion: 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 version",
605
+ details: {
606
+ entityKind: details.entityKind,
607
+ entityId: details.entityId,
608
+ currentVersion: details.currentVersion,
609
+ providedVersion: details.providedVersion,
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) {
@@ -319,7 +630,27 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
319
630
  return async (request: Request): Promise<Response> => {
320
631
  const url = new URL(request.url);
321
632
  const requestLabel = `${request.method} ${url.pathname}`;
322
- const requestToken = extractToken(request, url);
633
+ if (url.searchParams.has("token")) {
634
+ return jsonResponse(401, {
635
+ ok: false,
636
+ error: {
637
+ code: "unauthorized",
638
+ message: "Board API requests must authenticate with the session cookie or authorization header",
639
+ },
640
+ });
641
+ }
642
+ const requestToken = url.pathname === "/api/snapshot/stream"
643
+ ? readCookieToken(request)
644
+ : extractHeaderOrCookieToken(request);
645
+ // Plain `!==` instead of a constant-time compare is a deliberate choice
646
+ // (System Hardening 0.4.2, finding 32). The board server only binds to
647
+ // 127.0.0.1, the session token is a 256-bit cryptographically-random
648
+ // value rotated per board-server lifetime, and the comparison happens
649
+ // against an in-memory string — there is no remote-timing side-channel
650
+ // realistic enough to attack. Adopting `crypto.timingSafeEqual` would
651
+ // also require handling length-mismatch as a separate non-leaking case.
652
+ // Re-evaluate this decision if the board server ever listens on a
653
+ // non-loopback interface or uses a low-entropy / static token.
323
654
  if (requestToken !== context.token) {
324
655
  return jsonResponse(401, {
325
656
  ok: false,
@@ -332,6 +663,27 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
332
663
 
333
664
  const domain = new TrackerDomain(context.db);
334
665
  const mutations = new MutationService(context.db, context.cwd);
666
+ const eventBus = context.eventBus;
667
+
668
+ const respondWithMutation = (
669
+ domainArg: TrackerDomain,
670
+ data: Record<string, unknown>,
671
+ status = 200,
672
+ ): Response => {
673
+ publishSnapshotDeltaIfPresent(eventBus, data);
674
+ return buildMutationResponse(domainArg, data, status);
675
+ };
676
+
677
+ const respondWithMutationDelta = (
678
+ domainArg: TrackerDomain,
679
+ data: Record<string, unknown>,
680
+ selection: SnapshotDeltaSelection,
681
+ status = 200,
682
+ ): Response => {
683
+ const enrichedData = { ...data, snapshotDelta: buildSnapshotDelta(domainArg, selection) };
684
+ publishSnapshotDeltaIfPresent(eventBus, enrichedData);
685
+ return buildMutationResponse(domainArg, enrichedData, status);
686
+ };
335
687
 
336
688
  try {
337
689
  if (request.method === "GET" && url.pathname === "/api/snapshot") {
@@ -343,15 +695,26 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
343
695
  });
344
696
  }
345
697
 
698
+ if (request.method === "GET" && url.pathname === "/api/snapshot/stream") {
699
+ return openSnapshotStream(request, domain, eventBus);
700
+ }
701
+
346
702
  const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
347
703
  if (epicCascadeMatch) {
704
+ const epicId = epicCascadeMatch[1] ?? "";
348
705
  const body = await parseJsonBody(request);
349
706
  const status = readRequiredString(body, "status");
350
- const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
351
- return buildMutationDeltaResponse(domain, {
707
+ const ifMatch = parseIfMatchHeader(request);
708
+ // CAS path: precondition is enforced inside the write transaction
709
+ // (see PreconditionFailedError catch below). Missing-header path
710
+ // preserves back-compat with clients that don't send If-Match.
711
+ const plan = ifMatch !== null
712
+ ? mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status)
713
+ : mutations.updateEpicStatusCascade(epicId, status);
714
+ return respondWithMutationDelta(domain, {
352
715
  plan,
353
716
  }, {
354
- epicIds: [epicCascadeMatch[1] ?? ""],
717
+ epicIds: [epicId],
355
718
  taskIds: plan.orderedChanges.filter((change) => change.kind === "task").map((change) => change.id),
356
719
  subtaskIds: plan.orderedChanges.filter((change) => change.kind === "subtask").map((change) => change.id),
357
720
  });
@@ -359,38 +722,53 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
359
722
 
360
723
  const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
361
724
  if (epicMatch) {
725
+ const epicId = epicMatch[1] ?? "";
362
726
  const body = await parseJsonBody(request);
363
- const epic = mutations.updateEpic(epicMatch[1] ?? "", {
727
+ const ifMatch = parseIfMatchHeader(request);
728
+ const epicInput = {
364
729
  title: readOptionalString(body, "title"),
365
730
  description: readOptionalString(body, "description"),
366
731
  status: readOptionalString(body, "status"),
367
- });
368
- return buildMutationDeltaResponse(domain, { epic }, { epicIds: [epic.id] });
732
+ };
733
+ const epic = ifMatch !== null
734
+ ? mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput)
735
+ : mutations.updateEpic(epicId, epicInput);
736
+ return respondWithMutationDelta(domain, { epic }, { epicIds: [epic.id] });
369
737
  }
370
738
 
371
739
  const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
372
740
  if (taskMatch) {
741
+ const taskId = taskMatch[1] ?? "";
373
742
  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] });
743
+ const ifMatch = parseIfMatchHeader(request);
744
+ const taskInput = {
745
+ title: readOptionalString(body, "title"),
746
+ description: readOptionalString(body, "description"),
747
+ status: readOptionalString(body, "status"),
748
+ owner: readOptionalNullableString(body, "owner"),
749
+ };
750
+ const task = ifMatch !== null
751
+ ? mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput)
752
+ : mutations.updateTask(taskId, taskInput);
753
+ return respondWithMutationDelta(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
381
754
  }
382
755
 
383
756
  const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
384
757
  if (subtaskMatch) {
758
+ const subtaskId = subtaskMatch[1] ?? "";
385
759
  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
- });
760
+ const ifMatch = parseIfMatchHeader(request);
761
+ const subtaskInput = {
762
+ title: readOptionalString(body, "title"),
763
+ description: readOptionalString(body, "description"),
764
+ status: readOptionalString(body, "status"),
765
+ owner: readOptionalNullableString(body, "owner"),
766
+ };
767
+ const subtask = ifMatch !== null
768
+ ? mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput)
769
+ : mutations.updateSubtask(subtaskId, subtaskInput);
392
770
  const task = domain.getTaskOrThrow(subtask.taskId);
393
- return buildMutationDeltaResponse(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
771
+ return respondWithMutationDelta(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
394
772
  }
395
773
 
396
774
  if (request.method === "POST" && url.pathname === "/api/subtasks") {
@@ -413,7 +791,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
413
791
  subtaskIds: [subtask.id],
414
792
  }),
415
793
  };
416
- return buildMutationResponse(domain, responseData, 201);
794
+ return respondWithMutation(domain, responseData, 201);
417
795
  }
418
796
 
419
797
  const result = mutations.createSubtaskAtomicallyWithIdempotency({
@@ -442,7 +820,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
442
820
  const replaySubtaskId = readRecordId(result.responseData.subtask);
443
821
  const replayTaskId = replaySubtaskId ? domain.getSubtask(replaySubtaskId)?.taskId ?? null : null;
444
822
  const replayEpicId = replayTaskId ? domain.getTask(replayTaskId)?.epicId ?? null : null;
445
- return buildMutationResponse(domain, result.state === "replay"
823
+ return respondWithMutation(domain, result.state === "replay"
446
824
  ? withFreshReplaySnapshotDelta(domain, result.responseData, {
447
825
  epicIds: replayEpicId ? [replayEpicId] : [],
448
826
  taskIds: replayTaskId ? [replayTaskId] : [],
@@ -470,7 +848,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
470
848
  deletedDependencyIds,
471
849
  }),
472
850
  };
473
- return buildMutationResponse(domain, responseData, 200);
851
+ return respondWithMutation(domain, responseData, 200);
474
852
  }
475
853
 
476
854
  const result = mutations.deleteSubtaskAtomicallyWithIdempotency({
@@ -493,7 +871,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
493
871
  }),
494
872
  });
495
873
  const replaySnapshotDelta = readSnapshotDelta(result.responseData);
496
- return buildMutationResponse(domain, result.state === "replay"
874
+ return respondWithMutation(domain, result.state === "replay"
497
875
  ? withFreshReplaySnapshotDelta(domain, result.responseData, {
498
876
  epicIds: readRecordIds(replaySnapshotDelta?.epics),
499
877
  taskIds: readRecordIds(replaySnapshotDelta?.tasks),
@@ -519,7 +897,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
519
897
  dependencyIds: [dependency.id],
520
898
  }),
521
899
  };
522
- return buildMutationResponse(domain, responseData, 201);
900
+ return respondWithMutation(domain, responseData, 201);
523
901
  }
524
902
 
525
903
  const result = mutations.addDependencyAtomicallyWithIdempotency({
@@ -563,7 +941,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
563
941
  dependencyIds: replayDependencyId ? [replayDependencyId] : [],
564
942
  }
565
943
  : { dependencyIds: [] };
566
- return buildMutationResponse(domain, result.state === "replay"
944
+ return respondWithMutation(domain, result.state === "replay"
567
945
  ? withFreshReplaySnapshotDelta(domain, result.responseData, replaySelection)
568
946
  : result.responseData, result.status);
569
947
  }
@@ -598,7 +976,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
598
976
  deletedDependencyIds: existingDependencyIds,
599
977
  }),
600
978
  };
601
- return buildMutationResponse(domain, responseData, 200);
979
+ return respondWithMutation(domain, responseData, 200);
602
980
  }
603
981
 
604
982
  const result = mutations.removeDependencyAtomicallyWithIdempotency({
@@ -622,7 +1000,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
622
1000
  }),
623
1001
  });
624
1002
  const replaySnapshotDelta = readSnapshotDelta(result.responseData);
625
- return buildMutationResponse(domain, result.state === "replay"
1003
+ return respondWithMutation(domain, result.state === "replay"
626
1004
  ? withFreshReplaySnapshotDelta(domain, result.responseData, {
627
1005
  taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
628
1006
  subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
@@ -639,6 +1017,18 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
639
1017
  },
640
1018
  });
641
1019
  } catch (error: unknown) {
1020
+ // PreconditionFailedError is a typed signal from the *WithIfMatch
1021
+ // CAS variants. It carries the freshly-fetched currentVersion so
1022
+ // the 409 payload is always consistent with the post-rollback state.
1023
+ if (error instanceof PreconditionFailedError) {
1024
+ return preconditionFailedResponse({
1025
+ entityKind: error.entityKind,
1026
+ entityId: error.entityId,
1027
+ currentVersion: error.currentVersion,
1028
+ providedVersion: error.providedVersion,
1029
+ });
1030
+ }
1031
+
642
1032
  const routeError = describeBoardError(mutations, error, requestLabel);
643
1033
  return jsonResponse(routeError.status, {
644
1034
  ok: false,