trekoon 0.4.2 → 0.4.4

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 (40) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -208
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
  3. package/.agents/skills/trekoon/reference/execution.md +170 -380
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +193 -330
  6. package/.agents/skills/trekoon/reference/sync.md +56 -103
  7. package/README.md +29 -10
  8. package/docs/ai-agents.md +48 -4
  9. package/docs/commands.md +34 -25
  10. package/docs/machine-contracts.md +1 -1
  11. package/docs/quickstart.md +9 -9
  12. package/package.json +2 -2
  13. package/src/board/asset-root.ts +73 -0
  14. package/src/board/assets/app.js +5 -3
  15. package/src/board/assets/components/Component.js +6 -8
  16. package/src/board/assets/state/actions.js +3 -0
  17. package/src/board/assets/state/api.js +48 -34
  18. package/src/board/assets/state/store.js +3 -0
  19. package/src/board/event-bus.ts +15 -0
  20. package/src/board/routes.ts +94 -83
  21. package/src/board/server.ts +35 -8
  22. package/src/board/snapshot.ts +6 -0
  23. package/src/board/types.ts +2 -34
  24. package/src/board/wal-watcher.ts +170 -28
  25. package/src/commands/board.ts +20 -42
  26. package/src/commands/help.ts +11 -12
  27. package/src/commands/init.ts +0 -29
  28. package/src/commands/quickstart.ts +1 -1
  29. package/src/commands/skills.ts +17 -5
  30. package/src/domain/mutation-service.ts +61 -42
  31. package/src/domain/tracker-domain.ts +20 -16
  32. package/src/domain/types.ts +3 -0
  33. package/src/export/render-markdown.ts +1 -2
  34. package/src/runtime/daemon.ts +110 -49
  35. package/src/runtime/version.ts +10 -2
  36. package/src/storage/database.ts +9 -2
  37. package/src/storage/migrations.ts +19 -2
  38. package/src/storage/path.ts +0 -36
  39. package/src/sync/service.ts +47 -27
  40. package/src/board/install.ts +0 -196
