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
@@ -166,7 +166,7 @@ function renderWorkspaceHeader(props) {
166
166
  // ---------------------------------------------------------------------------
167
167
 
168
168
  function renderKanbanColumns(props) {
169
- const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
169
+ const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter, dragFeedback } = props;
170
170
  const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
171
171
 
172
172
  const columnsMarkup = STATUS_ORDER
@@ -178,6 +178,12 @@ function renderKanbanColumns(props) {
178
178
  ? renderEmptyState(`No ${columnTitle.toLowerCase()} work`, "Adjust search or switch epics to inspect more tasks.")
179
179
  : columnTasks.map((task) => renderTaskCard({ task, selected: selectedTaskId === task.id, isMutating })).join("");
180
180
 
181
+ // Re-apply drag-feedback class from store so a rerender during drag does
182
+ // not silently wipe the visual feedback that was set by handleDragover.
183
+ const feedbackClass = dragFeedback?.targetStatus === status
184
+ ? (dragFeedback.kind === "valid" ? " board-drop-valid" : " board-drop-invalid")
185
+ : "";
186
+
181
187
  return `
182
188
  <section class="board-column board-column--dense ${secondaryPanelClasses("flex min-h-[20rem] min-w-0 flex-col p-3")}" aria-labelledby="column-${status}">
183
189
  <header class="board-column__header flex items-start justify-between gap-3 border-b border-[var(--board-border)] pb-3">
@@ -190,7 +196,7 @@ function renderKanbanColumns(props) {
190
196
  </div>
191
197
  ${columnTasks.length > 0 ? `<span class="board-column__count text-xs font-medium text-[var(--board-text-soft)]">${columnTasks.length === 1 ? "1 task" : `${columnTasks.length} tasks`}</span>` : ""}
192
198
  </header>
193
- <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
199
+ <div class="board-column__tasks mt-3 grid min-h-0 flex-1 content-start gap-2.5 overflow-auto pr-1 overscroll-contain${feedbackClass}" id="column-${status}" data-drop-status="${escapeHtml(status)}">${content}</div>
194
200
  </section>
195
201
  `;
196
202
  }).join("");
@@ -292,7 +298,7 @@ function render(props) {
292
298
  });
293
299
 
294
300
  const contentMarkup = store.view === "kanban"
295
- ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
301
+ ? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter, dragFeedback: store.dragFeedback ?? null })
296
302
  : renderListView({ visibleTasks, selectedTaskId });
297
303
 
