trekoon 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/commands.md CHANGED
@@ -10,7 +10,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
10
10
  - `trekoon help [command]`
11
11
  - `trekoon quickstart`
12
12
  - `trekoon epic <create|expand|list|show|search|replace|update|delete|progress>`
13
- - `trekoon session [--epic <epic-id>]`
13
+ - `trekoon session [--epic <epic-id>] [--item <id>]`
14
14
  - `trekoon suggest [--epic <epic-id>]`
15
15
  - `trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete|claim>`
16
16
  - `trekoon subtask <create|create-many|list|search|replace|update|delete|claim>`
@@ -94,11 +94,11 @@ Board mutations from any source — board UI, CLI in another shell, or another
94
94
  worktree — propagate to every connected client through the SSE stream. The CLI
95
95
  side is driven by a WAL-watcher that diffs the snapshot when
96
96
  `.trekoon/trekoon.db-wal` changes; in-process mutations publish deltas
97
- directly. PATCH endpoints accept `If-Match: <version>` for optimistic
98
- concurrency; RFC 7232 strong or `W/`-prefixed weak ETag forms are accepted,
99
- but the `*` wildcard is not. A stale value returns `409` with
100
- `currentVersion` and `providedVersion`. Missing `If-Match` is allowed for
101
- back-compat.
97
+ directly. PATCH endpoints require `If-Match: <version>` for optimistic concurrency; RFC
98
+ 7232 strong or `W/`-prefixed weak ETag forms are accepted, but the `*`
99
+ wildcard is not. A stale value returns `409` with `currentVersion` and
100
+ `providedVersion`. A missing `If-Match` header returns `428 Precondition
101
+ Required` with error code `precondition_required`.
102
102
 
103
103
  Repos that already have an ignored `.trekoon/board` directory keep those files
104
104
  on disk, but current Trekoon versions no longer read, create, refresh, or
@@ -288,6 +288,19 @@ trekoon session --epic <epic-id>
288
288
 
289
289
  Scopes session readiness to a specific epic instead of the full tracker.
290
290
 
291
+ ## Session item resolve
292
+
293
+ ```bash
294
+ trekoon --toon session --item <id>
295
+ ```
296
+
297
+ Resolves any epic/task/subtask id in one call. Returns
298
+ `item: { id, kind, parentEpicId, entity, readiness, suggestedNext }` where
299
+ `kind` is one of `epic|task|subtask`, `entity` is the same shape as
300
+ `epic show --all` / `task show --all` / `subtask show`, and `readiness` is
301
+ scoped to `parentEpicId`. Replaces the legacy
302
+ `epic show || task show || subtask show` fallback cascade.
303
+
291
304
  ## Suggest
292
305
 
