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