trekoon 0.3.6 → 0.3.8
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 +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +19 -25
- package/src/board/assets/components/Notice.js +18 -4
- package/src/board/assets/state/actions.js +6 -6
- package/src/board/assets/state/api.js +155 -31
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +123 -30
- package/src/board/routes.ts +397 -54
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +205 -173
- package/src/commands/board.ts +1 -1
- package/src/commands/events.ts +17 -11
- package/src/commands/quickstart.ts +10 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +452 -54
- package/src/domain/tracker-domain.ts +185 -7
- package/src/storage/migrations.ts +123 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +18 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +47 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +759 -151
package/src/board/routes.ts
CHANGED
|
@@ -4,7 +4,16 @@ import { safeErrorMessage } from "../commands/error-utils";
|
|
|
4
4
|
import { MutationService } from "../domain/mutation-service";
|
|
5
5
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
6
6
|
import { DomainError } from "../domain/types";
|
|
7
|
-
import { buildBoardSnapshot } from "./snapshot";
|
|
7
|
+
import { buildBoardSnapshot, buildBoardSnapshotDelta } from "./snapshot";
|
|
8
|
+
|
|
9
|
+
interface SnapshotDeltaSelection {
|
|
10
|
+
readonly epicIds?: readonly string[];
|
|
11
|
+
readonly taskIds?: readonly string[];
|
|
12
|
+
readonly subtaskIds?: readonly string[];
|
|
13
|
+
readonly dependencyIds?: readonly string[];
|
|
14
|
+
readonly deletedSubtaskIds?: readonly string[];
|
|
15
|
+
readonly deletedDependencyIds?: readonly string[];
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
interface BoardRouteContext {
|
|
10
19
|
readonly db: Database;
|
|
@@ -19,6 +28,31 @@ interface BoardRouteError {
|
|
|
19
28
|
readonly details?: Record<string, unknown>;
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
function buildCreateSubtaskFingerprint(body: Record<string, unknown>): string {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
taskId: readRequiredString(body, "taskId"),
|
|
34
|
+
title: readRequiredString(body, "title"),
|
|
35
|
+
description: readOptionalString(body, "description") ?? null,
|
|
36
|
+
status: readOptionalString(body, "status") ?? null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildCreateDependencyFingerprint(sourceId: string, dependsOnId: string): string {
|
|
41
|
+
return JSON.stringify({ sourceId, dependsOnId });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildDeleteSubtaskFingerprint(subtaskId: string): string {
|
|
45
|
+
return JSON.stringify({ subtaskId });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildDeleteDependencyFingerprint(sourceId: string, dependsOnId: string): string {
|
|
49
|
+
return JSON.stringify({ sourceId, dependsOnId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compactIds(ids: readonly string[]): string[] {
|
|
53
|
+
return ids.filter((id) => id.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
function jsonResponse(status: number, data: unknown): Response {
|
|
23
57
|
return new Response(JSON.stringify(data), {
|
|
24
58
|
status,
|
|
@@ -29,6 +63,25 @@ function jsonResponse(status: number, data: unknown): Response {
|
|
|
29
63
|
});
|
|
30
64
|
}
|
|
31
65
|
|
|
66
|
+
function readCookieToken(request: Request): string | null {
|
|
67
|
+
const rawCookie = request.headers.get("cookie");
|
|
68
|
+
if (!rawCookie) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const part of rawCookie.split(";")) {
|
|
73
|
+
const [name, ...valueParts] = part.split("=");
|
|
74
|
+
if (name?.trim() !== "trekoon_board_session") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const value = valueParts.join("=").trim();
|
|
79
|
+
return value.length > 0 ? decodeURIComponent(value) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
function extractToken(request: Request, url: URL): string | null {
|
|
33
86
|
const authorization: string | null = request.headers.get("authorization");
|
|
34
87
|
if (authorization?.startsWith("Bearer ")) {
|
|
@@ -45,7 +98,7 @@ function extractToken(request: Request, url: URL): string | null {
|
|
|
45
98
|
return queryToken.trim();
|
|
46
99
|
}
|
|
47
100
|
|
|
48
|
-
return
|
|
101
|
+
return readCookieToken(request);
|
|
49
102
|
}
|
|
50
103
|
|
|
51
104
|
function isSqliteBusyMessage(message: string): boolean {
|
|
@@ -106,16 +159,68 @@ function describeBoardError(mutations: MutationService, error: unknown, requestL
|
|
|
106
159
|
};
|
|
107
160
|
}
|
|
108
161
|
|
|
109
|
-
function buildMutationResponse(
|
|
162
|
+
function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unknown>, status = 200): Response {
|
|
110
163
|
return jsonResponse(status, {
|
|
111
164
|
ok: true,
|
|
112
|
-
data
|
|
113
|
-
...data,
|
|
114
|
-
snapshot: buildBoardSnapshot(domain),
|
|
115
|
-
},
|
|
165
|
+
data,
|
|
116
166
|
});
|
|
117
167
|
}
|
|
118
168
|
|
|
169
|
+
const buildSnapshotDelta = buildBoardSnapshotDelta;
|
|
170
|
+
|
|
171
|
+
function buildMutationDeltaResponse(
|
|
172
|
+
domain: TrackerDomain,
|
|
173
|
+
data: Record<string, unknown>,
|
|
174
|
+
selection: SnapshotDeltaSelection,
|
|
175
|
+
status = 200,
|
|
176
|
+
): Response {
|
|
177
|
+
return buildMutationResponse(domain, {
|
|
178
|
+
...data,
|
|
179
|
+
snapshotDelta: buildSnapshotDelta(domain, selection),
|
|
180
|
+
}, status);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readRecordId(value: unknown): string | null {
|
|
184
|
+
if (!value || typeof value !== "object") {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const id = (value as { id?: unknown }).id;
|
|
189
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function withFreshReplaySnapshotDelta(
|
|
193
|
+
domain: TrackerDomain,
|
|
194
|
+
responseData: Record<string, unknown>,
|
|
195
|
+
selection: SnapshotDeltaSelection,
|
|
196
|
+
): Record<string, unknown> {
|
|
197
|
+
return {
|
|
198
|
+
...responseData,
|
|
199
|
+
snapshotDelta: buildSnapshotDelta(domain, selection),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readSnapshotDelta(responseData: Record<string, unknown>): Record<string, unknown> | null {
|
|
204
|
+
const snapshotDelta = responseData.snapshotDelta;
|
|
205
|
+
return snapshotDelta && typeof snapshotDelta === "object" ? snapshotDelta as Record<string, unknown> : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function readRecordIds(value: unknown): string[] {
|
|
209
|
+
if (!Array.isArray(value)) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return value.map((item) => readRecordId(item)).filter((id): id is string => id !== null);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function readStringArray(value: unknown): string[] {
|
|
217
|
+
if (!Array.isArray(value)) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
|
222
|
+
}
|
|
223
|
+
|
|
119
224
|
async function parseJsonBody(request: Request): Promise<Record<string, unknown>> {
|
|
120
225
|
const contentType: string = request.headers.get("content-type") ?? "";
|
|
121
226
|
if (!contentType.includes("application/json")) {
|
|
@@ -175,6 +280,41 @@ function readRequiredString(body: Record<string, unknown>, field: string): strin
|
|
|
175
280
|
return value;
|
|
176
281
|
}
|
|
177
282
|
|
|
283
|
+
function readOptionalNullableString(body: Record<string, unknown>, field: string): string | null | undefined {
|
|
284
|
+
const value = body[field];
|
|
285
|
+
if (value === undefined) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (value === null) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (typeof value !== "string") {
|
|
294
|
+
throw new DomainError({
|
|
295
|
+
code: "invalid_input",
|
|
296
|
+
message: `${field} must be a string or null`,
|
|
297
|
+
details: { field },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
|
|
305
|
+
const headerKey = request.headers.get("x-trekoon-idempotency-key");
|
|
306
|
+
if (typeof headerKey === "string" && headerKey.trim().length > 0) {
|
|
307
|
+
return headerKey.trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const bodyKey = body.clientRequestId;
|
|
311
|
+
if (typeof bodyKey === "string" && bodyKey.trim().length > 0) {
|
|
312
|
+
return bodyKey.trim();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
178
318
|
export function createBoardApiHandler(context: BoardRouteContext): (request: Request) => Promise<Response> {
|
|
179
319
|
return async (request: Request): Promise<Response> => {
|
|
180
320
|
const url = new URL(request.url);
|
|
@@ -203,89 +343,292 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
203
343
|
});
|
|
204
344
|
}
|
|
205
345
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
346
|
+
const epicCascadeMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)\/cascade$/u) : null;
|
|
347
|
+
if (epicCascadeMatch) {
|
|
348
|
+
const body = await parseJsonBody(request);
|
|
349
|
+
const status = readRequiredString(body, "status");
|
|
350
|
+
const plan = mutations.updateEpicStatusCascade(epicCascadeMatch[1] ?? "", status);
|
|
351
|
+
return buildMutationDeltaResponse(domain, {
|
|
352
|
+
plan,
|
|
353
|
+
}, {
|
|
354
|
+
epicIds: [epicCascadeMatch[1] ?? ""],
|
|
355
|
+
taskIds: plan.orderedChanges.filter((change) => change.kind === "task").map((change) => change.id),
|
|
356
|
+
subtaskIds: plan.orderedChanges.filter((change) => change.kind === "subtask").map((change) => change.id),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
213
359
|
|
|
214
360
|
const epicMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/epics\/([^/]+)$/u) : null;
|
|
215
|
-
|
|
361
|
+
if (epicMatch) {
|
|
216
362
|
const body = await parseJsonBody(request);
|
|
217
363
|
const epic = mutations.updateEpic(epicMatch[1] ?? "", {
|
|
218
364
|
title: readOptionalString(body, "title"),
|
|
219
365
|
description: readOptionalString(body, "description"),
|
|
220
366
|
status: readOptionalString(body, "status"),
|
|
221
367
|
});
|
|
222
|
-
|
|
223
|
-
|
|
368
|
+
return buildMutationDeltaResponse(domain, { epic }, { epicIds: [epic.id] });
|
|
369
|
+
}
|
|
224
370
|
|
|
225
371
|
const taskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/tasks\/([^/]+)$/u) : null;
|
|
226
372
|
if (taskMatch) {
|
|
227
373
|
const body = await parseJsonBody(request);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
374
|
+
const task = mutations.updateTask(taskMatch[1] ?? "", {
|
|
375
|
+
title: readOptionalString(body, "title"),
|
|
376
|
+
description: readOptionalString(body, "description"),
|
|
377
|
+
status: readOptionalString(body, "status"),
|
|
378
|
+
owner: readOptionalNullableString(body, "owner"),
|
|
379
|
+
});
|
|
380
|
+
return buildMutationDeltaResponse(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
|
|
381
|
+
}
|
|
236
382
|
|
|
237
383
|
const subtaskMatch = request.method === "PATCH" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
238
384
|
if (subtaskMatch) {
|
|
239
385
|
const body = await parseJsonBody(request);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
386
|
+
const subtask = mutations.updateSubtask(subtaskMatch[1] ?? "", {
|
|
387
|
+
title: readOptionalString(body, "title"),
|
|
388
|
+
description: readOptionalString(body, "description"),
|
|
389
|
+
status: readOptionalString(body, "status"),
|
|
390
|
+
owner: readOptionalNullableString(body, "owner"),
|
|
391
|
+
});
|
|
392
|
+
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
393
|
+
return buildMutationDeltaResponse(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
|
|
394
|
+
}
|
|
248
395
|
|
|
249
396
|
if (request.method === "POST" && url.pathname === "/api/subtasks") {
|
|
250
397
|
const body = await parseJsonBody(request);
|
|
251
|
-
const
|
|
398
|
+
const idempotencyKey = readIdempotencyKey(request, body);
|
|
399
|
+
const requestFingerprint = buildCreateSubtaskFingerprint(body);
|
|
400
|
+
if (!idempotencyKey) {
|
|
401
|
+
const subtask = mutations.createSubtask({
|
|
402
|
+
taskId: readRequiredString(body, "taskId"),
|
|
403
|
+
title: readRequiredString(body, "title"),
|
|
404
|
+
description: readOptionalString(body, "description"),
|
|
405
|
+
status: readOptionalString(body, "status"),
|
|
406
|
+
});
|
|
407
|
+
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
408
|
+
const responseData = {
|
|
409
|
+
subtask,
|
|
410
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
411
|
+
epicIds: [task.epicId],
|
|
412
|
+
taskIds: [task.id],
|
|
413
|
+
subtaskIds: [subtask.id],
|
|
414
|
+
}),
|
|
415
|
+
};
|
|
416
|
+
return buildMutationResponse(domain, responseData, 201);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const result = mutations.createSubtaskAtomicallyWithIdempotency({
|
|
252
420
|
taskId: readRequiredString(body, "taskId"),
|
|
253
421
|
title: readRequiredString(body, "title"),
|
|
254
422
|
description: readOptionalString(body, "description"),
|
|
255
423
|
status: readOptionalString(body, "status"),
|
|
424
|
+
claim: {
|
|
425
|
+
scope: "subtask",
|
|
426
|
+
idempotencyKey,
|
|
427
|
+
requestFingerprint,
|
|
428
|
+
conflictMessage: "Idempotency key cannot be reused for a different subtask request",
|
|
429
|
+
},
|
|
430
|
+
buildResponseData: ({ subtask, domain: transactionDomain }) => {
|
|
431
|
+
const task = transactionDomain.getTaskOrThrow(subtask.taskId);
|
|
432
|
+
return {
|
|
433
|
+
subtask,
|
|
434
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
435
|
+
epicIds: [task.epicId],
|
|
436
|
+
taskIds: [task.id],
|
|
437
|
+
subtaskIds: [subtask.id],
|
|
438
|
+
}),
|
|
439
|
+
};
|
|
440
|
+
},
|
|
256
441
|
});
|
|
257
|
-
|
|
442
|
+
const replaySubtaskId = readRecordId(result.responseData.subtask);
|
|
443
|
+
const replayTaskId = replaySubtaskId ? domain.getSubtask(replaySubtaskId)?.taskId ?? null : null;
|
|
444
|
+
const replayEpicId = replayTaskId ? domain.getTask(replayTaskId)?.epicId ?? null : null;
|
|
445
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
446
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
447
|
+
epicIds: replayEpicId ? [replayEpicId] : [],
|
|
448
|
+
taskIds: replayTaskId ? [replayTaskId] : [],
|
|
449
|
+
subtaskIds: replaySubtaskId ? [replaySubtaskId] : [],
|
|
450
|
+
})
|
|
451
|
+
: result.responseData, result.status);
|
|
258
452
|
}
|
|
259
453
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
454
|
+
const deleteSubtaskMatch = request.method === "DELETE" ? url.pathname.match(/^\/api\/subtasks\/([^/]+)$/u) : null;
|
|
455
|
+
if (deleteSubtaskMatch) {
|
|
456
|
+
const subtaskId = deleteSubtaskMatch[1] ?? "";
|
|
457
|
+
const idempotencyKey = request.headers.get("x-trekoon-idempotency-key")?.trim() || null;
|
|
458
|
+
const requestFingerprint = buildDeleteSubtaskFingerprint(subtaskId);
|
|
459
|
+
if (!idempotencyKey) {
|
|
460
|
+
const existingSubtask = domain.getSubtaskOrThrow(subtaskId);
|
|
461
|
+
const task = domain.getTaskOrThrow(existingSubtask.taskId);
|
|
462
|
+
const { deletedDependencyIds } = mutations.deleteSubtask(subtaskId);
|
|
463
|
+
const responseData = {
|
|
464
|
+
subtaskId,
|
|
465
|
+
deleted: true,
|
|
466
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
467
|
+
epicIds: [task.epicId],
|
|
468
|
+
taskIds: [task.id],
|
|
469
|
+
deletedSubtaskIds: [subtaskId],
|
|
470
|
+
deletedDependencyIds,
|
|
471
|
+
}),
|
|
472
|
+
};
|
|
473
|
+
return buildMutationResponse(domain, responseData, 200);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const result = mutations.deleteSubtaskAtomicallyWithIdempotency({
|
|
477
|
+
id: subtaskId,
|
|
478
|
+
claim: {
|
|
479
|
+
scope: "deleted_subtask",
|
|
480
|
+
idempotencyKey,
|
|
481
|
+
requestFingerprint,
|
|
482
|
+
conflictMessage: "Idempotency key cannot be reused for a different subtask delete request",
|
|
483
|
+
},
|
|
484
|
+
buildResponseData: ({ subtaskId: deletedSubtaskId, deletedDependencyIds, domain: transactionDomain, taskId, epicId }) => ({
|
|
485
|
+
subtaskId: deletedSubtaskId,
|
|
486
|
+
deleted: true,
|
|
487
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
488
|
+
epicIds: [epicId],
|
|
489
|
+
taskIds: [taskId],
|
|
490
|
+
deletedSubtaskIds: [deletedSubtaskId],
|
|
491
|
+
deletedDependencyIds,
|
|
492
|
+
}),
|
|
493
|
+
}),
|
|
494
|
+
});
|
|
495
|
+
const replaySnapshotDelta = readSnapshotDelta(result.responseData);
|
|
496
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
497
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
498
|
+
epicIds: readRecordIds(replaySnapshotDelta?.epics),
|
|
499
|
+
taskIds: readRecordIds(replaySnapshotDelta?.tasks),
|
|
500
|
+
deletedSubtaskIds: readStringArray(replaySnapshotDelta?.deletedSubtaskIds),
|
|
501
|
+
deletedDependencyIds: readStringArray(replaySnapshotDelta?.deletedDependencyIds),
|
|
502
|
+
})
|
|
503
|
+
: result.responseData, result.status);
|
|
504
|
+
}
|
|
266
505
|
|
|
267
506
|
if (request.method === "POST" && url.pathname === "/api/dependencies") {
|
|
268
507
|
const body = await parseJsonBody(request);
|
|
269
|
-
const
|
|
270
|
-
|
|
508
|
+
const sourceId = readRequiredString(body, "sourceId");
|
|
509
|
+
const dependsOnId = readRequiredString(body, "dependsOnId");
|
|
510
|
+
const idempotencyKey = readIdempotencyKey(request, body);
|
|
511
|
+
const requestFingerprint = buildCreateDependencyFingerprint(sourceId, dependsOnId);
|
|
512
|
+
if (!idempotencyKey) {
|
|
513
|
+
const dependency = mutations.addDependency(sourceId, dependsOnId);
|
|
514
|
+
const responseData = {
|
|
515
|
+
dependency,
|
|
516
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
517
|
+
taskIds: compactIds([dependency.sourceKind === "task" ? dependency.sourceId : "", dependency.dependsOnKind === "task" ? dependency.dependsOnId : ""]),
|
|
518
|
+
subtaskIds: compactIds([dependency.sourceKind === "subtask" ? dependency.sourceId : "", dependency.dependsOnKind === "subtask" ? dependency.dependsOnId : ""]),
|
|
519
|
+
dependencyIds: [dependency.id],
|
|
520
|
+
}),
|
|
521
|
+
};
|
|
522
|
+
return buildMutationResponse(domain, responseData, 201);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const result = mutations.addDependencyAtomicallyWithIdempotency({
|
|
526
|
+
sourceId,
|
|
527
|
+
dependsOnId,
|
|
528
|
+
claim: {
|
|
529
|
+
scope: "dependency",
|
|
530
|
+
idempotencyKey,
|
|
531
|
+
requestFingerprint,
|
|
532
|
+
conflictMessage: "Idempotency key cannot be reused for a different dependency request",
|
|
533
|
+
},
|
|
534
|
+
buildResponseData: ({ dependency, domain: transactionDomain }) => ({
|
|
535
|
+
dependency,
|
|
536
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
537
|
+
taskIds: compactIds([dependency.sourceKind === "task" ? dependency.sourceId : "", dependency.dependsOnKind === "task" ? dependency.dependsOnId : ""]),
|
|
538
|
+
subtaskIds: compactIds([dependency.sourceKind === "subtask" ? dependency.sourceId : "", dependency.dependsOnKind === "subtask" ? dependency.dependsOnId : ""]),
|
|
539
|
+
dependencyIds: [dependency.id],
|
|
540
|
+
}),
|
|
541
|
+
}),
|
|
542
|
+
});
|
|
543
|
+
const replayDependency = result.responseData.dependency;
|
|
544
|
+
const replayDependencyId = readRecordId(replayDependency);
|
|
545
|
+
const replaySelection = replayDependency && typeof replayDependency === "object"
|
|
546
|
+
? {
|
|
547
|
+
taskIds: compactIds([
|
|
548
|
+
(replayDependency as { sourceKind?: unknown; sourceId?: unknown }).sourceKind === "task"
|
|
549
|
+
? ((replayDependency as { sourceId?: unknown }).sourceId as string ?? "")
|
|
550
|
+
: "",
|
|
551
|
+
(replayDependency as { dependsOnKind?: unknown; dependsOnId?: unknown }).dependsOnKind === "task"
|
|
552
|
+
? ((replayDependency as { dependsOnId?: unknown }).dependsOnId as string ?? "")
|
|
553
|
+
: "",
|
|
554
|
+
]),
|
|
555
|
+
subtaskIds: compactIds([
|
|
556
|
+
(replayDependency as { sourceKind?: unknown; sourceId?: unknown }).sourceKind === "subtask"
|
|
557
|
+
? ((replayDependency as { sourceId?: unknown }).sourceId as string ?? "")
|
|
558
|
+
: "",
|
|
559
|
+
(replayDependency as { dependsOnKind?: unknown; dependsOnId?: unknown }).dependsOnKind === "subtask"
|
|
560
|
+
? ((replayDependency as { dependsOnId?: unknown }).dependsOnId as string ?? "")
|
|
561
|
+
: "",
|
|
562
|
+
]),
|
|
563
|
+
dependencyIds: replayDependencyId ? [replayDependencyId] : [],
|
|
564
|
+
}
|
|
565
|
+
: { dependencyIds: [] };
|
|
566
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
567
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, replaySelection)
|
|
568
|
+
: result.responseData, result.status);
|
|
271
569
|
}
|
|
272
570
|
|
|
273
571
|
if (request.method === "DELETE" && url.pathname === "/api/dependencies") {
|
|
274
572
|
const sourceId = url.searchParams.get("sourceId") ?? "";
|
|
275
573
|
const dependsOnId = url.searchParams.get("dependsOnId") ?? "";
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
574
|
+
const idempotencyKey = request.headers.get("x-trekoon-idempotency-key")?.trim() || null;
|
|
575
|
+
const requestFingerprint = buildDeleteDependencyFingerprint(sourceId, dependsOnId);
|
|
576
|
+
if (!idempotencyKey) {
|
|
577
|
+
const existingDependencyIds = domain.listDependencies(sourceId)
|
|
578
|
+
.filter((dependency) => dependency.dependsOnId === dependsOnId)
|
|
579
|
+
.map((dependency) => dependency.id);
|
|
580
|
+
const removed = mutations.removeDependency(sourceId, dependsOnId);
|
|
581
|
+
if (removed === 0) {
|
|
582
|
+
throw new DomainError({
|
|
583
|
+
code: "not_found",
|
|
584
|
+
message: "Dependency edge not found",
|
|
585
|
+
details: {
|
|
586
|
+
sourceId,
|
|
587
|
+
dependsOnId,
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
const responseData = {
|
|
592
|
+
sourceId,
|
|
593
|
+
dependsOnId,
|
|
594
|
+
removed,
|
|
595
|
+
snapshotDelta: buildSnapshotDelta(domain, {
|
|
596
|
+
taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
|
|
597
|
+
subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
|
|
598
|
+
deletedDependencyIds: existingDependencyIds,
|
|
599
|
+
}),
|
|
600
|
+
};
|
|
601
|
+
return buildMutationResponse(domain, responseData, 200);
|
|
286
602
|
}
|
|
287
603
|
|
|
288
|
-
|
|
604
|
+
const result = mutations.removeDependencyAtomicallyWithIdempotency({
|
|
605
|
+
sourceId,
|
|
606
|
+
dependsOnId,
|
|
607
|
+
claim: {
|
|
608
|
+
scope: "deleted_dependency",
|
|
609
|
+
idempotencyKey,
|
|
610
|
+
requestFingerprint,
|
|
611
|
+
conflictMessage: "Idempotency key cannot be reused for a different dependency delete request",
|
|
612
|
+
},
|
|
613
|
+
buildResponseData: ({ sourceId: deletedSourceId, dependsOnId: deletedDependsOnId, removed, existingDependencyIds, domain: transactionDomain }) => ({
|
|
614
|
+
sourceId: deletedSourceId,
|
|
615
|
+
dependsOnId: deletedDependsOnId,
|
|
616
|
+
removed,
|
|
617
|
+
snapshotDelta: buildSnapshotDelta(transactionDomain, {
|
|
618
|
+
taskIds: compactIds([transactionDomain.getTask(deletedSourceId)?.id ?? "", transactionDomain.getTask(deletedDependsOnId)?.id ?? ""]),
|
|
619
|
+
subtaskIds: compactIds([transactionDomain.getSubtask(deletedSourceId)?.id ?? "", transactionDomain.getSubtask(deletedDependsOnId)?.id ?? ""]),
|
|
620
|
+
deletedDependencyIds: existingDependencyIds,
|
|
621
|
+
}),
|
|
622
|
+
}),
|
|
623
|
+
});
|
|
624
|
+
const replaySnapshotDelta = readSnapshotDelta(result.responseData);
|
|
625
|
+
return buildMutationResponse(domain, result.state === "replay"
|
|
626
|
+
? withFreshReplaySnapshotDelta(domain, result.responseData, {
|
|
627
|
+
taskIds: compactIds([domain.getTask(sourceId)?.id ?? "", domain.getTask(dependsOnId)?.id ?? ""]),
|
|
628
|
+
subtaskIds: compactIds([domain.getSubtask(sourceId)?.id ?? "", domain.getSubtask(dependsOnId)?.id ?? ""]),
|
|
629
|
+
deletedDependencyIds: readStringArray(replaySnapshotDelta?.deletedDependencyIds),
|
|
630
|
+
})
|
|
631
|
+
: result.responseData, result.status);
|
|
289
632
|
}
|
|
290
633
|
|
|
291
634
|
return jsonResponse(404, {
|
package/src/board/server.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, extname, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { createBoardApiHandler } from "./routes";
|
|
6
|
+
import { buildBoardSnapshot } from "./snapshot";
|
|
6
7
|
|
|
7
8
|
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
8
9
|
import { resolveStoragePaths } from "../storage/path";
|
|
10
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
9
11
|
|
|
10
12
|
const CONTENT_TYPES: Record<string, string> = {
|
|
11
13
|
".css": "text/css; charset=utf-8",
|
|
@@ -87,6 +89,37 @@ function isUnavailablePortError(error: unknown): boolean {
|
|
|
87
89
|
return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
function buildBoardSessionCookie(token: string): string {
|
|
93
|
+
return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function serializeInlineJson(value: unknown): string {
|
|
97
|
+
return JSON.stringify(value)
|
|
98
|
+
.replace(/</g, "\\u003c")
|
|
99
|
+
.replace(/>/g, "\\u003e")
|
|
100
|
+
.replace(/&/g, "\\u0026")
|
|
101
|
+
.replace(/\u2028/g, "\\u2028")
|
|
102
|
+
.replace(/\u2029/g, "\\u2029");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
|
|
106
|
+
const domain = new TrackerDomain(database.db);
|
|
107
|
+
return serializeInlineJson({
|
|
108
|
+
token,
|
|
109
|
+
snapshot: buildBoardSnapshot(domain),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function injectBoardBootstrap(html: string, bootstrapJson: string): string {
|
|
114
|
+
const bootstrapTag = `<script id="trekoon-board-bootstrap" type="application/json">${bootstrapJson}</script>`;
|
|
115
|
+
const closingBodyIndex = html.lastIndexOf("</body>");
|
|
116
|
+
if (closingBodyIndex === -1) {
|
|
117
|
+
return `${html}${bootstrapTag}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return `${html.slice(0, closingBodyIndex)}${bootstrapTag}\n${html.slice(closingBodyIndex)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
90
123
|
export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
|
|
91
124
|
const cwd: string = options.cwd ?? process.cwd();
|
|
92
125
|
const database: TrekoonDatabase = openTrekoonDatabase(cwd);
|
|
@@ -110,6 +143,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
110
143
|
return apiHandler(request);
|
|
111
144
|
}
|
|
112
145
|
|
|
146
|
+
const responseHeaders: Record<string, string> = {
|
|
147
|
+
"cache-control": "no-store",
|
|
148
|
+
};
|
|
149
|
+
if ((url.searchParams.get("token") ?? "") === token) {
|
|
150
|
+
responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
|
|
151
|
+
}
|
|
152
|
+
|
|
113
153
|
const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
|
|
114
154
|
if (assetPath === null) {
|
|
115
155
|
const fallbackPath = readAssetPath(boardRoot, "/index.html");
|
|
@@ -117,9 +157,21 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
117
157
|
return new Response("Board assets are not installed", { status: 500 });
|
|
118
158
|
}
|
|
119
159
|
|
|
120
|
-
|
|
160
|
+
const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
|
|
161
|
+
|
|
162
|
+
return new Response(html, {
|
|
163
|
+
headers: {
|
|
164
|
+
...responseHeaders,
|
|
165
|
+
"content-type": "text/html; charset=utf-8",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (assetPath.endsWith("/index.html")) {
|
|
171
|
+
const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
|
|
172
|
+
return new Response(html, {
|
|
121
173
|
headers: {
|
|
122
|
-
|
|
174
|
+
...responseHeaders,
|
|
123
175
|
"content-type": "text/html; charset=utf-8",
|
|
124
176
|
},
|
|
125
177
|
});
|
|
@@ -127,7 +179,7 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
127
179
|
|
|
128
180
|
return new Response(readFileSync(assetPath), {
|
|
129
181
|
headers: {
|
|
130
|
-
|
|
182
|
+
...responseHeaders,
|
|
131
183
|
"content-type": guessContentType(assetPath),
|
|
132
184
|
},
|
|
133
185
|
});
|
|
@@ -169,11 +221,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
169
221
|
|
|
170
222
|
const origin: string = `http://127.0.0.1:${port}`;
|
|
171
223
|
const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
|
|
224
|
+
const fallbackUrl: string = origin;
|
|
172
225
|
|
|
173
226
|
return {
|
|
174
227
|
origin,
|
|
175
228
|
url,
|
|
176
|
-
fallbackUrl
|
|
229
|
+
fallbackUrl,
|
|
177
230
|
token,
|
|
178
231
|
hostname: "127.0.0.1",
|
|
179
232
|
port,
|