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.
- package/.agents/skills/trekoon/SKILL.md +97 -208
- package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
- package/.agents/skills/trekoon/reference/execution.md +170 -380
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +193 -330
- package/.agents/skills/trekoon/reference/sync.md +56 -103
- package/README.md +29 -10
- package/docs/ai-agents.md +48 -4
- package/docs/commands.md +34 -25
- package/docs/machine-contracts.md +1 -1
- package/docs/quickstart.md +9 -9
- package/package.json +2 -2
- package/src/board/asset-root.ts +73 -0
- package/src/board/assets/app.js +5 -3
- package/src/board/assets/components/Component.js +6 -8
- package/src/board/assets/state/actions.js +3 -0
- package/src/board/assets/state/api.js +48 -34
- package/src/board/assets/state/store.js +3 -0
- package/src/board/event-bus.ts +15 -0
- package/src/board/routes.ts +94 -83
- package/src/board/server.ts +35 -8
- package/src/board/snapshot.ts +6 -0
- package/src/board/types.ts +2 -34
- package/src/board/wal-watcher.ts +170 -28
- package/src/commands/board.ts +20 -42
- package/src/commands/help.ts +11 -12
- package/src/commands/init.ts +0 -29
- package/src/commands/quickstart.ts +1 -1
- package/src/commands/skills.ts +17 -5
- package/src/domain/mutation-service.ts +61 -42
- package/src/domain/tracker-domain.ts +20 -16
- package/src/domain/types.ts +3 -0
- package/src/export/render-markdown.ts +1 -2
- package/src/runtime/daemon.ts +110 -49
- package/src/runtime/version.ts +10 -2
- package/src/storage/database.ts +9 -2
- package/src/storage/migrations.ts +19 -2
- package/src/storage/path.ts +0 -36
- package/src/sync/service.ts +47 -27
- 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
|
|
157
|
-
const inputs = getManagedControls(container,
|
|
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,
|
|
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,
|
|
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,
|
|
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.
|
|
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 =
|
|
262
|
-
const optimisticSnapshot = mutation.optimistic(cloneSnapshot(
|
|
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.
|
|
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({
|
|
322
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
651
|
-
title: "Live updates disconnected",
|
|
652
|
-
message:
|
|
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) {
|
package/src/board/event-bus.ts
CHANGED
|
@@ -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();
|
package/src/board/routes.ts
CHANGED
|
@@ -84,7 +84,7 @@ function readCookieToken(request: Request): string | null {
|
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
enqueueRaw(
|
|
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 (
|
|
322
|
-
|
|
334
|
+
if (isBackpressured()) {
|
|
335
|
+
queueFullSnapshot(id);
|
|
323
336
|
return;
|
|
324
337
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
356
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 —
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
596
|
-
readonly
|
|
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
|
|
604
|
+
message: "If-Match version does not match current version",
|
|
605
605
|
details: {
|
|
606
606
|
entityKind: details.entityKind,
|
|
607
607
|
entityId: details.entityId,
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1017
|
-
|
|
1027
|
+
currentVersion: error.currentVersion,
|
|
1028
|
+
providedVersion: error.providedVersion,
|
|
1018
1029
|
});
|
|
1019
1030
|
}
|
|
1020
1031
|
|