@@ -153,8 +153,8 @@ export function preserveFormState(container, writeFn, options = {}) {
153
153
 
154
154
  // Per-form cache for getManagedControls: avoids the O(n^2) re-query that
155
155
  // occurs when many controls share the same form root.
156
- const captureCache = new Map();
157
- const inputs = getManagedControls(container, captureCache);
156
+ const controlCache = new Map();
157
+ const inputs = getManagedControls(container, controlCache);
158
158
 
159
159
  const activeElement = document.activeElement;
160
160
  let focusedIdentity = null;
@@ -162,7 +162,7 @@ export function preserveFormState(container, writeFn, options = {}) {
162
162
  const savedStates = inputs.map((el) => {
163
163
  const form = getFormRoot(el);
164
164
  const formId = getNamespacedFormIdentity(form);
165
- const controlId = form ? getControlIdentity(el, form, captureCache) : null;
165
+ const controlId = form ? getControlIdentity(el, form, controlCache) : null;
166
166
  const identity = controlId ? { formId, controlId } : null;
167
167
 
168
168
  if (activeElement === el) {
@@ -180,6 +180,7 @@ export function preserveFormState(container, writeFn, options = {}) {
180
180
  }).filter(s => s.identity);
181
181
 
182
182
  writeFn();
183
+ controlCache.clear();
183
184
 
184
185
  const formsByIdentity = new Map(
185
186
  Array.from(container.querySelectorAll(FORM_ROOT_SELECTOR)).map((form) => [
@@ -188,16 +189,13 @@ export function preserveFormState(container, writeFn, options = {}) {
188
189
  ]),
189
190
  );
190
191
 
191
- // Fresh cache for the restore pass (DOM was replaced by writeFn).
192
- const restoreCache = new Map();
193
-
194
192
  for (const state of savedStates) {
195
193
  const { formId, controlId } = state.identity;
196
194
  if (resetFormIds.has(formId)) {
197
195
  continue;
198
196
  }
199
197
  const form = formsByIdentity.get(formId) ?? container;
200
- const restored = getManagedControls(form, restoreCache).find((control) => getControlIdentity(control, form, restoreCache) === controlId);
198
+ const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
201
199
  if (restored && restored.value !== state.value) {
202
200
  restored.value = state.value;
203
201
  }
@@ -213,7 +211,7 @@ export function preserveFormState(container, writeFn, options = {}) {
213
211
  return;
214
212
  }
215
213
  const form = formsByIdentity.get(formId) ?? container;
216
- const restored = getManagedControls(form, restoreCache).find((control) => getControlIdentity(control, form, restoreCache) === controlId);
214
+ const restored = getManagedControls(form, controlCache).find((control) => getControlIdentity(control, form, controlCache) === controlId);
217
215
  if (restored) {
218
216
  restored.focus({ preventScroll: true });
219
217
  const focusedState = savedStates.find((state) => state.identity?.formId === formId && state.identity?.controlId === controlId);
@@ -444,6 +444,9 @@ export function createBoardActions(options) {
444
444
  const nextKey = feedback ? `${feedback.targetStatus}|${feedback.kind}` : null;
445
445
  if (prevKey === nextKey) return;
446
446
  store.dragFeedback = feedback;
447
+ if (typeof model.invalidateBoardStateMemo === "function") {
448
+ model.invalidateBoardStateMemo();
449
+ }
447
450
  rerender();
448
451
  },
449
452
  dropTaskStatus(taskId, nextStatus) {
@@ -222,10 +222,9 @@ function createTimeoutError(method, path, timeoutMs) {
222
222
  * }}
223
223
  */
224
224
  export function createMutationQueue(model, rerender) {
225
- /** @type {Array<{ optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
225
+ /** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
226
226
  const queue = [];
227
227
  let processing = false;
228
- let nextMutationId = 1;
229
228
  /** @type {Array<() => void>} */
230
229
  let flushResolvers = [];
231
230
 
@@ -246,7 +245,7 @@ export function createMutationQueue(model, rerender) {
246
245
 
247
246
  while (queue.length > 0) {
248
247
  const mutation = queue.shift();
249
- if (model.store.notice?.retryMutationId !== mutation.id) {
248
+ if (model.store.notice?.retryMutationId !== mutation.mutationId) {
250
249
  model.store.notice = null;
251
250
  }
252
251
 
@@ -258,8 +257,8 @@ export function createMutationQueue(model, rerender) {
258
257
 
259
258
  try {
260
259
  if (typeof mutation.optimistic === "function") {
261
- const previousSnapshot = cloneSnapshot(model.store.snapshot);
262
- const optimisticSnapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
260
+ const previousSnapshot = model.store.snapshot;
261
+ const optimisticSnapshot = mutation.optimistic(cloneSnapshot(previousSnapshot));
263
262
  inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
264
263
  model.store.snapshot = optimisticSnapshot;
265
264
  // Direct snapshot mutation bypasses setState/syncState; invalidate
@@ -298,7 +297,7 @@ export function createMutationQueue(model, rerender) {
298
297
  title: "Action failed",
299
298
  message,
300
299
  retryLabel: "Retry",
301
- retryMutationId: mutation.id,
300
+ retryMutationId: mutation.mutationId,
302
301
  };
303
302
 
304
303
  if (typeof mutation.onError === "function") {
@@ -318,8 +317,10 @@ export function createMutationQueue(model, rerender) {
318
317
 
319
318
  return {
320
319
  enqueue(mutation) {
321
- queue.push({ ...mutation, id: nextMutationId });
322
- nextMutationId += 1;
320
+ queue.push({
321
+ ...mutation,
322
+ mutationId: mutation.mutationId ?? crypto.randomUUID(),
323
+ });
323
324
  processNext();
324
325
  },
325
326
 
@@ -569,7 +570,7 @@ export function createApi(model, options) {
569
570
  *
570
571
  * @param {object} model - Store with `applySnapshotDelta` method
571
572
  * @param {object} options
572
- * @param {string} options.sessionToken - Auth token (forwarded as ?token=)
573
+ * @param {string} options.sessionToken - Auth token for API parity; EventSource uses the same-origin HttpOnly cookie.
573
574
  * @param {function} options.rerender - Trigger UI rerender after applying deltas
574
575
  * @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
575
576
  * @param {string} [options.path] - Override stream path; default /api/snapshot/stream
@@ -577,7 +578,6 @@ export function createApi(model, options) {
577
578
  */
578
579
  export function subscribeSnapshotStream(model, options) {
579
580
  const {
580
- sessionToken,
581
581
  rerender,
582
582
  EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null,
583
583
  path = "/api/snapshot/stream",
@@ -587,14 +587,20 @@ export function subscribeSnapshotStream(model, options) {
587
587
  return { dispose: () => {}, eventSource: null };
588
588
  }
589
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
590
  let disposed = false;
597
- const eventSource = new EventSourceCtor(url);
591
+ let consecutiveErrors = 0;
592
+ const eventSource = new EventSourceCtor(path);
593
+
594
+ const clearLiveUpdateNotice = () => {
595
+ if (model.store?.notice?.code === "live_updates_disconnected") {
596
+ model.store.notice = null;
597
+ }
598
+ };
599
+
600
+ const markLiveUpdateSuccess = () => {
601
+ consecutiveErrors = 0;
602
+ clearLiveUpdateNotice();
603
+ };
598
604
 
599
605
  const handleSnapshotDelta = (event) => {
600
606
  if (disposed) return;
@@ -613,6 +619,7 @@ export function subscribeSnapshotStream(model, options) {
613
619
  const delta = payload?.snapshotDelta;
614
620
  if (!delta || typeof delta !== "object") return;
615
621
  model.applySnapshotDelta(delta);
622
+ markLiveUpdateSuccess();
616
623
  if (typeof rerender === "function") rerender();
617
624
  };
618
625
 
@@ -632,27 +639,42 @@ export function subscribeSnapshotStream(model, options) {
632
639
  if (!snapshot || typeof snapshot !== "object") return;
633
640
  if (typeof model.replaceSnapshot === "function") {
634
641
  model.replaceSnapshot(snapshot);
642
+ markLiveUpdateSuccess();
635
643
  if (typeof rerender === "function") rerender();
636
644
  }
637
645
  };
638
646
 
647
+ function dispose() {
648
+ if (disposed) return;
649
+ disposed = true;
650
+ try {
651
+ eventSource.close();
652
+ } catch {
653
+ // best-effort
654
+ }
655
+ }
656
+
639
657
  const handleError = () => {
640
658
  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.
659
+ consecutiveErrors += 1;
645
660
  if (model.store && typeof model.store === "object") {
646
661
  const existing = model.store.notice;
647
- if (!existing || existing.code !== "live_updates_disconnected") {
662
+ const disabled = consecutiveErrors >= 5;
663
+ const nextCode = disabled ? "live_updates_disabled" : "live_updates_disconnected";
664
+ if (!existing || existing.code !== nextCode) {
648
665
  model.store.notice = {
649
666
  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.",
667
+ code: nextCode,
668
+ title: disabled ? "Live updates disabled" : "Live updates disconnected",
669
+ message: disabled
670
+ ? "Refresh the board to resume live updates from other sessions."
671
+ : "Reconnecting to the server. Changes from other sessions may be delayed.",
653
672
  };
654
673
  if (typeof rerender === "function") rerender();
655
674
  }
675
+ if (disabled) {
676
+ dispose();
677
+ }
656
678
  }
657
679
  };
658
680
 
@@ -665,14 +687,6 @@ export function subscribeSnapshotStream(model, options) {
665
687
 
666
688
  return {
667
689
  eventSource,
668
- dispose() {
669
- if (disposed) return;
670
- disposed = true;
671
- try {
672
- eventSource.close();
673
- } catch {
674
- // best-effort
675
- }
676
- },
690
+ dispose,
677
691
  };
678
692
  }
@@ -400,6 +400,9 @@ export function createStore(initialSnapshot, options = {}) {
400
400
 
401
401
  const boardStateMemo = createBoardStateMemo(deriveBoardState);
402
402
 
403
+ // Direct `model.store` mutations must manually invalidate this memo before
404
+ // rerendering. Current direct-mutation fields: snapshot, notice, isMutating,
405
+ // dragFeedback.
403
406
  function notify() {
404
407
  boardStateMemo.invalidate();
405
408
  for (const listener of listeners) {
@@ -18,7 +18,10 @@ export type BoardEventListener = (event: BoardEvent) => void;
18
18
 
19
19
  export interface BoardEventBus {
20
20
  publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
21
+ markInProcessWrite(timestamp?: number, snapshotDelta?: Record<string, unknown>): void;
21
22
  subscribe(listener: BoardEventListener): () => void;
23
+ readonly lastInProcessWriteAt: number;
24
+ readonly lastInProcessSnapshotDelta: Record<string, unknown> | null;
22
25
  readonly subscriberCount: number;
23
26
  close(): void;
24
27
  }
@@ -27,6 +30,8 @@ export function createBoardEventBus(): BoardEventBus {
27
30
  const listeners = new Set<BoardEventListener>();
28
31
  let nextId = 1;
29
32
  let closed = false;
33
+ let lastInProcessWriteAt = 0;
34
+ let lastInProcessSnapshotDelta: Record<string, unknown> | null = null;
30
35
 
31
36
  return {
32
37
  publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
@@ -51,6 +56,10 @@ export function createBoardEventBus(): BoardEventBus {
51
56
 
52
57
  return event;
53
58
  },
59
+ markInProcessWrite(timestamp = Date.now(), snapshotDelta: Record<string, unknown> | undefined = undefined): void {
60
+ lastInProcessWriteAt = timestamp;
61
+ lastInProcessSnapshotDelta = snapshotDelta ?? null;
62
+ },
54
63
  subscribe(listener: BoardEventListener): () => void {
55
64
  if (closed) {
56
65
  return () => {};
@@ -64,6 +73,12 @@ export function createBoardEventBus(): BoardEventBus {
64
73
  get subscriberCount(): number {
65
74
  return listeners.size;
66
75
  },
76
+ get lastInProcessWriteAt(): number {
77
+ return lastInProcessWriteAt;
78
+ },
79
+ get lastInProcessSnapshotDelta(): Record<string, unknown> | null {
80
+ return lastInProcessSnapshotDelta;
81
+ },
67
82
  close(): void {
68
83
  closed = true;
69
84
  listeners.clear();
@@ -84,7 +84,7 @@ function readCookieToken(request: Request): string | null {
84
84
  return null;
85
85
  }
86
86
 
87
- function extractToken(request: Request, url: URL): string | null {
87
+ function extractHeaderOrCookieToken(request: Request): string | null {
88
88
  const authorization: string | null = request.headers.get("authorization");
89
89
  if (authorization?.startsWith("Bearer ")) {
90
90
  return authorization.slice("Bearer ".length).trim();
@@ -95,11 +95,6 @@ function extractToken(request: Request, url: URL): string | null {
95
95
  return headerToken.trim();
96
96
  }
97
97
 
98
- const queryToken: string | null = url.searchParams.get("token");
99
- if (queryToken && queryToken.trim().length > 0) {
100
- return queryToken.trim();
101
- }
102
-
103
98
  return readCookieToken(request);
104
99
  }
105
100
 
@@ -108,17 +103,34 @@ function isSqliteBusyMessage(message: string): boolean {
108
103
  return normalized.includes("database is locked") || normalized.includes("database schema is locked");
109
104
  }
110
105
 
111
- function redactDetailLeaves(value: unknown): unknown {
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 {
112
113
  if (typeof value === "string") {
113
114
  return redactSensitive(value);
114
115
  }
116
+ if (depth >= REDACT_DETAILS_MAX_DEPTH) {
117
+ return "[MaxDepth]";
118
+ }
115
119
  if (Array.isArray(value)) {
116
- return value.map((item) => redactDetailLeaves(item));
120
+ if (seen.has(value)) {
121
+ return "[Circular]";
122
+ }
123
+ seen.add(value);
124
+ return value.map((item) => redactDetailLeaves(item, seen, depth + 1));
117
125
  }
118
126
  if (value !== null && typeof value === "object") {
127
+ if (seen.has(value)) {
128
+ return "[Circular]";
129
+ }
130
+ seen.add(value);
119
131
  const out: Record<string, unknown> = {};
120
132
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
121
- out[key] = redactDetailLeaves(child);
133
+ out[redactSensitive(key)] = redactDetailLeaves(child, seen, depth + 1);
122
134
  }
123
135
  return out;
124
136
  }
@@ -202,6 +214,7 @@ function publishSnapshotDeltaIfPresent(
202
214
  const delta = readSnapshotDelta(data);
203
215
  if (delta) {
204
216
  eventBus.publishSnapshotDelta(delta);
217
+ eventBus.markInProcessWrite(Date.now(), delta);
205
218
  }
206
219
  }
207
220
 
@@ -212,14 +225,10 @@ function formatSseEvent(eventName: string, data: unknown, id?: number): string {
212
225
 
213
226
  // SSE backpressure thresholds (P1 finding 5).
214
227
  // 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.
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.
223
232
  const SSE_BACKPRESSURE_CHECK_MS = 1_000;
224
233
 
225
234
  function openSnapshotStream(
@@ -243,19 +252,16 @@ function openSnapshotStream(
243
252
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
244
253
  let backpressureTimer: ReturnType<typeof setInterval> | null = null;
245
254
 
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
255
  let lastConsumeAt = Date.now();
252
256
  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
+ let pendingFrame: string | null = null;
258
+ let onAbort: (() => void) | null = null;
257
259
 
258
260
  const cleanupTimers = (): void => {
261
+ if (onAbort) {
262
+ request.signal.removeEventListener("abort", onAbort);
263
+ onAbort = null;
264
+ }
259
265
  if (unsubscribe) {
260
266
  unsubscribe();
261
267
  unsubscribe = null;
@@ -277,22 +283,29 @@ function openSnapshotStream(
277
283
  return;
278
284
  }
279
285
  try {
280
- const bytes = encoder.encode(chunk);
281
- controller.enqueue(bytes);
282
- queuedBytes += bytes.byteLength;
283
- pendingChunkSizes.push(bytes.byteLength);
286
+ controller.enqueue(encoder.encode(chunk));
284
287
  } catch {
285
288
  // Controller closed; cleanup happens via cancel.
286
289
  }
287
290
  };
288
291
 
289
- const flushPendingDelta = (): void => {
290
- if (!pendingDelta || closed) {
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()) {
291
304
  return;
292
305
  }
293
- const delta = pendingDelta;
294
- pendingDelta = null;
295
- enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta: delta.snapshotDelta }, delta.id));
306
+ const frame = pendingFrame;
307
+ pendingFrame = null;
308
+ enqueueRaw(frame);
296
309
  };
297
310
 
298
311
  const closeWithError = (reason: string): void => {
@@ -318,22 +331,15 @@ function openSnapshotStream(
318
331
  if (closed) {
319
332
  return;
320
333
  }
321
- if (queuedBytes >= SSE_MAX_QUEUED_BYTES) {
322
- closeWithError("queued bytes exceeded 1MB hard limit");
334
+ if (isBackpressured()) {
335
+ queueFullSnapshot(id);
323
336
  return;
324
337
  }
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 };
338
+ flushPendingFrame();
339
+ if (isBackpressured()) {
340
+ queueFullSnapshot(id);
330
341
  return;
331
342
  }
332
- // Fast path: flush any coalesced delta first to preserve ordering,
333
- // then enqueue the new one.
334
- if (pendingDelta) {
335
- flushPendingDelta();
336
- }
337
343
  enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta }, id));
338
344
  };
339
345
 
@@ -348,27 +354,26 @@ function openSnapshotStream(
348
354
 
349
355
  // Heartbeats keep proxies and stale-connection detectors happy.
350
356
  heartbeatTimer = setInterval(() => {
357
+ if ((controller.desiredSize ?? 1) < 0) {
358
+ return;
359
+ }
351
360
  enqueueRaw(": heartbeat\n\n");
352
361
  }, 15000);
353
362
 
354
363
  // 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.
364
+ // SSE_STALL_MS while the stream is under pressure, close with an
365
+ // error frame so EventSource reconnects and receives a fresh snapshot.
357
366
  backpressureTimer = setInterval(() => {
358
367
  if (closed) {
359
368
  return;
360
369
  }
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;
370
+ const stalled = (controller.desiredSize ?? 1) < 0 && Date.now() - lastConsumeAt > SSE_STALL_MS;
366
371
  if (stalled) {
367
372
  closeWithError(`no consumer pull within ${SSE_STALL_MS}ms`);
368
373
  }
369
374
  }, SSE_BACKPRESSURE_CHECK_MS);
370
375
 
371
- const onAbort = (): void => {
376
+ onAbort = (): void => {
372
377
  if (closed) {
373
378
  return;
374
379
  }
@@ -387,17 +392,17 @@ function openSnapshotStream(
387
392
  }
388
393
  request.signal.addEventListener("abort", onAbort);
389
394
  },
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
- }
395
+ pull(controller): void {
396
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.
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
+ }
401
406
  },
402
407
  cancel(): void {
403
408
  closed = true;
@@ -565,35 +570,30 @@ function parseIfMatchHeader(request: Request): number | null {
565
570
 
566
571
  // RFC 7232 §3.1 allows a strong ETag (`"<value>"`) or a weak ETag
567
572
  // (`W/"<value>"`). Trekoon does not differentiate strong from weak
568
- // semantics — a millisecond updatedAt is exact either way — so we
573
+ // semantics — the entity version token is exact either way — so we
569
574
  // accept both shapes. The wildcard `*` is intentionally NOT supported:
570
575
  // it would mean "any current representation matches" and would defeat
571
576
  // the whole purpose of the optimistic-concurrency check, so we surface
572
577
  // it as 400 invalid_input below rather than treating it as a no-op.
573
578
  const stripped = raw.trim().replace(/^W\//iu, "");
574
579
  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)) {
580
+ if (!/^(0|[1-9]\d*)$/u.test(trimmed)) {
581
581
  throw new DomainError({
582
582
  code: "invalid_input",
583
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",
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
585
  details: { header: "If-Match", value: raw },
586
586
  });
587
587
  }
588
588
 
589
- return parsed;
589
+ return Number(trimmed);
590
590
  }
591
591
 
592
592
  interface PreconditionFailedDetails {
593
593
  readonly entityKind: "epic" | "task" | "subtask";
594
594
  readonly entityId: string;
595
- readonly currentUpdatedAt: number;
596
- readonly providedUpdatedAt: number;
595
+ readonly currentVersion: number;
596
+ readonly providedVersion: number;
597
597
  }
598
598
 
599
599
  function preconditionFailedResponse(details: PreconditionFailedDetails): Response {
@@ -601,12 +601,12 @@ function preconditionFailedResponse(details: PreconditionFailedDetails): Respons
601
601
  ok: false,
602
602
  error: {
603
603
  code: "precondition_failed",
604
- message: "If-Match version does not match current updatedAt",
604
+ message: "If-Match version does not match current version",
605
605
  details: {
606
606
  entityKind: details.entityKind,
607
607
  entityId: details.entityId,
608
- currentUpdatedAt: details.currentUpdatedAt,
609
- providedUpdatedAt: details.providedUpdatedAt,
608
+ currentVersion: details.currentVersion,
609
+ providedVersion: details.providedVersion,
610
610
  },
611
611
  },
612
612
  });
@@ -630,7 +630,18 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
630
630
  return async (request: Request): Promise<Response> => {
631
631
  const url = new URL(request.url);
632
632
  const requestLabel = `${request.method} ${url.pathname}`;
633
- 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);
634
645
  // Plain `!==` instead of a constant-time compare is a deliberate choice
635
646
  // (System Hardening 0.4.2, finding 32). The board server only binds to
636
647
  // 127.0.0.1, the session token is a 256-bit cryptographically-random
@@ -1007,14 +1018,14 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
1007
1018
  });
1008
1019
  } catch (error: unknown) {
1009
1020
  // PreconditionFailedError is a typed signal from the *WithIfMatch
1010
- // CAS variants. It carries the freshly-fetched currentUpdatedAt so
1021
+ // CAS variants. It carries the freshly-fetched currentVersion so
1011
1022
  // the 409 payload is always consistent with the post-rollback state.
1012
1023
  if (error instanceof PreconditionFailedError) {
1013
1024
  return preconditionFailedResponse({
1014
1025
  entityKind: error.entityKind,
1015
1026
  entityId: error.entityId,
1016
- currentUpdatedAt: error.currentUpdatedAt,
1017
- providedUpdatedAt: error.providedUpdatedAt,
1027
+ currentVersion: error.currentVersion,
1028
+ providedVersion: error.providedVersion,
1018
1029
  });
1019
1030
  }
1020
1031