298
304
  return `
@@ -255,6 +255,10 @@ export function shouldUseTaskModal(boardState, store) {
255
255
  return Boolean(boardState?.taskModalOpen && boardState?.selectedTask);
256
256
  }
257
257
 
258
+ export function shouldUseSubtaskModal(boardState) {
259
+ return Boolean(boardState?.subtaskModalOpen && boardState?.selectedSubtask);
260
+ }
261
+
258
262
  export function lookupNode(snapshot, id) {
259
263
  return snapshot.tasks.find((task) => task.id === id)
260
264
  ?? snapshot.subtasks.find((subtask) => subtask.id === id)
@@ -310,6 +310,8 @@ export function createDelegation(rootElement, actions) {
310
310
  for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
311
311
  el.classList.remove("board-drop-valid", "board-drop-invalid");
312
312
  }
313
+ // Clear feedback in store so rerenders do not re-apply stale classes.
314
+ actions.setDragFeedback?.(null);
313
315
  }
314
316
 
315
317
  function handleDragstart(event) {
@@ -336,11 +338,15 @@ export function createDelegation(rootElement, actions) {
336
338
  const targetStatus = column.dataset.dropStatus;
337
339
  if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
338
340
  event.preventDefault();
341
+ // Fast path: apply directly to DOM for immediate visual feedback.
339
342
  column.classList.add("board-drop-valid");
340
343
  column.classList.remove("board-drop-invalid");
344
+ // Also persist in store so a rerender during drag restores the class.
345
+ actions.setDragFeedback?.({ targetStatus, kind: "valid" });
341
346
  } else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
342
347
  column.classList.add("board-drop-invalid");
343
348
  column.classList.remove("board-drop-valid");
349
+ actions.setDragFeedback?.({ targetStatus, kind: "invalid" });
344
350
  }
345
351
  }
346
352
 
@@ -348,6 +354,8 @@ export function createDelegation(rootElement, actions) {
348
354
  const column = event.target.closest("[data-drop-status]");
349
355
  if (column && !column.contains(event.relatedTarget)) {
350
356
  column.classList.remove("board-drop-valid", "board-drop-invalid");
357
+ // Clear store feedback when leaving the column.
358
+ actions.setDragFeedback?.(null);
351
359
  }
352
360
  }
353
361
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Lazy overlay focus-trap controller.
3
+ *
4
+ * Attaches the document-level keydown/focusin listeners only while an overlay
5
+ * is actually open, and detaches them on close. Without this, plain Tab outside
6
+ * any overlay can be intercepted by a stale handler in the microtask window
7
+ * between overlay close and rerender.
8
+ *
9
+ * @param {{
10
+ * doc?: Document,
11
+ * onTabKey: (event: KeyboardEvent) => void,
12
+ * onFocusIn: (event: FocusEvent) => void,
13
+ * }} options
14
+ */
15
+ export function createOverlayFocusTrap(options) {
16
+ const doc = options.doc ?? (typeof document !== "undefined" ? document : null);
17
+ if (!doc) {
18
+ return {
19
+ attach() {},
20
+ detach() {},
21
+ isAttached() { return false; },
22
+ };
23
+ }
24
+
25
+ const onKeyDown = options.onTabKey;
26
+ const onFocusIn = options.onFocusIn;
27
+ let attached = false;
28
+
29
+ function attach() {
30
+ if (attached) return;
31
+ doc.addEventListener("keydown", onKeyDown, true);
32
+ doc.addEventListener("focusin", onFocusIn, true);
33
+ attached = true;
34
+ }
35
+
36
+ function detach() {
37
+ if (!attached) return;
38
+ doc.removeEventListener("keydown", onKeyDown, true);
39
+ doc.removeEventListener("focusin", onFocusIn, true);
40
+ attached = false;
41
+ }
42
+
43
+ return {
44
+ attach,
45
+ detach,
46
+ isAttached() { return attached; },
47
+ };
48
+ }
@@ -142,6 +142,13 @@ export function createBoardActions(options) {
142
142
  searchFocusKeys,
143
143
  } = options;
144
144
  const { store, persist, getBoardState, getTaskById, syncState } = model;
145
+ // Direct mutations of `store.*` (filter toggles, notice changes, drag
146
+ // feedback) bypass setState/syncState, so `getBoardState()`'s memo would
147
+ // otherwise return a stale reference until the next syncState/notify.
148
+ // Route these through invalidateBoardStateMemo() before rerender.
149
+ const invalidateMemo = typeof model.invalidateBoardStateMemo === "function"
150
+ ? () => model.invalidateBoardStateMemo()
151
+ : () => {};
145
152
 
146
153
  const transition = (patch = {}, options = {}) => {
147
154
  const { persistState = true, rerenderBoard = true } = options;
@@ -348,13 +355,21 @@ export function createBoardActions(options) {
348
355
  });
349
356
  },
350
357
  closeTask() {
351
- transition({ selectedTaskId: null, selectedSubtaskId: null, taskModalOpen: false });
358
+ transition({
359
+ selectedTaskId: null,
360
+ selectedSubtaskId: null,
361
+ taskModalOpen: false,
362
+ subtaskModalOpen: false,
363
+ });
352
364
  },
353
365
  openSubtask(subtaskId) {
354
- transition({ selectedSubtaskId: subtaskId || null }, { persistState: false });
366
+ transition(
367
+ { selectedSubtaskId: subtaskId || null, subtaskModalOpen: Boolean(subtaskId) },
368
+ { persistState: false },
369
+ );
355
370
  },
356
371
  closeSubtask() {
357
- transition({ selectedSubtaskId: null }, { persistState: false });
372
+ transition({ selectedSubtaskId: null, subtaskModalOpen: false }, { persistState: false });
358
373
  },
359
374
  submitTaskForm(taskId, formData) {
360
375
  const updates = {
@@ -413,12 +428,31 @@ export function createBoardActions(options) {
413
428
  const task = getTaskById(taskId);
414
429
  return task?.status ?? null;
415
430
  },
431
+ /**
432
+ * Record the current drag-over feedback in store state so it survives a
433
+ * rerender triggered by an unrelated event (e.g. a server SSE push).
434
+ * Pass null to clear feedback (dragend / drop / dragleave).
435
+ *
436
+ * @param {{ targetStatus: string, kind: 'valid'|'invalid' }|null} feedback
437
+ */
438
+ setDragFeedback(feedback) {
439
+ // Reference equality on freshly-allocated `{ targetStatus, kind }` always
440
+ // fails, so dragover would rerender on every move. Compare by primitive
441
+ // key so repeats over the same column become a no-op.
442
+ const prev = store.dragFeedback;
443
+ const prevKey = prev ? `${prev.targetStatus}|${prev.kind}` : null;
444
+ const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
445
+ if (prevKey === nextKey) return;
446
+ store.dragFeedback = feedback;
447
+ rerender();
448
+ },
416
449
  dropTaskStatus(taskId, nextStatus) {
417
450
  const task = getTaskById(taskId);
418
451
  if (!task || !nextStatus || task.status === nextStatus) {
419
452
  return;
420
453
  }
421
- transition({ selectedTaskId: taskId, taskModalOpen: false }, { rerenderBoard: false });
454
+ // Drag/drop is a status change only; do not mutate selection or modal state,
455
+ // otherwise dropping a card while another task modal is open hijacks it.
422
456
  api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
423
457
  },
424
458
  changeEpicStatus(epicId, newStatus) {
@@ -438,22 +472,26 @@ export function createBoardActions(options) {
438
472
  toggleEpicStatusFilter(status) {
439
473
  const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
440
474
  store.epicStatusFilter = { ...current, [status]: !current[status] };
475
+ invalidateMemo();
441
476
  persist();
442
477
  rerender();
443
478
  },
444
479
  toggleTaskStatusFilter(status) {
445
480
  const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
446
481
  store.taskStatusFilter = { ...current, [status]: !current[status] };
482
+ invalidateMemo();
447
483
  persist();
448
484
  rerender();
449
485
  },
450
486
  resetEpicFilter() {
451
487
  store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
488
+ invalidateMemo();
452
489
  persist();
453
490
  rerender();
454
491
  },
455
492
  resetTaskFilter() {
456
493
  store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
494
+ invalidateMemo();
457
495
  persist();
458
496
  rerender();
459
497
  },
@@ -13,6 +13,133 @@ function cloneSnapshot(snapshot) {
13
13
  return JSON.parse(JSON.stringify(snapshot));
14
14
  }
15
15
 
16
+ const SNAPSHOT_COLLECTIONS = ["epics", "tasks", "subtasks", "dependencies"];
17
+
18
+ const COLLECTION_TO_DELETED_KEY = {
19
+ epics: "deletedEpicIds",
20
+ tasks: "deletedTaskIds",
21
+ subtasks: "deletedSubtaskIds",
22
+ dependencies: "deletedDependencyIds",
23
+ };
24
+
25
+ function arraysShallowEqual(left, right) {
26
+ if (left === right) return true;
27
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
28
+ if (left.length !== right.length) return false;
29
+ for (let i = 0; i < left.length; i += 1) {
30
+ if (left[i] !== right[i]) return false;
31
+ }
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Shallow equality on plain board records.
37
+ *
38
+ * Board records are flat objects whose values are primitives or arrays of
39
+ * primitives (e.g. dependency-id arrays). A field-by-field shallow comparison
40
+ * is therefore equivalent to a structural deep-equal but avoids the
41
+ * O(snapshot) JSON.stringify cost the previous implementation paid on every
42
+ * rollback. Cross-realm values and odd nested objects fall back to
43
+ * reference equality, which is a safe over-restore (worst case: an unchanged
44
+ * record is included in the inverse delta).
45
+ */
46
+ function recordsShallowEqual(left, right) {
47
+ if (left === right) return true;
48
+ if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
49
+ const leftKeys = Object.keys(left);
50
+ const rightKeys = Object.keys(right);
51
+ if (leftKeys.length !== rightKeys.length) return false;
52
+ for (const key of leftKeys) {
53
+ const leftValue = left[key];
54
+ const rightValue = right[key];
55
+ if (leftValue === rightValue) continue;
56
+ if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
57
+ if (!arraysShallowEqual(leftValue, rightValue)) return false;
58
+ continue;
59
+ }
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ function indexById(records) {
66
+ const map = new Map();
67
+ if (!Array.isArray(records)) {
68
+ return map;
69
+ }
70
+
71
+ for (const record of records) {
72
+ if (record && typeof record === "object" && typeof record.id === "string" && record.id.length > 0) {
73
+ map.set(record.id, record);
74
+ }
75
+ }
76
+
77
+ return map;
78
+ }
79
+
80
+ /**
81
+ * Compute the inverse delta needed to revert an optimistic mutation.
82
+ *
83
+ * The inverse is built by diffing the snapshot _before_ the optimistic patch
84
+ * against the snapshot _after_ the patch. We only describe entities the
85
+ * optimistic patch actually touched so that concurrent deltas pushed by the
86
+ * server (for unrelated entities) are preserved when we apply the inverse.
87
+ *
88
+ * @param {object} previousSnapshot - Snapshot prior to optimistic apply.
89
+ * @param {object} optimisticSnapshot - Snapshot after optimistic apply.
90
+ * @returns {{
91
+ * epics?: object[], tasks?: object[], subtasks?: object[], dependencies?: object[],
92
+ * deletedEpicIds?: string[], deletedTaskIds?: string[], deletedSubtaskIds?: string[], deletedDependencyIds?: string[],
93
+ * }}
94
+ */
95
+ export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
96
+ const inverse = {};
97
+ for (const collection of SNAPSHOT_COLLECTIONS) {
98
+ const before = indexById(previousSnapshot?.[collection]);
99
+ const after = indexById(optimisticSnapshot?.[collection]);
100
+
101
+ const restored = [];
102
+ const deletedIds = [];
103
+
104
+ // Entities that the optimistic patch deleted -> restore them.
105
+ for (const [id, beforeRecord] of before) {
106
+ if (!after.has(id)) {
107
+ restored.push(beforeRecord);
108
+ }
109
+ }
110
+
111
+ // Entities present in both but mutated -> restore the previous version.
112
+ // cloneSnapshot/normalizeSnapshot always produce fresh references for
113
+ // every record in the optimistic snapshot, so plain reference inequality
114
+ // would flag every entity. Use a shallow field-by-field equality check
115
+ // instead — equivalent to a structural compare for these flat records but
116
+ // O(field) per record rather than O(snapshot) JSON.stringify on each
117
+ // rollback (P2 perf finding).
118
+ for (const [id, afterRecord] of after) {
119
+ const beforeRecord = before.get(id);
120
+ if (beforeRecord && beforeRecord !== afterRecord && !recordsShallowEqual(beforeRecord, afterRecord)) {
121
+ restored.push(beforeRecord);
122
+ }
123
+ }
124
+
125
+ // Entities the optimistic patch added -> mark for deletion.
126
+ for (const id of after.keys()) {
127
+ if (!before.has(id)) {
128
+ deletedIds.push(id);
129
+ }
130
+ }
131
+
132
+ if (restored.length > 0) {
133
+ inverse[collection] = restored;
134
+ }
135
+ if (deletedIds.length > 0) {
136
+ inverse[COLLECTION_TO_DELETED_KEY[collection]] = deletedIds;
137
+ }
138
+ }
139
+
140
+ return inverse;
141
+ }
142
+
16
143
  async function readJsonPayload(response) {
17
144
  const text = await response.text();
18
145
  if (text.length === 0) {
@@ -119,14 +246,27 @@ export function createMutationQueue(model, rerender) {
119
246
 
120
247
  while (queue.length > 0) {
121
248
  const mutation = queue.shift();
122
- const previousSnapshot = cloneSnapshot(model.store.snapshot);
123
249
  if (model.store.notice?.retryMutationId !== mutation.id) {
124
250
  model.store.notice = null;
125
251
  }
126
252
 
253
+ // Capture per-mutation inverse delta if the optimistic patch ran.
254
+ // Using an inverse delta (rather than wholesale replaceSnapshot) means
255
+ // concurrent server-pushed deltas applied to unrelated entities while
256
+ // the request was in flight survive a rollback.
257
+ let inverseDelta = null;
258
+
127
259
  try {
128
260
  if (typeof mutation.optimistic === "function") {
129
- model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
261
+ const previousSnapshot = cloneSnapshot(model.store.snapshot);
262
+ const optimisticSnapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
263
+ inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
264
+ model.store.snapshot = optimisticSnapshot;
265
+ // Direct snapshot mutation bypasses setState/syncState; invalidate
266
+ // the memo so the next getBoardState() reflects the optimistic write.
267
+ if (typeof model.invalidateBoardStateMemo === "function") {
268
+ model.invalidateBoardStateMemo();
269
+ }
130
270
  rerender();
131
271
  }
132
272
 
@@ -146,8 +286,11 @@ export function createMutationQueue(model, rerender) {
146
286
  ? { type: "success", message: mutation.successMessage }
147
287
  : null;
148
288
  } catch (error) {
149
- // Revert to pre-optimistic snapshot
150
- model.replaceSnapshot(previousSnapshot);
289
+ // Revert only the entities this mutation touched. Any unrelated
290
+ // entities updated by concurrent server deltas remain intact.
291
+ if (inverseDelta) {
292
+ model.applySnapshotDelta(inverseDelta);
293
+ }
151
294
 
152
295
  const message = error instanceof Error ? error.message : String(error);
153
296
  model.store.notice = {
@@ -166,6 +309,9 @@ export function createMutationQueue(model, rerender) {
166
309
 
167
310
  processing = false;
168
311
  model.store.isMutating = false;
312
+ if (typeof model.invalidateBoardStateMemo === "function") {
313
+ model.invalidateBoardStateMemo();
314
+ }
169
315
  rerender();
170
316
  resolveFlushes();
171
317
  }
@@ -204,20 +350,26 @@ export function createApi(model, options) {
204
350
  let lastFailedMutation = null;
205
351
 
206
352
  function enqueueMutation(definition) {
353
+ // Assign a stable identity token so success callbacks can clear
354
+ // lastFailedMutation by id rather than by function-reference equality
355
+ // (inline arrow functions are never the same reference across retries).
356
+ const mutationId = crypto.randomUUID();
357
+ const tagged = { ...definition, mutationId };
358
+
207
359
  queue.enqueue({
208
- ...definition,
360
+ ...tagged,
209
361
  onSuccess(data) {
210
- if (lastFailedMutation?.request === definition.request) {
362
+ if (lastFailedMutation?.mutationId === mutationId) {
211
363
  lastFailedMutation = null;
212
364
  }
213
- if (typeof definition.onSuccess === "function") {
214
- definition.onSuccess(data);
365
+ if (typeof tagged.onSuccess === "function") {
366
+ tagged.onSuccess(data);
215
367
  }
216
368
  },
217
369
  onError(error) {
218
- lastFailedMutation = definition;
219
- if (typeof definition.onError === "function") {
220
- definition.onError(error);
370
+ lastFailedMutation = tagged;
371
+ if (typeof tagged.onError === "function") {
372
+ tagged.onError(error);
221
373
  }
222
374
  },
223
375
  });
@@ -403,3 +555,124 @@ export function createApi(model, options) {
403
555
  },
404
556
  };
405
557
  }
558
+
559
+ /**
560
+ * Subscribe the board client to /api/snapshot/stream.
561
+ *
562
+ * Receives `snapshotDelta` events emitted by the per-server-instance event bus
563
+ * (own server mutations + WAL-watcher-derived deltas from external CLI writes)
564
+ * and applies them via `model.applySnapshotDelta`. Idempotent merge means
565
+ * re-applying a delta we already saw via the mutation response is harmless.
566
+ *
567
+ * Returns a `dispose()` function that closes the EventSource and stops
568
+ * processing further events.
569
+ *
570
+ * @param {object} model - Store with `applySnapshotDelta` method
571
+ * @param {object} options
572
+ * @param {string} options.sessionToken - Auth token (forwarded as ?token=)
573
+ * @param {function} options.rerender - Trigger UI rerender after applying deltas
574
+ * @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
575
+ * @param {string} [options.path] - Override stream path; default /api/snapshot/stream
576
+ * @returns {{ dispose: () => void, eventSource: EventSource | null }}
577
+ */
578
+ export function subscribeSnapshotStream(model, options) {
579
+ const {
580
+ sessionToken,
581
+ rerender,
582
+ EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null,
583
+ path = "/api/snapshot/stream",
584
+ } = options ?? {};
585
+
586
+ if (!EventSourceCtor) {
587
+ return { dispose: () => {}, eventSource: null };
588
+ }
589
+
590
+ // EventSource cannot set custom headers, so the auth token rides as a query
591
+ // parameter. Server `extractToken` already accepts ?token=.
592
+ const url = sessionToken && sessionToken.length > 0
593
+ ? `${path}?token=${encodeURIComponent(sessionToken)}`
594
+ : path;
595
+
596
+ let disposed = false;
597
+ const eventSource = new EventSourceCtor(url);
598
+
599
+ const handleSnapshotDelta = (event) => {
600
+ if (disposed) return;
601
+ const raw = typeof event?.data === "string" ? event.data : "";
602
+ if (raw.length === 0) return;
603
+ let payload;
604
+ try {
605
+ payload = JSON.parse(raw);
606
+ } catch (error) {
607
+ const message = error instanceof Error ? error.message : String(error);
608
+ // Surface malformed payloads so operators can spot serialization bugs.
609
+ // Avoid logging the raw text (may include sensitive content) — just length + error.
610
+ console.warn(`subscribeSnapshotStream: malformed snapshotDelta JSON (${raw.length} bytes): ${message}`);
611
+ return;
612
+ }
613
+ const delta = payload?.snapshotDelta;
614
+ if (!delta || typeof delta !== "object") return;
615
+ model.applySnapshotDelta(delta);
616
+ if (typeof rerender === "function") rerender();
617
+ };
618
+
619
+ const handleSnapshot = (event) => {
620
+ if (disposed) return;
621
+ const raw = typeof event?.data === "string" ? event.data : "";
622
+ if (raw.length === 0) return;
623
+ let payload;
624
+ try {
625
+ payload = JSON.parse(raw);
626
+ } catch (error) {
627
+ const message = error instanceof Error ? error.message : String(error);
628
+ console.warn(`subscribeSnapshotStream: malformed snapshot JSON (${raw.length} bytes): ${message}`);
629
+ return;
630
+ }
631
+ const snapshot = payload?.snapshot;
632
+ if (!snapshot || typeof snapshot !== "object") return;
633
+ if (typeof model.replaceSnapshot === "function") {
634
+ model.replaceSnapshot(snapshot);
635
+ if (typeof rerender === "function") rerender();
636
+ }
637
+ };
638
+
639
+ const handleError = () => {
640
+ if (disposed) return;
641
+ // Surface the disconnect to the user so they don't silently miss live
642
+ // updates. EventSource will keep auto-reconnecting; once a snapshot/
643
+ // snapshotDelta event lands again, the regular notice clearing flow
644
+ // (e.g. on the next mutation) replaces this notice.
645
+ if (model.store && typeof model.store === "object") {
646
+ const existing = model.store.notice;
647
+ if (!existing || existing.code !== "live_updates_disconnected") {
648
+ model.store.notice = {
649
+ type: "warning",
650
+ code: "live_updates_disconnected",
651
+ title: "Live updates disconnected",
652
+ message: "Reconnecting to the server. Changes from other sessions may be delayed.",
653
+ };
654
+ if (typeof rerender === "function") rerender();
655
+ }
656
+ }
657
+ };
658
+
659
+ eventSource.addEventListener("snapshotDelta", handleSnapshotDelta);
660
+ eventSource.addEventListener("snapshot", handleSnapshot);
661
+ // EventSource calls .onerror on disconnect (and will continue auto-reconnecting).
662
+ // Use the onerror property rather than addEventListener("error") so tests can
663
+ // trigger it via `instance.onerror?.()` without a full event object.
664
+ eventSource.onerror = handleError;
665
+
666
+ return {
667
+ eventSource,
668
+ dispose() {
669
+ if (disposed) return;
670
+ disposed = true;
671
+ try {
672
+ eventSource.close();
673
+ } catch {
674
+ // best-effort
675
+ }
676
+ },
677
+ };
678
+ }