293
306
  ```bash
@@ -648,6 +648,7 @@ any `ok: false` response will be one of the following strings.
648
648
  | `outside_repo_target` | Skill install target path is outside the repository root. |
649
649
  | `permission_denied` | File-system permission denied for the requested path. |
650
650
  | `precondition_failed` | `If-Match` precondition header did not match the entity's current version. Error details include `currentVersion` and `providedVersion`. |
651
+ | `precondition_required` | `If-Match` header is required on a PATCH route but was omitted. |
651
652
  | `row_not_found` | Sync resolve target row no longer exists in the database. |
652
653
  | `status_transition_invalid` | Requested status transition is not permitted by the status machine. |
653
654
  | `stream_unavailable` | SSE snapshot stream is not available (board not initialised or shutting down). |
@@ -163,6 +163,10 @@ The response includes `claimed` (true/false), `currentOwner`, `currentStatus`,
163
163
  and the full entity record on success. Two concurrent claims return exactly one
164
164
  `claimed=true`.
165
165
 
166
+ In Claude Code, keep parallel Trekoon `Bash` calls read-only unless the command
167
+ is an atomic `claim`. Run status updates and completion commands sequentially so
168
+ a failed transition does not cancel sibling mutations.
169
+
166
170
  ## Database backup and migration
167
171
 
168
172
  Before any manual migration recovery, snapshot the database:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "AI-first task tracking that lives in your repo. You describe what to build, your agent plans it as a dependency graph, then executes it task by task",
5
5
  "keywords": [
6
6
  "ai",
@@ -377,7 +377,14 @@ export function createBoardActions(options) {
377
377
  description: String(formData.get("description") || "").trim(),
378
378
  status: normalizeStatus(String(formData.get("status") || "todo")),
379
379
  };
380
- api.patchTask(taskId, updates, (snapshot) => updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot));
380
+ // No eager version capture: api.patchTask resolves the If-Match version
381
+ // lazily at queue fire time so back-to-back edits on the same task see
382
+ // the post-success version landed via mutation response or SSE delta.
383
+ api.patchTask(
384
+ taskId,
385
+ updates,
386
+ (snapshot) => updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot),
387
+ );
381
388
  },
382
389
  submitSubtaskForm(subtaskId, formData) {
383
390
  const updates = {
@@ -386,7 +393,11 @@ export function createBoardActions(options) {
386
393
  status: normalizeStatus(String(formData.get("status") || "todo")),
387
394
  };
388
395
  syncState({ selectedSubtaskId: subtaskId });
389
- api.patchSubtask(subtaskId, updates, (snapshot) => updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot));
396
+ api.patchSubtask(
397
+ subtaskId,
398
+ updates,
399
+ (snapshot) => updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot),
400
+ );
390
401
  },
391
402
  submitCreateSubtask(taskId, formData) {
392
403
  const input = {
@@ -456,20 +467,31 @@ export function createBoardActions(options) {
456
467
  }
457
468
  // Drag/drop is a status change only; do not mutate selection or modal state,
458
469
  // otherwise dropping a card while another task modal is open hijacks it.
459
- api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
470
+ // If-Match version resolved lazily by api.patchTask at fire time.
471
+ api.patchTask(
472
+ taskId,
473
+ { status: nextStatus },
474
+ (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot),
475
+ );
460
476
  },
461
477
  changeEpicStatus(epicId, newStatus) {
462
478
  const normalizedStatus = normalizeStatus(newStatus);
463
- api.patchEpic(epicId, { status: normalizedStatus }, (snapshot) => {
464
- const epic = snapshot.epics.find(e => e.id === epicId);
465
- if (epic) epic.status = normalizedStatus;
466
- return snapshot;
467
- });
479
+ api.patchEpic(
480
+ epicId,
481
+ { status: normalizedStatus },
482
+ (snapshot) => {
483
+ const epic = snapshot.epics.find(e => e.id === epicId);
484
+ if (epic) epic.status = normalizedStatus;
485
+ return snapshot;
486
+ },
487
+ );
468
488
  },
469
489
  bulkSetStatus(epicId, newStatus) {
470
490
  const normalizedStatus = normalizeStatus(newStatus);
471
- api.cascadeEpicStatus(epicId, normalizedStatus, (snapshot) =>
472
- cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
491
+ api.cascadeEpicStatus(
492
+ epicId,
493
+ normalizedStatus,
494
+ (snapshot) => cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
473
495
  );
474
496
  },
475
497
  toggleEpicStatusFilter(status) {
@@ -140,6 +140,57 @@ export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
140
140
  return inverse;
141
141
  }
142
142
 
143
+ /**
144
+ * Filter an inverse delta produced by computeInverseDelta so we don't undo
145
+ * concurrent SSE-pushed advances that landed on the same entity while a
146
+ * mutation was in flight. Any record whose live snapshot `version` is strictly
147
+ * greater than the optimistic `ifMatchVersion` we sent is dropped from the
148
+ * `restored` arrays — the server-pushed state stays.
149
+ *
150
+ * If `optimisticVersion` is undefined/null/not-a-number we conservatively
151
+ * return the inverse delta unchanged (back-compat: legacy mutations without an
152
+ * If-Match version can't be reasoned about, so the original behavior wins).
153
+ *
154
+ * @param {object} inverseDelta - Inverse delta from computeInverseDelta.
155
+ * @param {object|null|undefined} currentSnapshot - Latest store snapshot.
156
+ * @param {number|null|undefined} optimisticVersion - The version we sent as If-Match.
157
+ * @returns {object}
158
+ */
159
+ export function stripUpToDateEntitiesFromInverse(inverseDelta, currentSnapshot, optimisticVersion) {
160
+ if (typeof optimisticVersion !== "number" || !Number.isFinite(optimisticVersion)) {
161
+ return inverseDelta;
162
+ }
163
+ if (!inverseDelta || typeof inverseDelta !== "object") {
164
+ return inverseDelta;
165
+ }
166
+
167
+ const next = { ...inverseDelta };
168
+ for (const collection of SNAPSHOT_COLLECTIONS) {
169
+ const restored = inverseDelta[collection];
170
+ if (!Array.isArray(restored) || restored.length === 0) continue;
171
+
172
+ const liveRecords = Array.isArray(currentSnapshot?.[collection]) ? currentSnapshot[collection] : [];
173
+ const liveById = indexById(liveRecords);
174
+ const filtered = restored.filter((record) => {
175
+ if (!record || typeof record !== "object" || typeof record.id !== "string") return true;
176
+ const live = liveById.get(record.id);
177
+ const liveVersion = typeof live?.version === "number" ? live.version : null;
178
+ if (liveVersion === null) return true;
179
+ // Drop the restore when the live snapshot's version has already advanced
180
+ // past what we sent — an SSE delta or another mutation reconciliation
181
+ // moved this record forward and the user must see that.
182
+ return !(liveVersion > optimisticVersion);
183
+ });
184
+
185
+ if (filtered.length === 0) {
186
+ delete next[collection];
187
+ } else if (filtered.length !== restored.length) {
188
+ next[collection] = filtered;
189
+ }
190
+ }
191
+ return next;
192
+ }
193
+
143
194
  async function readJsonPayload(response) {
144
195
  const text = await response.text();
145
196
  if (text.length === 0) {
@@ -208,6 +259,61 @@ function createTimeoutError(method, path, timeoutMs) {
208
259
  return error;
209
260
  }
210
261
 
262
+ /**
263
+ * Normalize a caller-supplied entity version into a bare integer suitable for
264
+ * the If-Match header. The server accepts a bare integer, a quoted ETag, or a
265
+ * W/-prefixed weak ETag (RFC 7232 §3.1) — we emit the bare integer form for
266
+ * simplicity. Returns null when the caller didn't pass a usable version
267
+ * (preserves back-compat with older callers that omit the argument).
268
+ *
269
+ * Logs a single console.warn when a non-null/non-undefined input is rejected so
270
+ * a regression silently dropping the If-Match header is easy to spot in dev.
271
+ */
272
+ function normalizeIfMatchVersion(version) {
273
+ if (version === undefined || version === null) {
274
+ return null;
275
+ }
276
+ if (typeof version === "number" && Number.isFinite(version) && version >= 0 && Number.isInteger(version)) {
277
+ return String(version);
278
+ }
279
+ try {
280
+ console.warn(`normalizeIfMatchVersion: ignoring non-integer/negative version ${JSON.stringify(version)} — If-Match header will be omitted`);
281
+ } catch {
282
+ // best-effort logging only
283
+ }
284
+ return null;
285
+ }
286
+
287
+ const ENTITY_KIND_COLLECTION = {
288
+ epic: "epics",
289
+ task: "tasks",
290
+ subtask: "subtasks",
291
+ };
292
+
293
+ /**
294
+ * Look up the current version of an entity from the live store snapshot.
295
+ *
296
+ * Resolved lazily at queue-executor fire time (rather than at action-enqueue
297
+ * time) so a second mutation enqueued while the first is in flight reads the
298
+ * post-success version — the server-acked snapshot landed via mutation
299
+ * response or SSE delta before processNext shifts the next mutation off the
300
+ * queue. Without this lazy read, rapid double-edits would carry stale
301
+ * If-Match versions and 409.
302
+ *
303
+ * @param {object} storeState - The live `model.store` mutable state object.
304
+ * @param {"epic"|"task"|"subtask"} kind
305
+ * @param {string} id
306
+ * @returns {number|undefined}
307
+ */
308
+ function getCurrentVersion(storeState, kind, id) {
309
+ const collection = ENTITY_KIND_COLLECTION[kind];
310
+ if (!collection) return undefined;
311
+ const records = storeState?.snapshot?.[collection];
312
+ if (!Array.isArray(records)) return undefined;
313
+ const record = records.find((entry) => entry?.id === id);
314
+ return typeof record?.version === "number" ? record.version : undefined;
315
+ }
316
+
211
317
  /**
212
318
  * Create a serial mutation queue.
213
319
  *
@@ -222,7 +328,7 @@ function createTimeoutError(method, path, timeoutMs) {
222
328
  * }}
223
329
  */
224
330
  export function createMutationQueue(model, rerender) {
225
- /** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
331
+ /** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string, resolveIfMatch?: function }>} */
226
332
  const queue = [];
227
333
  let processing = false;
228
334
  /** @type {Array<() => void>} */
@@ -255,6 +361,19 @@ export function createMutationQueue(model, rerender) {
255
361
  // the request was in flight survive a rollback.
256
362
  let inverseDelta = null;
257
363
 
364
+ // Resolve the If-Match version LAZILY at fire-time so a queued
365
+ // second mutation on the same entity sees the post-success version that
366
+ // landed via mutation response or SSE delta. Capturing at enqueue time
367
+ // would 409 every rapid double-edit.
368
+ let ifMatchVersion;
369
+ if (typeof mutation.resolveIfMatch === "function") {
370
+ try {
371
+ ifMatchVersion = mutation.resolveIfMatch();
372
+ } catch {
373
+ ifMatchVersion = undefined;
374
+ }
375
+ }
376
+
258
377
  try {
259
378
  if (typeof mutation.optimistic === "function") {
260
379
  const previousSnapshot = model.store.snapshot;
@@ -269,7 +388,7 @@ export function createMutationQueue(model, rerender) {
269
388
  rerender();
270
389
  }
271
390
 
272
- const data = await mutation.request();
391
+ const data = await mutation.request({ ifMatchVersion });
273
392
 
274
393
  if (data?.snapshot) {
275
394
  model.replaceSnapshot(data.snapshot);
@@ -285,20 +404,42 @@ export function createMutationQueue(model, rerender) {
285
404
  ? { type: "success", message: mutation.successMessage }
286
405
  : null;
287
406
  } catch (error) {
288
- // Revert only the entities this mutation touched. Any unrelated
289
- // entities updated by concurrent server deltas remain intact.
290
- if (inverseDelta) {
291
- model.applySnapshotDelta(inverseDelta);
407
+ const isStaleVersion = error?.code === "precondition_failed";
408
+
409
+ // Revert only the entities this mutation touched, but ALSO drop any
410
+ // entity whose live store version has already advanced past the
411
+ // optimistic version we sent: that means an SSE delta (or another
412
+ // queued mutation reconciliation) landed mid-flight and clobbering it
413
+ // with the pre-optimistic record would lose the user's most recent
414
+ // server-authoritative state.
415
+ const adjustedInverseDelta = inverseDelta
416
+ ? stripUpToDateEntitiesFromInverse(inverseDelta, model.store?.snapshot, ifMatchVersion)
417
+ : null;
418
+
419
+ if (adjustedInverseDelta) {
420
+ model.applySnapshotDelta(adjustedInverseDelta);
292
421
  }
293
422
 
294
- const message = error instanceof Error ? error.message : String(error);
295
- model.store.notice = {
296
- type: "error",
297
- title: "Action failed",
298
- message,
299
- retryLabel: "Retry",
300
- retryMutationId: mutation.mutationId,
301
- };
423
+ if (isStaleVersion) {
424
+ // Typed `stale_version` notice no retry button because replaying
425
+ // the optimistic payload against the post-advance state would 409
426
+ // again. The user needs to refresh to compose against latest.
427
+ model.store.notice = {
428
+ type: "warning",
429
+ code: "stale_version",
430
+ title: "Stale update",
431
+ message: "Updated by another session — refresh to load the latest version.",
432
+ };
433
+ } else {
434
+ const message = error instanceof Error ? error.message : String(error);
435
+ model.store.notice = {
436
+ type: "error",
437
+ title: "Action failed",
438
+ message,
439
+ retryLabel: "Retry",
440
+ retryMutationId: mutation.mutationId,
441
+ };
442
+ }
302
443
 
303
444
  if (typeof mutation.onError === "function") {
304
445
  mutation.onError(error);
@@ -368,7 +509,18 @@ export function createApi(model, options) {
368
509
  }
369
510
  },
370
511
  onError(error) {
371
- lastFailedMutation = tagged;
512
+ // Stale-version 409s are NOT retryable — the captured optimistic
513
+ // payload was composed against pre-advance state and replaying it
514
+ // would 409 again. Skip lastFailedMutation so the retry path can't
515
+ // even be invoked, and surface the typed stale_version notice (which
516
+ // does not carry a retry button) so the UI stays consistent.
517
+ if (error?.code === "precondition_failed") {
518
+ if (lastFailedMutation?.mutationId === mutationId) {
519
+ lastFailedMutation = null;
520
+ }
521
+ } else {
522
+ lastFailedMutation = tagged;
523
+ }
372
524
  if (typeof tagged.onError === "function") {
373
525
  tagged.onError(error);
374
526
  }
@@ -431,47 +583,94 @@ export function createApi(model, options) {
431
583
  return true;
432
584
  },
433
585
 
434
- patchEpic(epicId, updates, optimistic) {
586
+ patchEpic(epicId, updates, optimistic, options) {
587
+ // Per-call override wins; otherwise read the live store at fire-time so
588
+ // queued back-to-back edits on the same entity carry the post-success
589
+ // version rather than a stale enqueue-time snapshot.
590
+ const explicitVersion = options?.ifMatchVersion;
591
+ const resolveIfMatch = explicitVersion !== undefined
592
+ ? () => explicitVersion
593
+ : () => getCurrentVersion(model.store, "epic", epicId);
435
594
  enqueueMutation({
436
595
  optimistic,
437
596
  successMessage: "Epic saved.",
438
- request: () => request(`/api/epics/${encodeURIComponent(epicId)}`, {
439
- method: "PATCH",
440
- body: JSON.stringify(updates),
441
- }),
597
+ entityKind: "epic",
598
+ entityId: epicId,
599
+ resolveIfMatch,
600
+ request: ({ ifMatchVersion } = {}) => {
601
+ const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
602
+ return request(`/api/epics/${encodeURIComponent(epicId)}`, {
603
+ method: "PATCH",
604
+ headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
605
+ body: JSON.stringify(updates),
606
+ });
607
+ },
442
608
  });
443
609
  },
444
610
 
445
- patchTask(taskId, updates, optimistic) {
611
+ patchTask(taskId, updates, optimistic, options) {
612
+ const explicitVersion = options?.ifMatchVersion;
613
+ const resolveIfMatch = explicitVersion !== undefined
614
+ ? () => explicitVersion
615
+ : () => getCurrentVersion(model.store, "task", taskId);
446
616
  enqueueMutation({
447
617
  optimistic,
448
618
  successMessage: "Task saved.",
449
- request: () => request(`/api/tasks/${encodeURIComponent(taskId)}`, {
450
- method: "PATCH",
451
- body: JSON.stringify(updates),
452
- }),
619
+ entityKind: "task",
620
+ entityId: taskId,
621
+ resolveIfMatch,
622
+ request: ({ ifMatchVersion } = {}) => {
623
+ const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
624
+ return request(`/api/tasks/${encodeURIComponent(taskId)}`, {
625
+ method: "PATCH",
626
+ headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
627
+ body: JSON.stringify(updates),
628
+ });
629
+ },
453
630
  });
454
631
  },
455
632
 
456
- patchSubtask(subtaskId, updates, optimistic) {
633
+ patchSubtask(subtaskId, updates, optimistic, options) {
634
+ const explicitVersion = options?.ifMatchVersion;
635
+ const resolveIfMatch = explicitVersion !== undefined
636
+ ? () => explicitVersion
637
+ : () => getCurrentVersion(model.store, "subtask", subtaskId);
457
638
  enqueueMutation({
458
639
  optimistic,
459
640
  successMessage: "Subtask saved.",
460
- request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
461
- method: "PATCH",
462
- body: JSON.stringify(updates),
463
- }),
641
+ entityKind: "subtask",
642
+ entityId: subtaskId,
643
+ resolveIfMatch,
644
+ request: ({ ifMatchVersion } = {}) => {
645
+ const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
646
+ return request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
647
+ method: "PATCH",
648
+ headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
649
+ body: JSON.stringify(updates),
650
+ });
651
+ },
464
652
  });
465
653
  },
466
654
 
467
- cascadeEpicStatus(epicId, status, optimistic) {
655
+ cascadeEpicStatus(epicId, status, optimistic, options) {
656
+ const explicitVersion = options?.ifMatchVersion;
657
+ const resolveIfMatch = explicitVersion !== undefined
658
+ ? () => explicitVersion
659
+ : () => getCurrentVersion(model.store, "epic", epicId);
468
660
  enqueueMutation({
469
661
  optimistic,
470
662
  successMessage: "Epic cascade status updated.",
471
- request: () => request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
472
- method: "PATCH",
473
- body: JSON.stringify({ status }),
474
- }),
663
+ entityKind: "epic",
664
+ entityId: epicId,
665
+ resolveIfMatch,
666
+ request: ({ ifMatchVersion } = {}) => {
667
+ const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
668
+ return request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
669
+ method: "PATCH",
670
+ headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
671
+ body: JSON.stringify({ status }),
672
+ });
673
+ },
475
674
  });
476
675
  },
477
676
 
@@ -46,6 +46,21 @@ function normalizeOwner(value) {
46
46
  return typeof value === "string" ? value : null;
47
47
  }
48
48
 
49
+ /**
50
+ * Normalize a server-provided version into a non-negative integer, or null if
51
+ * the value is missing/invalid. The board uses this to populate If-Match
52
+ * headers; non-integers and negative numbers are silently dropped here so the
53
+ * mutation queue treats the entity as version-less (back-compat path).
54
+ * @param {unknown} value
55
+ * @returns {number|null}
56
+ */
57
+ function normalizeVersion(value) {
58
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
59
+ return null;
60
+ }
61
+ return value;
62
+ }
63
+
49
64
  /**
50
65
  * @param {any[]} tasks
51
66
  * @returns {Record<string, number>}
@@ -89,6 +104,7 @@ export function normalizeSnapshot(rawSnapshot) {
89
104
  owner: normalizeOwner(task.owner),
90
105
  createdAt,
91
106
  updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
107
+ version: normalizeVersion(task.version),
92
108
  blockedBy: [],
93
109
  blocks: [],
94
110
  dependencyIds: [],
@@ -118,6 +134,7 @@ export function normalizeSnapshot(rawSnapshot) {
118
134
  owner: normalizeOwner(subtask.owner),
119
135
  createdAt,
120
136
  updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
137
+ version: normalizeVersion(subtask.version),
121
138
  blockedBy: [],
122
139
  blocks: [],
123
140
  dependencyIds: [],
@@ -188,6 +205,7 @@ export function normalizeSnapshot(rawSnapshot) {
188
205
  status: normalizeStatus(String(epic.status ?? "todo")),
189
206
  createdAt,
190
207
  updatedAt: normalizeTimestamp(epic.updatedAt, createdAt),
208
+ version: normalizeVersion(epic.version),
191
209
  taskIds: epicTasks.map((task) => task.id),
192
210
  counts: deriveCounts(epicTasks),
193
211
  searchText: "",
@@ -612,6 +612,16 @@ function preconditionFailedResponse(details: PreconditionFailedDetails): Respons
612
612
  });
613
613
  }
614
614
 
615
+ function preconditionRequiredResponse(): Response {
616
+ return jsonResponse(428, {
617
+ ok: false,
618
+ error: {
619
+ code: "precondition_required",
620
+ message: "If-Match header is required for PATCH requests",
621
+ },
622
+ });
623
+ }
624
+
615
625
  function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
616
626
  const headerKey = request.headers.get("x-trekoon-idempotency-key");
617
627
  if (typeof headerKey === "string" && headerKey.trim().length > 0) {
@@ -705,12 +715,12 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
705
715
  const body = await parseJsonBody(request);
706
716
  const status = readRequiredString(body, "status");
707
717
  const ifMatch = parseIfMatchHeader(request);
718
+ if (ifMatch === null) {
719
+ return preconditionRequiredResponse();
720
+ }
708
721
  // 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);
722
+ // (see PreconditionFailedError catch below).
723
+ const plan = mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status);
714
724
  return respondWithMutationDelta(domain, {
715
725
  plan,
716
726
  }, {
@@ -725,14 +735,15 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
725
735
  const epicId = epicMatch[1] ?? "";
726
736
  const body = await parseJsonBody(request);
727
737
  const ifMatch = parseIfMatchHeader(request);
738
+ if (ifMatch === null) {
739
+ return preconditionRequiredResponse();
740
+ }
728
741
  const epicInput = {
729
742
  title: readOptionalString(body, "title"),
730
743
  description: readOptionalString(body, "description"),
731
744
  status: readOptionalString(body, "status"),
732
745
  };
733
- const epic = ifMatch !== null
734
- ? mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput)
735
- : mutations.updateEpic(epicId, epicInput);
746
+ const epic = mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput);
736
747
  return respondWithMutationDelta(domain, { epic }, { epicIds: [epic.id] });
737
748
  }
738
749
 
@@ -741,15 +752,16 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
741
752
  const taskId = taskMatch[1] ?? "";
742
753
  const body = await parseJsonBody(request);
743
754
  const ifMatch = parseIfMatchHeader(request);
755
+ if (ifMatch === null) {
756
+ return preconditionRequiredResponse();
757
+ }
744
758
  const taskInput = {
745
759
  title: readOptionalString(body, "title"),
746
760
  description: readOptionalString(body, "description"),
747
761
  status: readOptionalString(body, "status"),
748
762
  owner: readOptionalNullableString(body, "owner"),
749
763
  };
750
- const task = ifMatch !== null
751
- ? mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput)
752
- : mutations.updateTask(taskId, taskInput);
764
+ const task = mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput);
753
765
  return respondWithMutationDelta(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
754
766
  }
755
767
 
@@ -758,15 +770,16 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
758
770
  const subtaskId = subtaskMatch[1] ?? "";
759
771
  const body = await parseJsonBody(request);
760
772
  const ifMatch = parseIfMatchHeader(request);
773
+ if (ifMatch === null) {
774
+ return preconditionRequiredResponse();
775
+ }
761
776
  const subtaskInput = {
762
777
  title: readOptionalString(body, "title"),
763
778
  description: readOptionalString(body, "description"),
764
779
  status: readOptionalString(body, "status"),
765
780
  owner: readOptionalNullableString(body, "owner"),
766
781
  };
767
- const subtask = ifMatch !== null
768
- ? mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput)
769
- : mutations.updateSubtask(subtaskId, subtaskInput);
782
+ const subtask = mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput);
770
783
  const task = domain.getTaskOrThrow(subtask.taskId);
771
784
  return respondWithMutationDelta(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
772
785
  }