trekoon 0.4.0 → 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.
- package/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- 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 +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -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 +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -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 +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- 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 +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- 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 +650 -147
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 {
|
|
@@ -106,6 +108,23 @@ function isSqliteBusyMessage(message: string): boolean {
|
|
|
106
108
|
return normalized.includes("database is locked") || normalized.includes("database schema is locked");
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
function redactDetailLeaves(value: unknown): unknown {
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
return redactSensitive(value);
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
return value.map((item) => redactDetailLeaves(item));
|
|
117
|
+
}
|
|
118
|
+
if (value !== null && typeof value === "object") {
|
|
119
|
+
const out: Record<string, unknown> = {};
|
|
120
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
121
|
+
out[key] = redactDetailLeaves(child);
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteError {
|
|
110
129
|
if (error instanceof DomainError) {
|
|
111
130
|
const status =
|
|
@@ -116,11 +135,20 @@ function toBoardRouteError(error: unknown, requestLabel: string): BoardRouteErro
|
|
|
116
135
|
: error.code === "invalid_dependency" || error.code === "dependency_blocked"
|
|
117
136
|
? 409
|
|
118
137
|
: 400;
|
|
138
|
+
// Secrets occasionally ride DomainError when an upstream layer interpolates
|
|
139
|
+
// a request body or header into the message/details (P1 finding 10).
|
|
140
|
+
// Run the canonical redactor over the message and recursively over every
|
|
141
|
+
// string-valued leaf of `details` before serialising to the wire so we
|
|
142
|
+
// never leak Bearer/Basic credentials, JWTs, or keyed `token=...` shapes.
|
|
143
|
+
const redactedMessage = redactSensitive(error.message);
|
|
144
|
+
const redactedDetails = error.details === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: (redactDetailLeaves(error.details) as Record<string, unknown>);
|
|
119
147
|
return {
|
|
120
148
|
status,
|
|
121
149
|
code: error.code,
|
|
122
|
-
message:
|
|
123
|
-
...(
|
|
150
|
+
message: redactedMessage,
|
|
151
|
+
...(redactedDetails === undefined ? {} : { details: redactedDetails }),
|
|
124
152
|
};
|
|
125
153
|
}
|
|
126
154
|
|
|
@@ -153,12 +181,240 @@ function describeBoardError(mutations: MutationService, error: unknown, requestL
|
|
|
153
181
|
return routeError;
|
|
154
182
|
}
|
|
155
183
|
|
|
184
|
+
// describeError can echo user-supplied values straight from a DomainError
|
|
185
|
+
// (status_transition_invalid returns error.message verbatim, dependency
|
|
186
|
+
// descriptions interpolate ids). Run the same redact pass over the
|
|
187
|
+
// friendlier message so secrets don't slip past the wire-level sanitiser.
|
|
156
188
|
return {
|
|
157
189
|
...routeError,
|
|
158
|
-
message: readableMessage,
|
|
190
|
+
message: redactSensitive(readableMessage),
|
|
159
191
|
};
|
|
160
192
|
}
|
|
161
193
|
|
|
194
|
+
function publishSnapshotDeltaIfPresent(
|
|
195
|
+
eventBus: BoardEventBus | undefined,
|
|
196
|
+
data: Record<string, unknown>,
|
|
197
|
+
): void {
|
|
198
|
+
if (!eventBus) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const delta = readSnapshotDelta(data);
|
|
203
|
+
if (delta) {
|
|
204
|
+
eventBus.publishSnapshotDelta(delta);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatSseEvent(eventName: string, data: unknown, id?: number): string {
|
|
209
|
+
const idLine = id === undefined ? "" : `id: ${id}\n`;
|
|
210
|
+
return `${idLine}event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// SSE backpressure thresholds (P1 finding 5).
|
|
214
|
+
// 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.
|
|
223
|
+
const SSE_BACKPRESSURE_CHECK_MS = 1_000;
|
|
224
|
+
|
|
225
|
+
function openSnapshotStream(
|
|
226
|
+
request: Request,
|
|
227
|
+
domain: TrackerDomain,
|
|
228
|
+
eventBus: BoardEventBus | undefined,
|
|
229
|
+
): Response {
|
|
230
|
+
if (!eventBus) {
|
|
231
|
+
return jsonResponse(503, {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: {
|
|
234
|
+
code: "stream_unavailable",
|
|
235
|
+
message: "Snapshot stream is not available on this server",
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const encoder = new TextEncoder();
|
|
241
|
+
const initialSnapshot = buildBoardSnapshot(domain);
|
|
242
|
+
let unsubscribe: (() => void) | null = null;
|
|
243
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
244
|
+
let backpressureTimer: ReturnType<typeof setInterval> | null = null;
|
|
245
|
+
|
|
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
|
+
let lastConsumeAt = Date.now();
|
|
252
|
+
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
|
+
|
|
258
|
+
const cleanupTimers = (): void => {
|
|
259
|
+
if (unsubscribe) {
|
|
260
|
+
unsubscribe();
|
|
261
|
+
unsubscribe = null;
|
|
262
|
+
}
|
|
263
|
+
if (heartbeatTimer) {
|
|
264
|
+
clearInterval(heartbeatTimer);
|
|
265
|
+
heartbeatTimer = null;
|
|
266
|
+
}
|
|
267
|
+
if (backpressureTimer) {
|
|
268
|
+
clearInterval(backpressureTimer);
|
|
269
|
+
backpressureTimer = null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
274
|
+
start(controller): void {
|
|
275
|
+
const enqueueRaw = (chunk: string): void => {
|
|
276
|
+
if (closed) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const bytes = encoder.encode(chunk);
|
|
281
|
+
controller.enqueue(bytes);
|
|
282
|
+
queuedBytes += bytes.byteLength;
|
|
283
|
+
pendingChunkSizes.push(bytes.byteLength);
|
|
284
|
+
} catch {
|
|
285
|
+
// Controller closed; cleanup happens via cancel.
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const flushPendingDelta = (): void => {
|
|
290
|
+
if (!pendingDelta || closed) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const delta = pendingDelta;
|
|
294
|
+
pendingDelta = null;
|
|
295
|
+
enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta: delta.snapshotDelta }, delta.id));
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const closeWithError = (reason: string): void => {
|
|
299
|
+
if (closed) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
closed = true;
|
|
303
|
+
try {
|
|
304
|
+
const errorFrame = formatSseEvent("stream_error", { code: "backpressure", reason });
|
|
305
|
+
controller.enqueue(encoder.encode(errorFrame));
|
|
306
|
+
} catch {
|
|
307
|
+
// Already closed.
|
|
308
|
+
}
|
|
309
|
+
cleanupTimers();
|
|
310
|
+
try {
|
|
311
|
+
controller.close();
|
|
312
|
+
} catch {
|
|
313
|
+
// Already closed.
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const handleSnapshotDelta = (id: number, snapshotDelta: Record<string, unknown>): void => {
|
|
318
|
+
if (closed) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (queuedBytes >= SSE_MAX_QUEUED_BYTES) {
|
|
322
|
+
closeWithError("queued bytes exceeded 1MB hard limit");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
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 };
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Fast path: flush any coalesced delta first to preserve ordering,
|
|
333
|
+
// then enqueue the new one.
|
|
334
|
+
if (pendingDelta) {
|
|
335
|
+
flushPendingDelta();
|
|
336
|
+
}
|
|
337
|
+
enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta }, id));
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Initial snapshot so late-joining tabs converge immediately.
|
|
341
|
+
enqueueRaw(formatSseEvent("snapshot", { snapshot: initialSnapshot }));
|
|
342
|
+
|
|
343
|
+
unsubscribe = eventBus.subscribe((event) => {
|
|
344
|
+
if (event.type === "snapshotDelta") {
|
|
345
|
+
handleSnapshotDelta(event.id, event.snapshotDelta);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Heartbeats keep proxies and stale-connection detectors happy.
|
|
350
|
+
heartbeatTimer = setInterval(() => {
|
|
351
|
+
enqueueRaw(": heartbeat\n\n");
|
|
352
|
+
}, 15000);
|
|
353
|
+
|
|
354
|
+
// 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.
|
|
357
|
+
backpressureTimer = setInterval(() => {
|
|
358
|
+
if (closed) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
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;
|
|
366
|
+
if (stalled) {
|
|
367
|
+
closeWithError(`no consumer pull within ${SSE_STALL_MS}ms`);
|
|
368
|
+
}
|
|
369
|
+
}, SSE_BACKPRESSURE_CHECK_MS);
|
|
370
|
+
|
|
371
|
+
const onAbort = (): void => {
|
|
372
|
+
if (closed) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
closed = true;
|
|
376
|
+
cleanupTimers();
|
|
377
|
+
try {
|
|
378
|
+
controller.close();
|
|
379
|
+
} catch {
|
|
380
|
+
// Already closed.
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
if (request.signal.aborted) {
|
|
385
|
+
onAbort();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
request.signal.addEventListener("abort", onAbort);
|
|
389
|
+
},
|
|
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
|
+
}
|
|
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.
|
|
401
|
+
},
|
|
402
|
+
cancel(): void {
|
|
403
|
+
closed = true;
|
|
404
|
+
cleanupTimers();
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return new Response(stream, {
|
|
409
|
+
status: 200,
|
|
410
|
+
headers: {
|
|
411
|
+
"cache-control": "no-store",
|
|
412
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
413
|
+
"x-accel-buffering": "no",
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
162
418
|
function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
|
|
163
419
|
return jsonResponse(status, {
|
|
164
420
|
ok: true,
|
|
@@ -301,6 +557,61 @@ function readOptionalNullableString(body: Record<string, unknown>, field: string
|
|
|
301
557
|
return value;
|
|
302
558
|
}
|
|
303
559
|
|
|
560
|
+
function parseIfMatchHeader(request: Request): number | null {
|
|
561
|
+
const raw = request.headers.get("if-match");
|
|
562
|
+
if (raw === null) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// RFC 7232 §3.1 allows a strong ETag (`"<value>"`) or a weak ETag
|
|
567
|
+
// (`W/"<value>"`). Trekoon does not differentiate strong from weak
|
|
568
|
+
// semantics — a millisecond updatedAt is exact either way — so we
|
|
569
|
+
// accept both shapes. The wildcard `*` is intentionally NOT supported:
|
|
570
|
+
// it would mean "any current representation matches" and would defeat
|
|
571
|
+
// the whole purpose of the optimistic-concurrency check, so we surface
|
|
572
|
+
// it as 400 invalid_input below rather than treating it as a no-op.
|
|
573
|
+
const stripped = raw.trim().replace(/^W\//iu, "");
|
|
574
|
+
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)) {
|
|
581
|
+
throw new DomainError({
|
|
582
|
+
code: "invalid_input",
|
|
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",
|
|
585
|
+
details: { header: "If-Match", value: raw },
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return parsed;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
interface PreconditionFailedDetails {
|
|
593
|
+
readonly entityKind: "epic" | "task" | "subtask";
|
|
594
|
+
readonly entityId: string;
|
|
595
|
+
readonly currentUpdatedAt: number;
|
|
596
|
+
readonly providedUpdatedAt: 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 updatedAt",
|
|
605
|
+
details: {
|
|
606
|
+
entityKind: details.entityKind,
|
|
607
|
+
entityId: details.entityId,
|
|
608
|
+
currentUpdatedAt: details.currentUpdatedAt,
|
|
609
|
+
providedUpdatedAt: details.providedUpdatedAt,
|
|
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) {
|
|
@@ -320,6 +631,15 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
320
631
|
const url = new URL(request.url);
|
|
321
632
|
const requestLabel = `${request.method} ${url.pathname}`;
|
|
322
633
|
const requestToken = extractToken(request, url);
|
|
634
|
+
// Plain `!==` instead of a constant-time compare is a deliberate choice
|
|
635
|
+
// (System Hardening 0.4.2, finding 32). The board server only binds to
|
|
636
|
+
// 127.0.0.1, the session token is a 256-bit cryptographically-random
|
|
637
|
+
// value rotated per board-server lifetime, and the comparison happens
|
|
638
|
+
// against an in-memory string — there is no remote-timing side-channel
|
|
639
|
+
// realistic enough to attack. Adopting `crypto.timingSafeEqual` would
|
|
640
|
+
// also require handling length-mismatch as a separate non-leaking case.
|
|
641
|
+
// Re-evaluate this decision if the board server ever listens on a
|
|
642
|
+
// non-loopback interface or uses a low-entropy / static token.
|
|
323
643
|
if (requestToken !== context.token) {
|
|
324
644
|
return jsonResponse(401, {
|
|
325
645
|
ok: false,
|
|
@@ -332,6 +652,27 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
332
652
|
|
|
333
653
|
const domain = new TrackerDomain(context.db);
|
|
334
654
|
const mutations = new MutationService(context.db, context.cwd);
|
|
655
|
+
const eventBus = context.eventBus;
|
|
656
|
+
|
|
657
|
+
const respondWithMutation = (
|
|
658
|
+
domainArg: TrackerDomain,
|
|
659
|
+
data: Record<string, unknown>,
|
|
660
|
+
status = 200,
|
|
661
|
+
): Response => {
|
|
662
|
+
publishSnapshotDeltaIfPresent(eventBus, data);
|
|
663
|
+
return buildMutationResponse(domainArg, data, status);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const respondWithMutationDelta = (
|
|
667
|
+
domainArg: TrackerDomain,
|
|
668
|
+
data: Record<string, unknown>,
|
|
669
|
+
selection: SnapshotDeltaSelection,
|
|
670
|
+
status = 200,
|
|
671
|
+
): Response => {
|
|
672
|
+
const enrichedData = { ...data, snapshotDelta: buildSnapshotDelta(domainArg, selection) };
|
|
673
|
+
publishSnapshotDeltaIfPresent(eventBus, enrichedData);
|
|
674
|
+
return buildMutationResponse(domainArg, enrichedData, status);
|
|
675
|
+
};
|
|
335
676
|
|
|
336
677
|
try {
|
|
337
678
|
if (request.method === "GET" && url.pathname === "/api/snapshot") {
|
|
@@ -343,15 +684,26 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
343
684
|
});
|
|
344
685
|
}
|
|
345
686
|
|
|
687
|
+
if (request.method === "GET" && url.pathname === "/api/snapshot/stream") {
|
|
688
|
+
return openSnapshotStream(request, domain, eventBus);
|
|
689
|
+
}
|
|
690
|
+
|
|
346
691
|
const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
|
|
347
692
|
if (epicCascadeMatch) {
|
|
693
|
+
const epicId = epicCascadeMatch[1] ?? "";
|
|
348
694
|
const body = await parseJsonBody(request);
|
|
349
695
|
const status = readRequiredString(body, "status");
|
|
350
|
-
const
|
|
351
|
-
|
|
696
|
+
const ifMatch = parseIfMatchHeader(request);
|
|
697
|
+
// CAS path: precondition is enforced inside the write transaction
|
|
698
|
+
// (see PreconditionFailedError catch below). Missing-header path
|
|
699
|
+
// preserves back-compat with clients that don't send If-Match.
|
|
700
|
+
const plan = ifMatch !== null
|
|
701
|
+
? mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status)
|
|
702
|
+
: mutations.updateEpicStatusCascade(epicId, status);
|
|
703
|
+
return respondWithMutationDelta(domain, {
|
|
352
704
|
plan,
|
|
353
705
|
}, {
|
|
354
|
-
epicIds: [
|
|
706
|
+
epicIds: [epicId],
|
|
355
707
|
taskIds: plan.orderedChanges.filter((change) => change.kind === "task").map((change) => change.id),
|
|
356
708
|
subtaskIds: plan.orderedChanges.filter((change) => change.kind === "subtask").map((change) => change.id),
|
|
357
709
|
});
|
|
@@ -359,38 +711,53 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
359
711
|
|
|
360
712
|
const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
|
|
361
713
|
if (epicMatch) {
|
|
714
|
+
const epicId = epicMatch[1] ?? "";
|
|
362
715
|
const body = await parseJsonBody(request);
|
|
363
|
-
const
|
|
716
|
+
const ifMatch = parseIfMatchHeader(request);
|
|
717
|
+
const epicInput = {
|
|
364
718
|
title: readOptionalString(body, "title"),
|
|
365
719
|
description: readOptionalString(body, "description"),
|
|
366
720
|
status: readOptionalString(body, "status"),
|
|
367
|
-
}
|
|
368
|
-
|
|
721
|
+
};
|
|
722
|
+
const epic = ifMatch !== null
|
|
723
|
+
? mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput)
|
|
724
|
+
: mutations.updateEpic(epicId, epicInput);
|
|
725
|
+
return respondWithMutationDelta(domain, { epic }, { epicIds: [epic.id] });
|
|
369
726
|
}
|
|
370
727
|
|
|
371
728
|
const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
|
|
372
729
|
if (taskMatch) {
|
|
730
|
+
const taskId = taskMatch[1] ?? "";
|
|
373
731
|
const body = await parseJsonBody(request);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
732
|
+
const ifMatch = parseIfMatchHeader(request);
|
|
733
|
+
const taskInput = {
|
|
734
|
+
title: readOptionalString(body, "title"),
|
|
735
|
+
description: readOptionalString(body, "description"),
|
|
736
|
+
status: readOptionalString(body, "status"),
|
|
737
|
+
owner: readOptionalNullableString(body, "owner"),
|
|
738
|
+
};
|
|
739
|
+
const task = ifMatch !== null
|
|
740
|
+
? mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput)
|
|
741
|
+
: mutations.updateTask(taskId, taskInput);
|
|
742
|
+
return respondWithMutationDelta(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
|
|
381
743
|
}
|
|
382
744
|
|
|
383
745
|
const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
384
746
|
if (subtaskMatch) {
|
|
747
|
+
const subtaskId = subtaskMatch[1] ?? "";
|
|
385
748
|
const body = await parseJsonBody(request);
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
749
|
+
const ifMatch = parseIfMatchHeader(request);
|
|
750
|
+
const subtaskInput = {
|
|
751
|
+
title: readOptionalString(body, "title"),
|
|
752
|
+
description: readOptionalString(body, "description"),
|
|
753
|
+
status: readOptionalString(body, "status"),
|
|
754
|
+
owner: readOptionalNullableString(body, "owner"),
|
|
755
|
+
};
|
|
756
|
+
const subtask = ifMatch !== null
|
|
757
|
+
? mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput)
|
|
758
|
+
: mutations.updateSubtask(subtaskId, subtaskInput);
|
|
392
759
|
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
393
|
-
return
|
|
760
|
+
return respondWithMutationDelta(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
|
|
394
761
|
}
|
|
395
762
|
|
|
396
763
|
if (request.method === "POST" && url.pathname === "/api/subtasks") {
|
|
@@ -413,7 +780,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
413
780
|
subtaskIds: [subtask.id],
|
|
414
781
|
}),
|
|
415
782
|
};
|
|
416
|
-
return
|
|
783
|
+
return respondWithMutation(domain, responseData, 201);
|
|
417
784
|
}
|
|
418
785
|
|
|
419
786
|
const result = mutations.createSubtaskAtomicallyWithIdempotency({
|
|
@@ -442,7 +809,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
442
809
|
const replaySubtaskId = readRecordId(result.responseData.subtask);
|
|
443
810
|
const replayTaskId = replaySubtaskId ? domain.getSubtask(replaySubtaskId)?.taskId ?? null : null;
|
|
444
811
|
const replayEpicId = replayTaskId ? domain.getTask(replayTaskId)?.epicId ?? null : null;
|
|
445
|
-
return
|
|
812
|
+
return respondWithMutation(domain, result.state === "replay"
|
|
446
813
|
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
447
814
|
epicIds: replayEpicId ? [replayEpicId] : [],
|
|
448
815
|
taskIds: replayTaskId ? [replayTaskId] : [],
|
|
@@ -470,7 +837,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
470
837
|
deletedDependencyIds,
|
|
471
838
|
}),
|
|
472
839
|
};
|
|
473
|
-
return
|
|
840
|
+
return respondWithMutation(domain, responseData, 200);
|
|
474
841
|
}
|
|
475
842
|
|
|
476
843
|
const result = mutations.deleteSubtaskAtomicallyWithIdempotency({
|
|
@@ -493,7 +860,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
493
860
|
}),
|
|
494
861
|
});
|
|
495
862
|
const replaySnapshotDelta = readSnapshotDelta(result.responseData);
|
|
496
|
-
return
|
|
863
|
+
return respondWithMutation(domain, result.state === "replay"
|
|
497
864
|
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
498
865
|
epicIds: readRecordIds(replaySnapshotDelta?.epics),
|
|
499
866
|
taskIds: readRecordIds(replaySnapshotDelta?.tasks),
|
|
@@ -519,7 +886,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
519
886
|
dependencyIds: [dependency.id],
|
|
520
887
|
}),
|
|
521
888
|
};
|
|
522
|
-
return
|
|
889
|
+
return respondWithMutation(domain, responseData, 201);
|
|
523
890
|
}
|
|
524
891
|
|
|
525
892
|
const result = mutations.addDependencyAtomicallyWithIdempotency({
|
|
@@ -563,7 +930,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
563
930
|
dependencyIds: replayDependencyId ? [replayDependencyId] : [],
|
|
564
931
|
}
|
|
565
932
|
: { dependencyIds: [] };
|
|
566
|
-
return
|
|
933
|
+
return respondWithMutation(domain, result.state === "replay"
|
|
567
934
|
? withFreshReplaySnapshotDelta(domain, result.responseData, replaySelection)
|
|
568
935
|
: result.responseData, result.status);
|
|
569
936
|
}
|
|
@@ -598,7 +965,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
598
965
|
deletedDependencyIds: existingDependencyIds,
|
|
599
966
|
}),
|
|
600
967
|
};
|
|
601
|
-
return
|
|
968
|
+
return respondWithMutation(domain, responseData, 200);
|
|
602
969
|
}
|
|
603
970
|
|
|
604
971
|
const result = mutations.removeDependencyAtomicallyWithIdempotency({
|
|
@@ -622,7 +989,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
622
989
|
}),
|
|
623
990
|
});
|
|
624
991
|
const replaySnapshotDelta = readSnapshotDelta(result.responseData);
|
|
625
|
-
return
|
|
992
|
+
return respondWithMutation(domain, result.state === "replay"
|
|
626
993
|
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
627
994
|
taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
|
|
628
995
|
subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
|
|
@@ -639,6 +1006,18 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
639
1006
|
},
|
|
640
1007
|
});
|
|
641
1008
|
} catch (error: unknown) {
|
|
1009
|
+
// PreconditionFailedError is a typed signal from the *WithIfMatch
|
|
1010
|
+
// CAS variants. It carries the freshly-fetched currentUpdatedAt so
|
|
1011
|
+
// the 409 payload is always consistent with the post-rollback state.
|
|
1012
|
+
if (error instanceof PreconditionFailedError) {
|
|
1013
|
+
return preconditionFailedResponse({
|
|
1014
|
+
entityKind: error.entityKind,
|
|
1015
|
+
entityId: error.entityId,
|
|
1016
|
+
currentUpdatedAt: error.currentUpdatedAt,
|
|
1017
|
+
providedUpdatedAt: error.providedUpdatedAt,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
642
1021
|
const routeError = describeBoardError(mutations, error, requestLabel);
|
|
643
1022
|
return jsonResponse(routeError.status, {
|
|
644
1023
|
ok: false,
|