trekoon 0.4.1 → 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 -1
- 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 +45 -13
- 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 +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 +42 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +79 -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
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { type Database } from "bun:sqlite";
|
|
2
2
|
|
|
3
3
|
import { writeTransaction } from "../storage/database";
|
|
4
|
-
import { appendEventWithGitContext,
|
|
4
|
+
import { appendEventWithGitContext, withTransactionEventContext } from "../sync/event-writes";
|
|
5
|
+
import { resolveGitContext } from "../sync/git-context";
|
|
5
6
|
import { ENTITY_OPERATIONS } from "./mutation-operations";
|
|
7
|
+
import {
|
|
8
|
+
buildMatchSnippet,
|
|
9
|
+
buildReplacementSnippet,
|
|
10
|
+
countMatches,
|
|
11
|
+
replaceMatches,
|
|
12
|
+
summarizeMatches,
|
|
13
|
+
} from "./search";
|
|
6
14
|
import { TrackerDomain, validateStatusTransition } from "./tracker-domain";
|
|
7
15
|
import {
|
|
8
16
|
type CompactEpicCreateResult,
|
|
@@ -26,6 +34,72 @@ import {
|
|
|
26
34
|
DomainError,
|
|
27
35
|
} from "./types";
|
|
28
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Local mirror of `assertNonEmpty` from tracker-domain (which is
|
|
39
|
+
* file-local). Used by the *WithIfMatch CAS variants which build their
|
|
40
|
+
* UPDATE row directly rather than going through `domain.updateX`, so they
|
|
41
|
+
* must enforce the same non-empty-string contract on caller-provided
|
|
42
|
+
* fields.
|
|
43
|
+
*/
|
|
44
|
+
function assertNonEmptyField(field: string, value: string): string {
|
|
45
|
+
const normalized = value.trim();
|
|
46
|
+
if (!normalized) {
|
|
47
|
+
throw new DomainError({
|
|
48
|
+
code: "invalid_input",
|
|
49
|
+
message: `${field} must be a non-empty string`,
|
|
50
|
+
details: { field },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Local mirror of `normalizeOwner` from tracker-domain. `undefined`
|
|
58
|
+
* preserves the existing owner; `null` or blank-after-trim clears it;
|
|
59
|
+
* any other string is trimmed and stored.
|
|
60
|
+
*/
|
|
61
|
+
function normalizeOwnerInput(owner: string | null | undefined): string | null | undefined {
|
|
62
|
+
if (owner === undefined) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (owner === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const trimmed = owner.trim();
|
|
69
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Thrown by the *WithIfMatch CAS variants when the supplied `If-Match`
|
|
74
|
+
* `updatedAt` does not match the row currently in the database.
|
|
75
|
+
*
|
|
76
|
+
* The error is **not** a `DomainError` so the generic `toBoardRouteError`
|
|
77
|
+
* fall-through doesn't accidentally surface it as a 400 — route handlers
|
|
78
|
+
* catch it explicitly and emit the canonical 409 `precondition_failed`
|
|
79
|
+
* payload (with `currentUpdatedAt` fetched inside the same transaction
|
|
80
|
+
* that observed the mismatch).
|
|
81
|
+
*/
|
|
82
|
+
export class PreconditionFailedError extends Error {
|
|
83
|
+
readonly entityKind: "epic" | "task" | "subtask";
|
|
84
|
+
readonly entityId: string;
|
|
85
|
+
readonly currentUpdatedAt: number;
|
|
86
|
+
readonly providedUpdatedAt: number;
|
|
87
|
+
|
|
88
|
+
constructor(input: {
|
|
89
|
+
entityKind: "epic" | "task" | "subtask";
|
|
90
|
+
entityId: string;
|
|
91
|
+
currentUpdatedAt: number;
|
|
92
|
+
providedUpdatedAt: number;
|
|
93
|
+
}) {
|
|
94
|
+
super("If-Match version does not match current updatedAt");
|
|
95
|
+
this.name = "PreconditionFailedError";
|
|
96
|
+
this.entityKind = input.entityKind;
|
|
97
|
+
this.entityId = input.entityId;
|
|
98
|
+
this.currentUpdatedAt = input.currentUpdatedAt;
|
|
99
|
+
this.providedUpdatedAt = input.providedUpdatedAt;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
29
103
|
interface AtomicIdempotencyClaim {
|
|
30
104
|
readonly scope: "subtask" | "dependency" | "deleted_subtask" | "deleted_dependency";
|
|
31
105
|
readonly idempotencyKey: string;
|
|
@@ -51,66 +125,26 @@ type AtomicIdempotentMutationResult =
|
|
|
51
125
|
|
|
52
126
|
const BOARD_IDEMPOTENCY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
53
127
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
function replaceMatches(value: string, searchText: string, replacement: string): string {
|
|
75
|
-
return searchText.length === 0 ? value : value.split(searchText).join(replacement);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function buildMatchSnippet(value: string, searchText: string, contextSize = 24): string {
|
|
79
|
-
if (searchText.length === 0) {
|
|
80
|
-
return "";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const matchIndex = value.indexOf(searchText);
|
|
84
|
-
if (matchIndex === -1) {
|
|
85
|
-
return "";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const start = Math.max(0, matchIndex - contextSize);
|
|
89
|
-
const end = Math.min(value.length, matchIndex + searchText.length + contextSize);
|
|
90
|
-
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
91
|
-
const prefix = start > 0 ? "…" : "";
|
|
92
|
-
const suffix = end < value.length ? "…" : "";
|
|
93
|
-
return `${prefix}${rawSnippet}${suffix}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function buildReplacementSnippet(value: string, replacementIndex: number, replacementLength: number, contextSize = 24): string {
|
|
97
|
-
const start = Math.max(0, replacementIndex - contextSize);
|
|
98
|
-
const end = Math.min(value.length, replacementIndex + replacementLength + contextSize);
|
|
99
|
-
const rawSnippet = value.slice(start, end).replace(/\s+/g, " ").trim();
|
|
100
|
-
const prefix = start > 0 ? "…" : "";
|
|
101
|
-
const suffix = end < value.length ? "…" : "";
|
|
102
|
-
return `${prefix}${rawSnippet}${suffix}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function summarizeMatches(matches: readonly SearchEntityMatch[]): SearchSummary {
|
|
106
|
-
return {
|
|
107
|
-
matchedEntities: matches.length,
|
|
108
|
-
matchedFields: matches.reduce((total, match) => total + match.fields.length, 0),
|
|
109
|
-
totalMatches: matches.reduce(
|
|
110
|
-
(total, match) => total + match.fields.reduce((fieldTotal, field) => fieldTotal + field.count, 0),
|
|
111
|
-
0,
|
|
112
|
-
),
|
|
113
|
-
};
|
|
128
|
+
// Throttle window for idempotency-key garbage collection. The previous
|
|
129
|
+
// implementation ran a DELETE on every claim which wasted IO under bursts
|
|
130
|
+
// (System Hardening 0.4.2, finding 27). Keys live for
|
|
131
|
+
// BOARD_IDEMPOTENCY_RETENTION_MS (7 days), so a once-per-minute sweep more
|
|
132
|
+
// than keeps up with expiration.
|
|
133
|
+
const BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS = 60 * 1000;
|
|
134
|
+
|
|
135
|
+
// Module-level timestamp guards the prune sweep. Per-process (not
|
|
136
|
+
// per-MutationService) so that a long-lived process making frequent claims
|
|
137
|
+
// does not re-run the DELETE on every transaction. Reset to 0 by tests via
|
|
138
|
+
// `__resetIdempotencyPruneThrottleForTests`.
|
|
139
|
+
let lastIdempotencyPruneAt = 0;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Test hook: resets the module-level prune throttle so a fresh test run
|
|
143
|
+
* always observes the first call as un-throttled. Production code paths
|
|
144
|
+
* never invoke this.
|
|
145
|
+
*/
|
|
146
|
+
export function __resetIdempotencyPruneThrottleForTests(): void {
|
|
147
|
+
lastIdempotencyPruneAt = 0;
|
|
114
148
|
}
|
|
115
149
|
|
|
116
150
|
interface ScopeReplacementResult {
|
|
@@ -130,8 +164,16 @@ export class MutationService {
|
|
|
130
164
|
}
|
|
131
165
|
|
|
132
166
|
#writeTransaction<T>(fn: () => T): T {
|
|
133
|
-
|
|
134
|
-
|
|
167
|
+
// Resolve git context BEFORE acquiring the SQLite write lock. Cold-cache
|
|
168
|
+
// resolution spawns `git branch` / `git rev-parse` subprocesses; doing
|
|
169
|
+
// that inside BEGIN IMMEDIATE would serialize concurrent writers behind
|
|
170
|
+
// git invocations rather than just the lock-promotion itself.
|
|
171
|
+
//
|
|
172
|
+
// withTransactionEventContext still computes the event timestamp lazily
|
|
173
|
+
// AFTER BEGIN IMMEDIATE is issued by writeTransaction so concurrent
|
|
174
|
+
// writers cannot collide on (created_at, id).
|
|
175
|
+
const git = resolveGitContext(this.#cwd);
|
|
176
|
+
return writeTransaction(this.#db, (): T => withTransactionEventContext(this.#db, git, fn));
|
|
135
177
|
}
|
|
136
178
|
|
|
137
179
|
#dependencyEventEntityId(input: {
|
|
@@ -178,11 +220,7 @@ export class MutationService {
|
|
|
178
220
|
createEpic(input: { title: string; description: string; status?: string | undefined }): EpicRecord {
|
|
179
221
|
return this.#writeTransaction((): EpicRecord => {
|
|
180
222
|
const epic = this.#domain.createEpic(input);
|
|
181
|
-
this.#
|
|
182
|
-
title: epic.title,
|
|
183
|
-
description: epic.description,
|
|
184
|
-
status: epic.status,
|
|
185
|
-
});
|
|
223
|
+
this.#emitEpicCreated(epic);
|
|
186
224
|
return epic;
|
|
187
225
|
});
|
|
188
226
|
}
|
|
@@ -204,28 +242,14 @@ export class MutationService {
|
|
|
204
242
|
dependencySpecs: input.dependencySpecs,
|
|
205
243
|
});
|
|
206
244
|
|
|
207
|
-
this.#
|
|
208
|
-
title: epic.title,
|
|
209
|
-
description: epic.description,
|
|
210
|
-
status: epic.status,
|
|
211
|
-
});
|
|
245
|
+
this.#emitEpicCreated(epic);
|
|
212
246
|
|
|
213
247
|
for (const task of created.tasks) {
|
|
214
|
-
this.#
|
|
215
|
-
epic_id: task.epicId,
|
|
216
|
-
title: task.title,
|
|
217
|
-
description: task.description,
|
|
218
|
-
status: task.status,
|
|
219
|
-
});
|
|
248
|
+
this.#emitTaskCreated(task);
|
|
220
249
|
}
|
|
221
250
|
|
|
222
251
|
for (const subtask of created.subtasks) {
|
|
223
|
-
this.#
|
|
224
|
-
task_id: subtask.taskId,
|
|
225
|
-
title: subtask.title,
|
|
226
|
-
description: subtask.description,
|
|
227
|
-
status: subtask.status,
|
|
228
|
-
});
|
|
252
|
+
this.#emitSubtaskCreated(subtask);
|
|
229
253
|
}
|
|
230
254
|
|
|
231
255
|
for (const dependency of created.dependencies) {
|
|
@@ -263,11 +287,83 @@ export class MutationService {
|
|
|
263
287
|
validateStatusTransition(existing.status, input.status, "epic", id);
|
|
264
288
|
}
|
|
265
289
|
const epic = this.#domain.updateEpic(id, input);
|
|
266
|
-
this.#
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
290
|
+
this.#emitEpicUpdated(epic);
|
|
291
|
+
return epic;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Atomic If-Match CAS variant of {@link updateEpic}.
|
|
297
|
+
*
|
|
298
|
+
* The `If-Match` precondition is enforced INSIDE the write transaction
|
|
299
|
+
* via a SQL compare-and-swap (`UPDATE ... WHERE id = ? AND updated_at = ?`).
|
|
300
|
+
* If zero rows are affected we determine whether the row is missing
|
|
301
|
+
* (→ `DomainError(not_found)`) or merely stale (→ {@link PreconditionFailedError}
|
|
302
|
+
* with the freshly-fetched `currentUpdatedAt`).
|
|
303
|
+
*
|
|
304
|
+
* This eliminates the read-check-then-write race the previous route-level
|
|
305
|
+
* check had: a concurrent writer could land between `parseIfMatchHeader`'s
|
|
306
|
+
* read and `mutations.updateEpic`'s BEGIN IMMEDIATE, allowing the second
|
|
307
|
+
* PATCH to silently overwrite the first.
|
|
308
|
+
*
|
|
309
|
+
* Input validation (non-empty / status transition) mirrors
|
|
310
|
+
* `domain.updateEpic` — it runs inside the same transaction so a
|
|
311
|
+
* malformed PATCH never observes the CAS branch.
|
|
312
|
+
*/
|
|
313
|
+
updateEpicWithIfMatch(
|
|
314
|
+
id: string,
|
|
315
|
+
ifMatchUpdatedAt: number,
|
|
316
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined },
|
|
317
|
+
): EpicRecord {
|
|
318
|
+
return this.#writeTransaction((): EpicRecord => {
|
|
319
|
+
// Resolve the current row first inside the tx so we can (1) surface
|
|
320
|
+
// `not_found` before the CAS and (2) materialise the per-field
|
|
321
|
+
// defaults that domain.updateEpic computes from the current row.
|
|
322
|
+
// Holding the BEGIN IMMEDIATE write lock guarantees no other writer
|
|
323
|
+
// mutates the row between this read and the CAS UPDATE.
|
|
324
|
+
const existing = this.#domain.getEpicOrThrow(id);
|
|
325
|
+
|
|
326
|
+
const nextTitle = input.title !== undefined
|
|
327
|
+
? assertNonEmptyField("title", input.title)
|
|
328
|
+
: existing.title;
|
|
329
|
+
const nextDescription = input.description !== undefined
|
|
330
|
+
? assertNonEmptyField("description", input.description)
|
|
331
|
+
: existing.description;
|
|
332
|
+
const nextStatus = input.status !== undefined
|
|
333
|
+
? assertNonEmptyField("status", input.status)
|
|
334
|
+
: existing.status;
|
|
335
|
+
|
|
336
|
+
if (input.status !== undefined) {
|
|
337
|
+
validateStatusTransition(existing.status, nextStatus, "epic", id);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const now: number = Date.now();
|
|
341
|
+
const result = this.#db
|
|
342
|
+
.query(
|
|
343
|
+
`UPDATE epics
|
|
344
|
+
SET title = ?, description = ?, status = ?, updated_at = ?, version = version + 1
|
|
345
|
+
WHERE id = ?
|
|
346
|
+
AND updated_at = ?
|
|
347
|
+
RETURNING id`,
|
|
348
|
+
)
|
|
349
|
+
.get(nextTitle, nextDescription, nextStatus, now, id, ifMatchUpdatedAt) as { id: string } | null;
|
|
350
|
+
|
|
351
|
+
if (result === null) {
|
|
352
|
+
// Zero rows changed. We already proved the row exists via
|
|
353
|
+
// getEpicOrThrow, so the only remaining failure mode is a stale
|
|
354
|
+
// precondition. Re-fetch updatedAt inside the same tx so the
|
|
355
|
+
// caller's 409 carries the freshest value.
|
|
356
|
+
const current = this.#domain.getEpicOrThrow(id);
|
|
357
|
+
throw new PreconditionFailedError({
|
|
358
|
+
entityKind: "epic",
|
|
359
|
+
entityId: id,
|
|
360
|
+
currentUpdatedAt: current.updatedAt,
|
|
361
|
+
providedUpdatedAt: ifMatchUpdatedAt,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const epic = this.#domain.getEpicOrThrow(id);
|
|
366
|
+
this.#emitEpicUpdated(epic);
|
|
271
367
|
return epic;
|
|
272
368
|
});
|
|
273
369
|
}
|
|
@@ -281,22 +377,69 @@ export class MutationService {
|
|
|
281
377
|
});
|
|
282
378
|
}
|
|
283
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Atomic If-Match CAS variant of {@link updateEpicStatusCascade}.
|
|
382
|
+
*
|
|
383
|
+
* Cascades touch many rows so a row-level SQL CAS isn't a natural fit;
|
|
384
|
+
* instead the precondition is enforced inside the same writeTransaction
|
|
385
|
+
* as the cascade plan. The BEGIN IMMEDIATE write lock guarantees no
|
|
386
|
+
* other writer mutates the epic between the precondition read and the
|
|
387
|
+
* cascade application — eliminating the same race the per-row CAS
|
|
388
|
+
* variants close.
|
|
389
|
+
*/
|
|
390
|
+
updateEpicStatusCascadeWithIfMatch(
|
|
391
|
+
id: string,
|
|
392
|
+
ifMatchUpdatedAt: number,
|
|
393
|
+
status: string,
|
|
394
|
+
): StatusCascadePlan {
|
|
395
|
+
return this.#writeTransaction((): StatusCascadePlan => {
|
|
396
|
+
const existing = this.#domain.getEpicOrThrow(id);
|
|
397
|
+
if (existing.updatedAt !== ifMatchUpdatedAt) {
|
|
398
|
+
throw new PreconditionFailedError({
|
|
399
|
+
entityKind: "epic",
|
|
400
|
+
entityId: id,
|
|
401
|
+
currentUpdatedAt: existing.updatedAt,
|
|
402
|
+
providedUpdatedAt: ifMatchUpdatedAt,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
const plan = this.#domain.planStatusCascade("epic", id, status);
|
|
406
|
+
this.#assertCascadeNotBlocked(plan);
|
|
407
|
+
this.#applyStatusCascadePlan(plan);
|
|
408
|
+
return plan;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
284
412
|
deleteEpic(id: string): void {
|
|
285
413
|
this.#writeTransaction((): void => {
|
|
414
|
+
const tasks = this.#domain.listTasks(id);
|
|
415
|
+
const taskIds = tasks.map((task) => task.id);
|
|
416
|
+
const subtasksByTaskId = taskIds.length > 0
|
|
417
|
+
? this.#domain.listSubtasksByTaskIds(taskIds)
|
|
418
|
+
: new Map<string, readonly SubtaskRecord[]>();
|
|
419
|
+
|
|
286
420
|
this.#domain.deleteEpic(id);
|
|
287
|
-
|
|
421
|
+
|
|
422
|
+
const epicDeleteEventId = this.#emitEpicDeleted(id);
|
|
423
|
+
|
|
424
|
+
for (const task of tasks) {
|
|
425
|
+
// Stamp cascaded task.deleted events with the parent epic-delete event
|
|
426
|
+
// id so peer worktrees can suppress the per-task __delete__ conflict
|
|
427
|
+
// when an epic-level conflict is already pending. Without this, a peer
|
|
428
|
+
// with edits on the epic's tasks gets N+1 conflicts (epic + one per
|
|
429
|
+
// task) instead of the single epic-level conflict.
|
|
430
|
+
const taskDeleteEventId = this.#emitTaskDeleted(task.id, { sourceEventId: epicDeleteEventId });
|
|
431
|
+
const subtasks = subtasksByTaskId.get(task.id) ?? [];
|
|
432
|
+
for (const subtask of subtasks) {
|
|
433
|
+
this.#emitSubtaskDeleted(subtask.id, { taskId: task.id, sourceEventId: taskDeleteEventId });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
288
436
|
});
|
|
289
437
|
}
|
|
290
438
|
|
|
291
439
|
createTask(input: { epicId: string; title: string; description: string; status?: string | undefined }): TaskRecord {
|
|
292
440
|
return this.#writeTransaction((): TaskRecord => {
|
|
293
441
|
const task = this.#domain.createTask(input);
|
|
294
|
-
this.#
|
|
295
|
-
epic_id: task.epicId,
|
|
296
|
-
title: task.title,
|
|
297
|
-
description: task.description,
|
|
298
|
-
status: task.status,
|
|
299
|
-
});
|
|
442
|
+
this.#emitTaskCreated(task);
|
|
300
443
|
return task;
|
|
301
444
|
});
|
|
302
445
|
}
|
|
@@ -305,12 +448,7 @@ export class MutationService {
|
|
|
305
448
|
return this.#writeTransaction((): CompactTaskBatchCreateResult => {
|
|
306
449
|
const created = this.#domain.createTaskBatch(input);
|
|
307
450
|
for (const task of created.tasks) {
|
|
308
|
-
this.#
|
|
309
|
-
epic_id: task.epicId,
|
|
310
|
-
title: task.title,
|
|
311
|
-
description: task.description,
|
|
312
|
-
status: task.status,
|
|
313
|
-
});
|
|
451
|
+
this.#emitTaskCreated(task);
|
|
314
452
|
}
|
|
315
453
|
return created;
|
|
316
454
|
});
|
|
@@ -325,21 +463,11 @@ export class MutationService {
|
|
|
325
463
|
return this.#writeTransaction((): CompactEpicExpandResult => {
|
|
326
464
|
const created = this.#domain.expandEpic(input);
|
|
327
465
|
for (const task of created.tasks) {
|
|
328
|
-
this.#
|
|
329
|
-
epic_id: task.epicId,
|
|
330
|
-
title: task.title,
|
|
331
|
-
description: task.description,
|
|
332
|
-
status: task.status,
|
|
333
|
-
});
|
|
466
|
+
this.#emitTaskCreated(task);
|
|
334
467
|
}
|
|
335
468
|
|
|
336
469
|
for (const subtask of created.subtasks) {
|
|
337
|
-
this.#
|
|
338
|
-
task_id: subtask.taskId,
|
|
339
|
-
title: subtask.title,
|
|
340
|
-
description: subtask.description,
|
|
341
|
-
status: subtask.status,
|
|
342
|
-
});
|
|
470
|
+
this.#emitSubtaskCreated(subtask);
|
|
343
471
|
}
|
|
344
472
|
|
|
345
473
|
for (const dependency of created.dependencies) {
|
|
@@ -371,17 +499,433 @@ export class MutationService {
|
|
|
371
499
|
validateStatusTransition(existing.status, input.status, "task", id);
|
|
372
500
|
}
|
|
373
501
|
const task = this.#domain.updateTask(id, input);
|
|
374
|
-
this.#
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
502
|
+
this.#emitTaskUpdated(task);
|
|
503
|
+
return task;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Atomic If-Match CAS variant of {@link updateTask}. See
|
|
509
|
+
* {@link updateEpicWithIfMatch} for the rationale; the key difference is
|
|
510
|
+
* that tasks also enforce dependency-gating via
|
|
511
|
+
* `assertNoUnresolvedDependenciesForStatusTransition` for status
|
|
512
|
+
* transitions and may set/clear `owner`.
|
|
513
|
+
*/
|
|
514
|
+
updateTaskWithIfMatch(
|
|
515
|
+
id: string,
|
|
516
|
+
ifMatchUpdatedAt: number,
|
|
517
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
518
|
+
): TaskRecord {
|
|
519
|
+
return this.#writeTransaction((): TaskRecord => {
|
|
520
|
+
const existing = this.#domain.getTaskOrThrow(id);
|
|
521
|
+
|
|
522
|
+
const nextTitle = input.title !== undefined
|
|
523
|
+
? assertNonEmptyField("title", input.title)
|
|
524
|
+
: existing.title;
|
|
525
|
+
const nextDescription = input.description !== undefined
|
|
526
|
+
? assertNonEmptyField("description", input.description)
|
|
527
|
+
: existing.description;
|
|
528
|
+
const nextStatus = input.status !== undefined
|
|
529
|
+
? assertNonEmptyField("status", input.status)
|
|
530
|
+
: existing.status;
|
|
531
|
+
const normalizedOwner = normalizeOwnerInput(input.owner);
|
|
532
|
+
const nextOwner = normalizedOwner === undefined ? existing.owner : normalizedOwner;
|
|
533
|
+
|
|
534
|
+
if (input.status !== undefined) {
|
|
535
|
+
validateStatusTransition(existing.status, nextStatus, "task", id);
|
|
536
|
+
}
|
|
537
|
+
// Dependency gating mirrors domain.updateTask. Even when the status
|
|
538
|
+
// is unchanged this is a no-op because
|
|
539
|
+
// assertNoUnresolvedDependenciesForStatusTransition short-circuits
|
|
540
|
+
// when from === to.
|
|
541
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(id, "task", existing.status, nextStatus);
|
|
542
|
+
|
|
543
|
+
const now: number = Date.now();
|
|
544
|
+
const result = this.#db
|
|
545
|
+
.query(
|
|
546
|
+
`UPDATE tasks
|
|
547
|
+
SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
|
|
548
|
+
WHERE id = ?
|
|
549
|
+
AND updated_at = ?
|
|
550
|
+
RETURNING id`,
|
|
551
|
+
)
|
|
552
|
+
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchUpdatedAt) as { id: string } | null;
|
|
553
|
+
|
|
554
|
+
if (result === null) {
|
|
555
|
+
const current = this.#domain.getTaskOrThrow(id);
|
|
556
|
+
throw new PreconditionFailedError({
|
|
557
|
+
entityKind: "task",
|
|
558
|
+
entityId: id,
|
|
559
|
+
currentUpdatedAt: current.updatedAt,
|
|
560
|
+
providedUpdatedAt: ifMatchUpdatedAt,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const task = this.#domain.getTaskOrThrow(id);
|
|
565
|
+
this.#emitTaskUpdated(task);
|
|
566
|
+
return task;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Atomically append text to a task's description using a single SQL
|
|
572
|
+
* `description = description || ?` expression inside a write transaction.
|
|
573
|
+
*
|
|
574
|
+
* This eliminates the read-modify-write TOCTOU race that existed when
|
|
575
|
+
* callers read the description, computed the new value in application code,
|
|
576
|
+
* then issued a separate update write. Two concurrent appends targeting
|
|
577
|
+
* the same task will each hold their own BEGIN IMMEDIATE lock in turn and
|
|
578
|
+
* see the other's text already committed, so neither write is lost.
|
|
579
|
+
*
|
|
580
|
+
* The separator matches the `appendLine` helper used across the CLI:
|
|
581
|
+
* an empty description gets the text directly, a non-empty description
|
|
582
|
+
* gets a `\n` prefix on the appended text.
|
|
583
|
+
*
|
|
584
|
+
* Optional `status` is validated through the normal status-machine checker
|
|
585
|
+
* and applied atomically in the same statement so combined
|
|
586
|
+
* `--append --status` flows remain a single write.
|
|
587
|
+
*/
|
|
588
|
+
appendToTaskDescription(input: {
|
|
589
|
+
taskId: string;
|
|
590
|
+
append: string;
|
|
591
|
+
status?: string | undefined;
|
|
592
|
+
owner?: string | null | undefined;
|
|
593
|
+
}): TaskRecord {
|
|
594
|
+
return this.#writeTransaction((): TaskRecord => {
|
|
595
|
+
const existing = this.#domain.getTaskOrThrow(input.taskId);
|
|
596
|
+
if (input.status !== undefined) {
|
|
597
|
+
validateStatusTransition(existing.status, input.status, "task", input.taskId);
|
|
598
|
+
// Enforce dependency gating BEFORE the direct UPDATE bypass below.
|
|
599
|
+
// The combined append+status path issues its own SQL UPDATE rather
|
|
600
|
+
// than going through `domain.updateTask`, so without this call a
|
|
601
|
+
// blocked task with unresolved upstream deps could be flipped into
|
|
602
|
+
// a gated status (in_progress/done) — defeating the
|
|
603
|
+
// dependency_blocked contract. Symmetric with claimTask /
|
|
604
|
+
// markTaskDoneAtomically gating sites.
|
|
605
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
606
|
+
input.taskId,
|
|
607
|
+
"task",
|
|
608
|
+
existing.status,
|
|
609
|
+
input.status,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
const separator = existing.description.length > 0 ? "\n" : "";
|
|
613
|
+
const now = Date.now();
|
|
614
|
+
const nextStatus = input.status ?? existing.status;
|
|
615
|
+
const nextOwner = input.owner !== undefined
|
|
616
|
+
? (input.owner ?? null)
|
|
617
|
+
: existing.owner;
|
|
618
|
+
this.#db
|
|
619
|
+
.query(
|
|
620
|
+
"UPDATE tasks SET description = description || ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
|
|
621
|
+
)
|
|
622
|
+
.run(separator + input.append, nextStatus, nextOwner, now, input.taskId);
|
|
623
|
+
const task = this.#domain.getTaskOrThrow(input.taskId);
|
|
624
|
+
this.#emitTaskUpdated(task);
|
|
381
625
|
return task;
|
|
382
626
|
});
|
|
383
627
|
}
|
|
384
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Atomically append text to a subtask's description.
|
|
631
|
+
* Same semantics as `appendToTaskDescription`.
|
|
632
|
+
*/
|
|
633
|
+
appendToSubtaskDescription(input: {
|
|
634
|
+
subtaskId: string;
|
|
635
|
+
append: string;
|
|
636
|
+
status?: string | undefined;
|
|
637
|
+
owner?: string | null | undefined;
|
|
638
|
+
}): SubtaskRecord {
|
|
639
|
+
return this.#writeTransaction((): SubtaskRecord => {
|
|
640
|
+
const existing = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
641
|
+
if (input.status !== undefined) {
|
|
642
|
+
validateStatusTransition(existing.status, input.status, "subtask", input.subtaskId);
|
|
643
|
+
// Mirror of appendToTaskDescription: gate the combined
|
|
644
|
+
// append+status path through assertNoUnresolvedDependenciesForStatusTransition
|
|
645
|
+
// so subtasks cannot bypass dependency resolution via the bypass UPDATE.
|
|
646
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
647
|
+
input.subtaskId,
|
|
648
|
+
"subtask",
|
|
649
|
+
existing.status,
|
|
650
|
+
input.status,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const separator = existing.description.length > 0 ? "\n" : "";
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
const nextStatus = input.status ?? existing.status;
|
|
656
|
+
const nextOwner = input.owner !== undefined
|
|
657
|
+
? (input.owner ?? null)
|
|
658
|
+
: existing.owner;
|
|
659
|
+
this.#db
|
|
660
|
+
.query(
|
|
661
|
+
"UPDATE subtasks SET description = description || ?, status = ?, owner = ?, updated_at = ?, version = version + 1 WHERE id = ?;",
|
|
662
|
+
)
|
|
663
|
+
.run(separator + input.append, nextStatus, nextOwner, now, input.subtaskId);
|
|
664
|
+
const subtask = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
665
|
+
this.#emitSubtaskUpdated(subtask);
|
|
666
|
+
return subtask;
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Atomically append text to an epic's description.
|
|
672
|
+
* Same semantics as `appendToTaskDescription`.
|
|
673
|
+
*/
|
|
674
|
+
appendToEpicDescription(input: {
|
|
675
|
+
epicId: string;
|
|
676
|
+
append: string;
|
|
677
|
+
status?: string | undefined;
|
|
678
|
+
}): EpicRecord {
|
|
679
|
+
return this.#writeTransaction((): EpicRecord => {
|
|
680
|
+
const existing = this.#domain.getEpicOrThrow(input.epicId);
|
|
681
|
+
if (input.status !== undefined) {
|
|
682
|
+
validateStatusTransition(existing.status, input.status, "epic", input.epicId);
|
|
683
|
+
}
|
|
684
|
+
const separator = existing.description.length > 0 ? "\n" : "";
|
|
685
|
+
const now = Date.now();
|
|
686
|
+
const nextStatus = input.status ?? existing.status;
|
|
687
|
+
this.#db
|
|
688
|
+
.query(
|
|
689
|
+
"UPDATE epics SET description = description || ?, status = ?, updated_at = ? WHERE id = ?;",
|
|
690
|
+
)
|
|
691
|
+
.run(separator + input.append, nextStatus, now, input.epicId);
|
|
692
|
+
const epic = this.#domain.getEpicOrThrow(input.epicId);
|
|
693
|
+
this.#emitEpicUpdated(epic);
|
|
694
|
+
return epic;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Mark a task `done` atomically in a single write transaction.
|
|
700
|
+
*
|
|
701
|
+
* Background (Trekoon task 4a0111c4-6400-4a77-b4f3-d9ad863e47db / system
|
|
702
|
+
* hardening): the legacy `task done` handler issued two separate
|
|
703
|
+
* `updateTask` mutations whenever the task was in `todo` or `blocked`
|
|
704
|
+
* (auto-stepping through `in_progress` to satisfy the public status-machine
|
|
705
|
+
* checker). Each `updateTask` ran in its own `BEGIN IMMEDIATE` transaction,
|
|
706
|
+
* so a crash, kill, or thrown exception between the two writes could leave
|
|
707
|
+
* the task wedged in `in_progress` forever — even though the user had
|
|
708
|
+
* asked to mark it done.
|
|
709
|
+
*
|
|
710
|
+
* This method consolidates the operation into one transaction and bypasses
|
|
711
|
+
* `validateStatusTransition`. THIS IS THE ONE INTENTIONAL DIRECT-STATUS-WRITE
|
|
712
|
+
* EXCEPTION in the codebase: every other status mutation MUST go through the
|
|
713
|
+
* public transition checker. The bypass is safe here because the resulting
|
|
714
|
+
* status (`done`) is a documented terminal target reachable from every
|
|
715
|
+
* non-`done` status (`todo`, `blocked`, `in_progress`) in the status
|
|
716
|
+
* machine. See docs/machine-contracts.md for the canonical exceptions list.
|
|
717
|
+
*
|
|
718
|
+
* Contract:
|
|
719
|
+
* - On success: task row is `done`; exactly one `task.updated` event is
|
|
720
|
+
* emitted (no intermediate `in_progress` event); pre-blocked reverse
|
|
721
|
+
* deps are captured inside the same transaction so the unblocked-array
|
|
722
|
+
* computed by the caller is consistent with the post-COMMIT snapshot.
|
|
723
|
+
* - On failure (throw): `ROLLBACK` restores the original status — task is
|
|
724
|
+
* NEVER observable in `in_progress` due to a partial done.
|
|
725
|
+
*
|
|
726
|
+
* The caller supplies `computeSnapshot` which runs inside the transaction
|
|
727
|
+
* AFTER the row has been flipped to `done`. This is where the command
|
|
728
|
+
* layer computes readiness / unblocked / next without leaking
|
|
729
|
+
* `buildTaskReadiness` into the domain layer.
|
|
730
|
+
*/
|
|
731
|
+
markTaskDoneAtomically<T>(input: {
|
|
732
|
+
taskId: string;
|
|
733
|
+
computeSnapshot: (params: {
|
|
734
|
+
domain: TrackerDomain;
|
|
735
|
+
completed: TaskRecord;
|
|
736
|
+
preBlockedReverseDepIds: readonly string[];
|
|
737
|
+
}) => T;
|
|
738
|
+
}): T {
|
|
739
|
+
return this.#writeTransaction((): T => {
|
|
740
|
+
const existing = this.#domain.getTaskOrThrow(input.taskId);
|
|
741
|
+
|
|
742
|
+
// Positive allowlist of acceptable source statuses for the atomic done
|
|
743
|
+
// bypass. Any future terminal status (e.g. `cancelled`, `archived`) MUST
|
|
744
|
+
// be explicitly added here before it can be auto-flipped to `done`;
|
|
745
|
+
// otherwise it falls through to this guard and surfaces as
|
|
746
|
+
// `already_done`. This is safer than the legacy
|
|
747
|
+
// `existing.status === "done"` negative check, which silently accepted
|
|
748
|
+
// any new terminal status as "still allowed to transition to done".
|
|
749
|
+
if (existing.status !== "todo" && existing.status !== "blocked" && existing.status !== "in_progress") {
|
|
750
|
+
throw new DomainError({
|
|
751
|
+
code: "already_done",
|
|
752
|
+
message: "Task is already done",
|
|
753
|
+
details: { id: input.taskId },
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Enforce dependency gating BEFORE the direct UPDATE bypass. The atomic
|
|
758
|
+
// done flow skips `validateStatusTransition`, so without this call a
|
|
759
|
+
// blocked task with unresolved upstream deps would be silently flipped
|
|
760
|
+
// to `done` — defeating the dependency_blocked contract.
|
|
761
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
762
|
+
input.taskId,
|
|
763
|
+
"task",
|
|
764
|
+
existing.status,
|
|
765
|
+
"done",
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
// Snapshot direct task-level reverse-dep blockers BEFORE the status
|
|
769
|
+
// flip so the post-write snapshot can diff "newly-unblocked" tasks.
|
|
770
|
+
const reverseDeps = this.#domain.listReverseDependencies(input.taskId);
|
|
771
|
+
const directRevDepTaskIds = reverseDeps
|
|
772
|
+
.filter((rd) => rd.isDirect && rd.kind === "task")
|
|
773
|
+
.map((rd) => rd.id);
|
|
774
|
+
const preDepStatuses = this.#domain.batchResolveDependencyStatuses(directRevDepTaskIds);
|
|
775
|
+
const preBlockedReverseDepIds = directRevDepTaskIds.filter((id) => {
|
|
776
|
+
const resolved = preDepStatuses.get(id);
|
|
777
|
+
return resolved !== undefined && resolved.blockers.length > 0;
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// Direct UPDATE bypassing validateStatusTransition. See method-doc
|
|
781
|
+
// comment above for the rationale: this is the ONLY allowed direct
|
|
782
|
+
// status write in the codebase; do not copy this pattern elsewhere.
|
|
783
|
+
const now: number = Date.now();
|
|
784
|
+
this.#db
|
|
785
|
+
.query("UPDATE tasks SET status = ?, updated_at = ?, version = version + 1 WHERE id = ?;")
|
|
786
|
+
.run("done", now, input.taskId);
|
|
787
|
+
|
|
788
|
+
const completed = this.#domain.getTaskOrThrow(input.taskId);
|
|
789
|
+
|
|
790
|
+
// Emit exactly one task.updated event. Payload shape matches the
|
|
791
|
+
// canonical #emitTaskUpdated contract — sync consumers see a single
|
|
792
|
+
// logical "task became done" transition, never a phantom intermediate
|
|
793
|
+
// in_progress step.
|
|
794
|
+
this.#emitTaskUpdated(completed);
|
|
795
|
+
|
|
796
|
+
return input.computeSnapshot({
|
|
797
|
+
domain: this.#domain,
|
|
798
|
+
completed,
|
|
799
|
+
preBlockedReverseDepIds,
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Atomically claim a task for an owner using a SQL compare-and-swap.
|
|
806
|
+
*
|
|
807
|
+
* The UPDATE predicate ensures:
|
|
808
|
+
* - Only `todo` or `blocked` tasks can be claimed (not `done` or
|
|
809
|
+
* already-in_progress tasks owned by someone else).
|
|
810
|
+
* - An owner can re-claim their own in-progress task (idempotent).
|
|
811
|
+
* - Exactly one caller gets `claimed: true` when two concurrent calls race.
|
|
812
|
+
*
|
|
813
|
+
* Returns `{ claimed, currentOwner, currentStatus }`.
|
|
814
|
+
* When `claimed` is true the returned `task` is the post-update record.
|
|
815
|
+
*/
|
|
816
|
+
claimTask(input: { taskId: string; owner: string }): {
|
|
817
|
+
claimed: boolean;
|
|
818
|
+
currentOwner: string | null;
|
|
819
|
+
currentStatus: string;
|
|
820
|
+
task?: TaskRecord;
|
|
821
|
+
} {
|
|
822
|
+
return this.#writeTransaction(() => {
|
|
823
|
+
// Enforce dependency gating BEFORE the CAS so a blocked-by-unresolved-dep
|
|
824
|
+
// task cannot be silently flipped into `in_progress`. Symmetric with
|
|
825
|
+
// markTaskDoneAtomically: both "forward-progress" terminal/active
|
|
826
|
+
// transitions go through the same gating call. A pre-existing in_progress
|
|
827
|
+
// or done row is short-circuited because its existing status equals the
|
|
828
|
+
// next status (or is non-gated terminal) and the gating helper returns
|
|
829
|
+
// early.
|
|
830
|
+
const existing = this.#domain.getTask(input.taskId);
|
|
831
|
+
if (existing && (existing.status === "todo" || existing.status === "blocked")) {
|
|
832
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
833
|
+
input.taskId,
|
|
834
|
+
"task",
|
|
835
|
+
existing.status,
|
|
836
|
+
"in_progress",
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const now = Date.now();
|
|
841
|
+
const result = this.#db
|
|
842
|
+
.query(
|
|
843
|
+
`UPDATE tasks
|
|
844
|
+
SET status = 'in_progress', owner = ?, updated_at = ?, version = version + 1
|
|
845
|
+
WHERE id = ?
|
|
846
|
+
AND status IN ('todo', 'blocked')
|
|
847
|
+
AND (owner IS NULL OR owner = ?)
|
|
848
|
+
RETURNING id`,
|
|
849
|
+
)
|
|
850
|
+
.get(input.owner, now, input.taskId, input.owner) as { id: string } | null;
|
|
851
|
+
|
|
852
|
+
if (result !== null) {
|
|
853
|
+
const task = this.#domain.getTaskOrThrow(input.taskId);
|
|
854
|
+
this.#emitTaskUpdated(task);
|
|
855
|
+
return {
|
|
856
|
+
claimed: true,
|
|
857
|
+
currentOwner: input.owner,
|
|
858
|
+
currentStatus: "in_progress",
|
|
859
|
+
task,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// CAS failed — fetch current state for the caller
|
|
864
|
+
const current = this.#domain.getTaskOrThrow(input.taskId);
|
|
865
|
+
return {
|
|
866
|
+
claimed: false,
|
|
867
|
+
currentOwner: current.owner,
|
|
868
|
+
currentStatus: current.status,
|
|
869
|
+
};
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Atomically claim a subtask for an owner using a SQL compare-and-swap.
|
|
875
|
+
* Same semantics as `claimTask`.
|
|
876
|
+
*/
|
|
877
|
+
claimSubtask(input: { subtaskId: string; owner: string }): {
|
|
878
|
+
claimed: boolean;
|
|
879
|
+
currentOwner: string | null;
|
|
880
|
+
currentStatus: string;
|
|
881
|
+
subtask?: SubtaskRecord;
|
|
882
|
+
} {
|
|
883
|
+
return this.#writeTransaction(() => {
|
|
884
|
+
// Mirror of claimTask: gate the todo/blocked → in_progress transition
|
|
885
|
+
// through assertNoUnresolvedDependenciesForStatusTransition so subtask
|
|
886
|
+
// claims cannot bypass dependency resolution.
|
|
887
|
+
const existing = this.#domain.getSubtask(input.subtaskId);
|
|
888
|
+
if (existing && (existing.status === "todo" || existing.status === "blocked")) {
|
|
889
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(
|
|
890
|
+
input.subtaskId,
|
|
891
|
+
"subtask",
|
|
892
|
+
existing.status,
|
|
893
|
+
"in_progress",
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const now = Date.now();
|
|
898
|
+
const result = this.#db
|
|
899
|
+
.query(
|
|
900
|
+
`UPDATE subtasks
|
|
901
|
+
SET status = 'in_progress', owner = ?, updated_at = ?, version = version + 1
|
|
902
|
+
WHERE id = ?
|
|
903
|
+
AND status IN ('todo', 'blocked')
|
|
904
|
+
AND (owner IS NULL OR owner = ?)
|
|
905
|
+
RETURNING id`,
|
|
906
|
+
)
|
|
907
|
+
.get(input.owner, now, input.subtaskId, input.owner) as { id: string } | null;
|
|
908
|
+
|
|
909
|
+
if (result !== null) {
|
|
910
|
+
const subtask = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
911
|
+
this.#emitSubtaskUpdated(subtask);
|
|
912
|
+
return {
|
|
913
|
+
claimed: true,
|
|
914
|
+
currentOwner: input.owner,
|
|
915
|
+
currentStatus: "in_progress",
|
|
916
|
+
subtask,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const current = this.#domain.getSubtaskOrThrow(input.subtaskId);
|
|
921
|
+
return {
|
|
922
|
+
claimed: false,
|
|
923
|
+
currentOwner: current.owner,
|
|
924
|
+
currentStatus: current.status,
|
|
925
|
+
};
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
385
929
|
updateTaskStatusCascade(id: string, status: string): StatusCascadePlan {
|
|
386
930
|
return this.#writeTransaction((): StatusCascadePlan => {
|
|
387
931
|
const plan = this.#domain.planStatusCascade("task", id, status);
|
|
@@ -395,13 +939,10 @@ export class MutationService {
|
|
|
395
939
|
return this.#writeTransaction((): { deletedSubtaskIds: string[]; deletedDependencyIds: string[] } => {
|
|
396
940
|
const plan = this.#domain.planTaskDeletion(id);
|
|
397
941
|
this.#domain.deleteTask(id);
|
|
398
|
-
const taskDeleteEventId = this.#
|
|
942
|
+
const taskDeleteEventId = this.#emitTaskDeleted(id);
|
|
399
943
|
|
|
400
944
|
for (const subtaskId of plan.subtaskIds) {
|
|
401
|
-
this.#
|
|
402
|
-
task_id: id,
|
|
403
|
-
source_event_id: taskDeleteEventId,
|
|
404
|
-
});
|
|
945
|
+
this.#emitSubtaskDeleted(subtaskId, { taskId: id, sourceEventId: taskDeleteEventId });
|
|
405
946
|
}
|
|
406
947
|
|
|
407
948
|
for (const dependency of plan.touchingDependencies) {
|
|
@@ -435,12 +976,7 @@ export class MutationService {
|
|
|
435
976
|
}): SubtaskRecord {
|
|
436
977
|
return this.#writeTransaction((): SubtaskRecord => {
|
|
437
978
|
const subtask = this.#domain.createSubtask(input);
|
|
438
|
-
this.#
|
|
439
|
-
task_id: subtask.taskId,
|
|
440
|
-
title: subtask.title,
|
|
441
|
-
description: subtask.description,
|
|
442
|
-
status: subtask.status,
|
|
443
|
-
});
|
|
979
|
+
this.#emitSubtaskCreated(subtask);
|
|
444
980
|
return subtask;
|
|
445
981
|
});
|
|
446
982
|
}
|
|
@@ -455,12 +991,7 @@ export class MutationService {
|
|
|
455
991
|
}): AtomicIdempotentMutationResult {
|
|
456
992
|
return this.#completeAtomicIdempotentMutation(input.claim, (): AtomicIdempotencyCompletedResult => {
|
|
457
993
|
const subtask = this.#domain.createSubtask(input);
|
|
458
|
-
this.#
|
|
459
|
-
task_id: subtask.taskId,
|
|
460
|
-
title: subtask.title,
|
|
461
|
-
description: subtask.description,
|
|
462
|
-
status: subtask.status,
|
|
463
|
-
});
|
|
994
|
+
this.#emitSubtaskCreated(subtask);
|
|
464
995
|
return {
|
|
465
996
|
state: "completed",
|
|
466
997
|
status: 201,
|
|
@@ -473,12 +1004,7 @@ export class MutationService {
|
|
|
473
1004
|
return this.#writeTransaction((): CompactSubtaskBatchCreateResult => {
|
|
474
1005
|
const created = this.#domain.createSubtaskBatch(input);
|
|
475
1006
|
for (const subtask of created.subtasks) {
|
|
476
|
-
this.#
|
|
477
|
-
task_id: subtask.taskId,
|
|
478
|
-
title: subtask.title,
|
|
479
|
-
description: subtask.description,
|
|
480
|
-
status: subtask.status,
|
|
481
|
-
});
|
|
1007
|
+
this.#emitSubtaskCreated(subtask);
|
|
482
1008
|
}
|
|
483
1009
|
return created;
|
|
484
1010
|
});
|
|
@@ -494,13 +1020,66 @@ export class MutationService {
|
|
|
494
1020
|
validateStatusTransition(existing.status, input.status, "subtask", id);
|
|
495
1021
|
}
|
|
496
1022
|
const subtask = this.#domain.updateSubtask(id, input);
|
|
497
|
-
this.#
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
1023
|
+
this.#emitSubtaskUpdated(subtask);
|
|
1024
|
+
return subtask;
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Atomic If-Match CAS variant of {@link updateSubtask}. Mirrors
|
|
1030
|
+
* {@link updateTaskWithIfMatch} except subtask `description` may be
|
|
1031
|
+
* empty (matches `normalizeSubtaskDescription` semantics).
|
|
1032
|
+
*/
|
|
1033
|
+
updateSubtaskWithIfMatch(
|
|
1034
|
+
id: string,
|
|
1035
|
+
ifMatchUpdatedAt: number,
|
|
1036
|
+
input: { title?: string | undefined; description?: string | undefined; status?: string | undefined; owner?: string | null | undefined },
|
|
1037
|
+
): SubtaskRecord {
|
|
1038
|
+
return this.#writeTransaction((): SubtaskRecord => {
|
|
1039
|
+
const existing = this.#domain.getSubtaskOrThrow(id);
|
|
1040
|
+
|
|
1041
|
+
const nextTitle = input.title !== undefined
|
|
1042
|
+
? assertNonEmptyField("title", input.title)
|
|
1043
|
+
: existing.title;
|
|
1044
|
+
// Subtask description allows empty strings — mirror
|
|
1045
|
+
// normalizeSubtaskDescription rather than asserting non-empty.
|
|
1046
|
+
const nextDescription = input.description !== undefined
|
|
1047
|
+
? input.description.trim()
|
|
1048
|
+
: existing.description;
|
|
1049
|
+
const nextStatus = input.status !== undefined
|
|
1050
|
+
? assertNonEmptyField("status", input.status)
|
|
1051
|
+
: existing.status;
|
|
1052
|
+
const normalizedOwner = normalizeOwnerInput(input.owner);
|
|
1053
|
+
const nextOwner = normalizedOwner === undefined ? existing.owner : normalizedOwner;
|
|
1054
|
+
|
|
1055
|
+
if (input.status !== undefined) {
|
|
1056
|
+
validateStatusTransition(existing.status, nextStatus, "subtask", id);
|
|
1057
|
+
}
|
|
1058
|
+
this.#domain.assertNoUnresolvedDependenciesForStatusTransition(id, "subtask", existing.status, nextStatus);
|
|
1059
|
+
|
|
1060
|
+
const now: number = Date.now();
|
|
1061
|
+
const result = this.#db
|
|
1062
|
+
.query(
|
|
1063
|
+
`UPDATE subtasks
|
|
1064
|
+
SET title = ?, description = ?, status = ?, owner = ?, updated_at = ?, version = version + 1
|
|
1065
|
+
WHERE id = ?
|
|
1066
|
+
AND updated_at = ?
|
|
1067
|
+
RETURNING id`,
|
|
1068
|
+
)
|
|
1069
|
+
.get(nextTitle, nextDescription, nextStatus, nextOwner, now, id, ifMatchUpdatedAt) as { id: string } | null;
|
|
1070
|
+
|
|
1071
|
+
if (result === null) {
|
|
1072
|
+
const current = this.#domain.getSubtaskOrThrow(id);
|
|
1073
|
+
throw new PreconditionFailedError({
|
|
1074
|
+
entityKind: "subtask",
|
|
1075
|
+
entityId: id,
|
|
1076
|
+
currentUpdatedAt: current.updatedAt,
|
|
1077
|
+
providedUpdatedAt: ifMatchUpdatedAt,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const subtask = this.#domain.getSubtaskOrThrow(id);
|
|
1082
|
+
this.#emitSubtaskUpdated(subtask);
|
|
504
1083
|
return subtask;
|
|
505
1084
|
});
|
|
506
1085
|
}
|
|
@@ -509,7 +1088,7 @@ export class MutationService {
|
|
|
509
1088
|
return this.#writeTransaction((): { deletedDependencyIds: string[] } => {
|
|
510
1089
|
const touchingDependencies = this.#domain.listDependenciesTouchingNode(id);
|
|
511
1090
|
this.#domain.deleteSubtask(id);
|
|
512
|
-
const subtaskDeleteEventId = this.#
|
|
1091
|
+
const subtaskDeleteEventId = this.#emitSubtaskDeleted(id);
|
|
513
1092
|
for (const dependency of touchingDependencies) {
|
|
514
1093
|
this.#appendEntityEvent(
|
|
515
1094
|
"dependency",
|
|
@@ -547,7 +1126,7 @@ export class MutationService {
|
|
|
547
1126
|
const task = this.#domain.getTaskOrThrow(existingSubtask.taskId);
|
|
548
1127
|
const touchingDependencies = this.#domain.listDependenciesTouchingNode(input.id);
|
|
549
1128
|
this.#domain.deleteSubtask(input.id);
|
|
550
|
-
const subtaskDeleteEventId = this.#
|
|
1129
|
+
const subtaskDeleteEventId = this.#emitSubtaskDeleted(input.id);
|
|
551
1130
|
for (const dependency of touchingDependencies) {
|
|
552
1131
|
this.#appendEntityEvent(
|
|
553
1132
|
"dependency",
|
|
@@ -838,6 +1417,87 @@ export class MutationService {
|
|
|
838
1417
|
});
|
|
839
1418
|
}
|
|
840
1419
|
|
|
1420
|
+
// -- Centralized event-emission helpers ------------------------------------
|
|
1421
|
+
// Each helper builds the payload for a single (entity, op) pair from the
|
|
1422
|
+
// entity record. Payload shapes here MUST match the historical inline
|
|
1423
|
+
// construction byte-for-byte: sync correctness depends on it.
|
|
1424
|
+
|
|
1425
|
+
#emitEpicCreated(epic: EpicRecord): string {
|
|
1426
|
+
return this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.created, {
|
|
1427
|
+
title: epic.title,
|
|
1428
|
+
description: epic.description,
|
|
1429
|
+
status: epic.status,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
#emitEpicUpdated(epic: EpicRecord): string {
|
|
1434
|
+
return this.#appendEntityEvent("epic", epic.id, ENTITY_OPERATIONS.epic.updated, {
|
|
1435
|
+
title: epic.title,
|
|
1436
|
+
description: epic.description,
|
|
1437
|
+
status: epic.status,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
#emitEpicDeleted(epicId: string): string {
|
|
1442
|
+
return this.#appendEntityEvent("epic", epicId, ENTITY_OPERATIONS.epic.deleted, {});
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
#emitTaskCreated(task: TaskRecord): string {
|
|
1446
|
+
return this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.created, {
|
|
1447
|
+
epic_id: task.epicId,
|
|
1448
|
+
title: task.title,
|
|
1449
|
+
description: task.description,
|
|
1450
|
+
status: task.status,
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
#emitTaskUpdated(task: TaskRecord): string {
|
|
1455
|
+
return this.#appendEntityEvent("task", task.id, ENTITY_OPERATIONS.task.updated, {
|
|
1456
|
+
epic_id: task.epicId,
|
|
1457
|
+
title: task.title,
|
|
1458
|
+
description: task.description,
|
|
1459
|
+
status: task.status,
|
|
1460
|
+
owner: task.owner,
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
#emitTaskDeleted(
|
|
1465
|
+
taskId: string,
|
|
1466
|
+
cascade?: { sourceEventId: string } | undefined,
|
|
1467
|
+
): string {
|
|
1468
|
+
const fields: Record<string, unknown> = cascade ? { source_event_id: cascade.sourceEventId } : {};
|
|
1469
|
+
return this.#appendEntityEvent("task", taskId, ENTITY_OPERATIONS.task.deleted, fields);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
#emitSubtaskCreated(subtask: SubtaskRecord): string {
|
|
1473
|
+
return this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.created, {
|
|
1474
|
+
task_id: subtask.taskId,
|
|
1475
|
+
title: subtask.title,
|
|
1476
|
+
description: subtask.description,
|
|
1477
|
+
status: subtask.status,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
#emitSubtaskUpdated(subtask: SubtaskRecord): string {
|
|
1482
|
+
return this.#appendEntityEvent("subtask", subtask.id, ENTITY_OPERATIONS.subtask.updated, {
|
|
1483
|
+
task_id: subtask.taskId,
|
|
1484
|
+
title: subtask.title,
|
|
1485
|
+
description: subtask.description,
|
|
1486
|
+
status: subtask.status,
|
|
1487
|
+
owner: subtask.owner,
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
#emitSubtaskDeleted(
|
|
1492
|
+
subtaskId: string,
|
|
1493
|
+
cascade?: { taskId: string; sourceEventId: string } | undefined,
|
|
1494
|
+
): string {
|
|
1495
|
+
const fields: Record<string, unknown> = cascade
|
|
1496
|
+
? { task_id: cascade.taskId, source_event_id: cascade.sourceEventId }
|
|
1497
|
+
: {};
|
|
1498
|
+
return this.#appendEntityEvent("subtask", subtaskId, ENTITY_OPERATIONS.subtask.deleted, fields);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
841
1501
|
#completeAtomicIdempotentMutation(
|
|
842
1502
|
claim: AtomicIdempotencyClaim,
|
|
843
1503
|
mutate: () => AtomicIdempotencyCompletedResult,
|
|
@@ -920,6 +1580,14 @@ export class MutationService {
|
|
|
920
1580
|
}
|
|
921
1581
|
|
|
922
1582
|
#pruneExpiredIdempotencyKeys(now: number = Date.now()): void {
|
|
1583
|
+
// Skip if we swept recently — see BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS
|
|
1584
|
+
// for rationale. Module-level throttle so it applies per-process even
|
|
1585
|
+
// across MutationService instances.
|
|
1586
|
+
if (now - lastIdempotencyPruneAt < BOARD_IDEMPOTENCY_PRUNE_INTERVAL_MS) {
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
lastIdempotencyPruneAt = now;
|
|
1590
|
+
|
|
923
1591
|
const cutoff: number = now - BOARD_IDEMPOTENCY_RETENTION_MS;
|
|
924
1592
|
this.#db.query(
|
|
925
1593
|
`
|
|
@@ -966,34 +1634,18 @@ export class MutationService {
|
|
|
966
1634
|
for (const change of plan.orderedChanges) {
|
|
967
1635
|
if (change.kind === "epic") {
|
|
968
1636
|
const epic = this.#domain.updateEpic(change.id, { status: change.nextStatus });
|
|
969
|
-
this.#
|
|
970
|
-
title: epic.title,
|
|
971
|
-
description: epic.description,
|
|
972
|
-
status: epic.status,
|
|
973
|
-
});
|
|
1637
|
+
this.#emitEpicUpdated(epic);
|
|
974
1638
|
continue;
|
|
975
1639
|
}
|
|
976
1640
|
|
|
977
1641
|
if (change.kind === "task") {
|
|
978
1642
|
const task = this.#domain.updateTask(change.id, { status: change.nextStatus });
|
|
979
|
-
this.#
|
|
980
|
-
epic_id: task.epicId,
|
|
981
|
-
title: task.title,
|
|
982
|
-
description: task.description,
|
|
983
|
-
status: task.status,
|
|
984
|
-
owner: task.owner,
|
|
985
|
-
});
|
|
1643
|
+
this.#emitTaskUpdated(task);
|
|
986
1644
|
continue;
|
|
987
1645
|
}
|
|
988
1646
|
|
|
989
1647
|
const subtask = this.#domain.updateSubtask(change.id, { status: change.nextStatus });
|
|
990
|
-
this.#
|
|
991
|
-
task_id: subtask.taskId,
|
|
992
|
-
title: subtask.title,
|
|
993
|
-
description: subtask.description,
|
|
994
|
-
status: subtask.status,
|
|
995
|
-
owner: subtask.owner,
|
|
996
|
-
});
|
|
1648
|
+
this.#emitSubtaskUpdated(subtask);
|
|
997
1649
|
}
|
|
998
1650
|
}
|
|
999
1651
|
|
|
@@ -1018,34 +1670,18 @@ export class MutationService {
|
|
|
1018
1670
|
|
|
1019
1671
|
if (node.kind === "epic") {
|
|
1020
1672
|
const epic = this.#domain.updateEpic(node.id, { title: nextTitle, description: nextDescription });
|
|
1021
|
-
this.#
|
|
1022
|
-
title: epic.title,
|
|
1023
|
-
description: epic.description,
|
|
1024
|
-
status: epic.status,
|
|
1025
|
-
});
|
|
1673
|
+
this.#emitEpicUpdated(epic);
|
|
1026
1674
|
continue;
|
|
1027
1675
|
}
|
|
1028
1676
|
|
|
1029
1677
|
if (node.kind === "task") {
|
|
1030
1678
|
const task = this.#domain.updateTask(node.id, { title: nextTitle, description: nextDescription });
|
|
1031
|
-
this.#
|
|
1032
|
-
epic_id: task.epicId,
|
|
1033
|
-
title: task.title,
|
|
1034
|
-
description: task.description,
|
|
1035
|
-
status: task.status,
|
|
1036
|
-
owner: task.owner,
|
|
1037
|
-
});
|
|
1679
|
+
this.#emitTaskUpdated(task);
|
|
1038
1680
|
continue;
|
|
1039
1681
|
}
|
|
1040
1682
|
|
|
1041
1683
|
const subtask = this.#domain.updateSubtask(node.id, { title: nextTitle, description: nextDescription });
|
|
1042
|
-
this.#
|
|
1043
|
-
task_id: subtask.taskId,
|
|
1044
|
-
title: subtask.title,
|
|
1045
|
-
description: subtask.description,
|
|
1046
|
-
status: subtask.status,
|
|
1047
|
-
owner: subtask.owner,
|
|
1048
|
-
});
|
|
1684
|
+
this.#emitSubtaskUpdated(subtask);
|
|
1049
1685
|
}
|
|
1050
1686
|
});
|
|
1051
1687
|
|