trekoon 0.4.5 → 0.4.7
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 +37 -36
- package/.agents/skills/trekoon/reference/execution.md +73 -69
- package/.agents/skills/trekoon/reference/harness-primitives.md +37 -0
- package/.agents/skills/trekoon/reference/planning.md +66 -72
- package/README.md +28 -0
- package/docs/commands.md +38 -6
- package/docs/machine-contracts.md +1 -0
- package/docs/quickstart.md +6 -2
- package/package.json +1 -1
- package/src/board/assets/state/actions.js +32 -10
- package/src/board/assets/state/api.js +234 -35
- package/src/board/assets/state/utils.js +18 -0
- package/src/board/routes.ts +27 -14
- package/src/board/snapshot.ts +9 -19
- package/src/board/wal-watcher.ts +637 -74
- package/src/commands/epic.ts +6 -6
- package/src/commands/help.ts +20 -6
- package/src/commands/quickstart.ts +5 -2
- package/src/commands/session.ts +161 -1
- package/src/commands/subtask.ts +2 -2
- package/src/commands/suggest.ts +1 -1
- package/src/commands/task.ts +2 -2
- package/src/domain/mutation-service.ts +83 -9
- package/src/domain/tracker-domain.ts +109 -6
- package/src/io/output.ts +1 -1
- package/src/storage/database.ts +67 -2
- package/src/storage/migrations.ts +149 -2
- package/src/storage/schema.ts +6 -1
- package/src/sync/event-writes.ts +24 -2
- package/.agents/skills/trekoon/reference/execution-with-team.md +0 -170
package/docs/commands.md
CHANGED
|
@@ -10,7 +10,7 @@ the quickest way to get started, read [Quickstart](quickstart.md) first.
|
|
|
10
10
|
- `trekoon help [command]`
|
|
11
11
|
- `trekoon quickstart`
|
|
12
12
|
- `trekoon epic <create|expand|list|show|search|replace|update|delete|progress>`
|
|
13
|
-
- `trekoon session [--epic <epic-id>]`
|
|
13
|
+
- `trekoon session [--epic <epic-id>] [--item <id>]`
|
|
14
14
|
- `trekoon suggest [--epic <epic-id>]`
|
|
15
15
|
- `trekoon task <create|create-many|list|show|ready|next|done|search|replace|update|delete|claim>`
|
|
16
16
|
- `trekoon subtask <create|create-many|list|search|replace|update|delete|claim>`
|
|
@@ -94,11 +94,11 @@ Board mutations from any source — board UI, CLI in another shell, or another
|
|
|
94
94
|
worktree — propagate to every connected client through the SSE stream. The CLI
|
|
95
95
|
side is driven by a WAL-watcher that diffs the snapshot when
|
|
96
96
|
`.trekoon/trekoon.db-wal` changes; in-process mutations publish deltas
|
|
97
|
-
directly. PATCH endpoints
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
`
|
|
101
|
-
|
|
97
|
+
directly. PATCH endpoints require `If-Match: <version>` for optimistic concurrency; RFC
|
|
98
|
+
7232 strong or `W/`-prefixed weak ETag forms are accepted, but the `*`
|
|
99
|
+
wildcard is not. A stale value returns `409` with `currentVersion` and
|
|
100
|
+
`providedVersion`. A missing `If-Match` header returns `428 Precondition
|
|
101
|
+
Required` with error code `precondition_required`.
|
|
102
102
|
|
|
103
103
|
Repos that already have an ignored `.trekoon/board` directory keep those files
|
|
104
104
|
on disk, but current Trekoon versions no longer read, create, refresh, or
|
|
@@ -179,6 +179,25 @@ Rules:
|
|
|
179
179
|
- Subtask cascade is accepted for consistency but just updates one subtask
|
|
180
180
|
- Success response includes `data.cascade` with changed/unchanged IDs and counts
|
|
181
181
|
|
|
182
|
+
## Epic create and expand
|
|
183
|
+
|
|
184
|
+
### Temp-key rules
|
|
185
|
+
|
|
186
|
+
Temp keys in `--task` and `--subtask` share one flat namespace per `epic create` / `epic expand` invocation — reusing a key across any two records in the same call fails the batch. Prefix subtask keys with the parent task key to keep them unique (e.g. `task-a-sub-1` under `task-a`).
|
|
187
|
+
|
|
188
|
+
`task create-many` and `subtask create-many` use their own scoped namespaces, independent of each other and of `epic create`.
|
|
189
|
+
|
|
190
|
+
Escape any literal `|` inside field values as `\|`. A single bare `|` in a spec without an explicit `|<status>` field silently pushes the trailing text into the status slot — creation succeeds and the record only breaks on the next status update. Multi-pipe constructs like `||` fail loudly on the field-count check. See the planning skill for the full pipe-escape rules.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
trekoon epic create \
|
|
194
|
+
--title "Launch" \
|
|
195
|
+
--task "task-a|First task|Description|todo" \
|
|
196
|
+
--task "task-b|Second task|Description|todo" \
|
|
197
|
+
--subtask "@task-a|task-a-sub-1|First subtask|Description|todo" \
|
|
198
|
+
--subtask "@task-b|task-b-sub-1|Second subtask|Description|todo"
|
|
199
|
+
```
|
|
200
|
+
|
|
182
201
|
## Status machine
|
|
183
202
|
|
|
184
203
|
Statuses: `todo`, `in_progress`, `done`, `blocked`. The hyphenated `in-progress`
|
|
@@ -288,6 +307,19 @@ trekoon session --epic <epic-id>
|
|
|
288
307
|
|
|
289
308
|
Scopes session readiness to a specific epic instead of the full tracker.
|
|
290
309
|
|
|
310
|
+
## Session item resolve
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
trekoon --toon session --item <id>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Resolves any epic/task/subtask id in one call. Returns
|
|
317
|
+
`item: { id, kind, parentEpicId, entity, readiness, suggestedNext }` where
|
|
318
|
+
`kind` is one of `epic|task|subtask`, `entity` is the same shape as
|
|
319
|
+
`epic show --all` / `task show --all` / `subtask show`, and `readiness` is
|
|
320
|
+
scoped to `parentEpicId`. Replaces the legacy
|
|
321
|
+
`epic show || task show || subtask show` fallback cascade.
|
|
322
|
+
|
|
291
323
|
## Suggest
|
|
292
324
|
|
|
293
325
|
```bash
|
|
@@ -648,6 +648,7 @@ any `ok: false` response will be one of the following strings.
|
|
|
648
648
|
| `outside_repo_target` | Skill install target path is outside the repository root. |
|
|
649
649
|
| `permission_denied` | File-system permission denied for the requested path. |
|
|
650
650
|
| `precondition_failed` | `If-Match` precondition header did not match the entity's current version. Error details include `currentVersion` and `providedVersion`. |
|
|
651
|
+
| `precondition_required` | `If-Match` header is required on a PATCH route but was omitted. |
|
|
651
652
|
| `row_not_found` | Sync resolve target row no longer exists in the database. |
|
|
652
653
|
| `status_transition_invalid` | Requested status transition is not permitted by the status machine. |
|
|
653
654
|
| `stream_unavailable` | SSE snapshot stream is not available (board not initialised or shutting down). |
|
package/docs/quickstart.md
CHANGED
|
@@ -93,11 +93,15 @@ trekoon --toon epic create \
|
|
|
93
93
|
--description "Ship one-shot planning workflows" \
|
|
94
94
|
--task "task-a|First task|First description|todo" \
|
|
95
95
|
--task "task-b|Second task|Second description|todo" \
|
|
96
|
-
--subtask "@task-a|sub-a|First subtask|Subtask description|todo" \
|
|
96
|
+
--subtask "@task-a|sub-a-first|First subtask|Subtask description|todo" \
|
|
97
97
|
--dep "@task-b|@task-a" \
|
|
98
|
-
--dep "@sub-a|@task-a"
|
|
98
|
+
--dep "@sub-a-first|@task-a"
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
All temp keys (task and subtask) must be unique across the whole command — they share one flat namespace. Prefix subtask keys with the parent task key to stay unique.
|
|
102
|
+
|
|
103
|
+
Escape any literal `|` inside field values as `\|`. A bare `|` on a spec without an explicit `|<status>` field silently pushes trailing text into the status slot (e.g. `Verify: bun test foo | tail` lets `tail` become the status, and creation still succeeds). Specs that already pass `|<status>` fail loudly on the same input. See the planning skill for full rules.
|
|
104
|
+
|
|
101
105
|
This is better than sequential creates because later records can reference
|
|
102
106
|
earlier ones with `@temp-key`, and you get one atomic operation with mappings
|
|
103
107
|
and counts in the response.
|
package/package.json
CHANGED
|
@@ -377,7 +377,14 @@ export function createBoardActions(options) {
|
|
|
377
377
|
description: String(formData.get("description") || "").trim(),
|
|
378
378
|
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
379
379
|
};
|
|
380
|
-
api.patchTask
|
|
380
|
+
// No eager version capture: api.patchTask resolves the If-Match version
|
|
381
|
+
// lazily at queue fire time so back-to-back edits on the same task see
|
|
382
|
+
// the post-success version landed via mutation response or SSE delta.
|
|
383
|
+
api.patchTask(
|
|
384
|
+
taskId,
|
|
385
|
+
updates,
|
|
386
|
+
(snapshot) => updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot),
|
|
387
|
+
);
|
|
381
388
|
},
|
|
382
389
|
submitSubtaskForm(subtaskId, formData) {
|
|
383
390
|
const updates = {
|
|
@@ -386,7 +393,11 @@ export function createBoardActions(options) {
|
|
|
386
393
|
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
387
394
|
};
|
|
388
395
|
syncState({ selectedSubtaskId: subtaskId });
|
|
389
|
-
api.patchSubtask(
|
|
396
|
+
api.patchSubtask(
|
|
397
|
+
subtaskId,
|
|
398
|
+
updates,
|
|
399
|
+
(snapshot) => updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot),
|
|
400
|
+
);
|
|
390
401
|
},
|
|
391
402
|
submitCreateSubtask(taskId, formData) {
|
|
392
403
|
const input = {
|
|
@@ -456,20 +467,31 @@ export function createBoardActions(options) {
|
|
|
456
467
|
}
|
|
457
468
|
// Drag/drop is a status change only; do not mutate selection or modal state,
|
|
458
469
|
// otherwise dropping a card while another task modal is open hijacks it.
|
|
459
|
-
|
|
470
|
+
// If-Match version resolved lazily by api.patchTask at fire time.
|
|
471
|
+
api.patchTask(
|
|
472
|
+
taskId,
|
|
473
|
+
{ status: nextStatus },
|
|
474
|
+
(snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot),
|
|
475
|
+
);
|
|
460
476
|
},
|
|
461
477
|
changeEpicStatus(epicId, newStatus) {
|
|
462
478
|
const normalizedStatus = normalizeStatus(newStatus);
|
|
463
|
-
api.patchEpic(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
479
|
+
api.patchEpic(
|
|
480
|
+
epicId,
|
|
481
|
+
{ status: normalizedStatus },
|
|
482
|
+
(snapshot) => {
|
|
483
|
+
const epic = snapshot.epics.find(e => e.id === epicId);
|
|
484
|
+
if (epic) epic.status = normalizedStatus;
|
|
485
|
+
return snapshot;
|
|
486
|
+
},
|
|
487
|
+
);
|
|
468
488
|
},
|
|
469
489
|
bulkSetStatus(epicId, newStatus) {
|
|
470
490
|
const normalizedStatus = normalizeStatus(newStatus);
|
|
471
|
-
api.cascadeEpicStatus(
|
|
472
|
-
|
|
491
|
+
api.cascadeEpicStatus(
|
|
492
|
+
epicId,
|
|
493
|
+
normalizedStatus,
|
|
494
|
+
(snapshot) => cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
|
|
473
495
|
);
|
|
474
496
|
},
|
|
475
497
|
toggleEpicStatusFilter(status) {
|
|
@@ -140,6 +140,57 @@ export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
|
|
|
140
140
|
return inverse;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Filter an inverse delta produced by computeInverseDelta so we don't undo
|
|
145
|
+
* concurrent SSE-pushed advances that landed on the same entity while a
|
|
146
|
+
* mutation was in flight. Any record whose live snapshot `version` is strictly
|
|
147
|
+
* greater than the optimistic `ifMatchVersion` we sent is dropped from the
|
|
148
|
+
* `restored` arrays — the server-pushed state stays.
|
|
149
|
+
*
|
|
150
|
+
* If `optimisticVersion` is undefined/null/not-a-number we conservatively
|
|
151
|
+
* return the inverse delta unchanged (back-compat: legacy mutations without an
|
|
152
|
+
* If-Match version can't be reasoned about, so the original behavior wins).
|
|
153
|
+
*
|
|
154
|
+
* @param {object} inverseDelta - Inverse delta from computeInverseDelta.
|
|
155
|
+
* @param {object|null|undefined} currentSnapshot - Latest store snapshot.
|
|
156
|
+
* @param {number|null|undefined} optimisticVersion - The version we sent as If-Match.
|
|
157
|
+
* @returns {object}
|
|
158
|
+
*/
|
|
159
|
+
export function stripUpToDateEntitiesFromInverse(inverseDelta, currentSnapshot, optimisticVersion) {
|
|
160
|
+
if (typeof optimisticVersion !== "number" || !Number.isFinite(optimisticVersion)) {
|
|
161
|
+
return inverseDelta;
|
|
162
|
+
}
|
|
163
|
+
if (!inverseDelta || typeof inverseDelta !== "object") {
|
|
164
|
+
return inverseDelta;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const next = { ...inverseDelta };
|
|
168
|
+
for (const collection of SNAPSHOT_COLLECTIONS) {
|
|
169
|
+
const restored = inverseDelta[collection];
|
|
170
|
+
if (!Array.isArray(restored) || restored.length === 0) continue;
|
|
171
|
+
|
|
172
|
+
const liveRecords = Array.isArray(currentSnapshot?.[collection]) ? currentSnapshot[collection] : [];
|
|
173
|
+
const liveById = indexById(liveRecords);
|
|
174
|
+
const filtered = restored.filter((record) => {
|
|
175
|
+
if (!record || typeof record !== "object" || typeof record.id !== "string") return true;
|
|
176
|
+
const live = liveById.get(record.id);
|
|
177
|
+
const liveVersion = typeof live?.version === "number" ? live.version : null;
|
|
178
|
+
if (liveVersion === null) return true;
|
|
179
|
+
// Drop the restore when the live snapshot's version has already advanced
|
|
180
|
+
// past what we sent — an SSE delta or another mutation reconciliation
|
|
181
|
+
// moved this record forward and the user must see that.
|
|
182
|
+
return !(liveVersion > optimisticVersion);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (filtered.length === 0) {
|
|
186
|
+
delete next[collection];
|
|
187
|
+
} else if (filtered.length !== restored.length) {
|
|
188
|
+
next[collection] = filtered;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return next;
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
async function readJsonPayload(response) {
|
|
144
195
|
const text = await response.text();
|
|
145
196
|
if (text.length === 0) {
|
|
@@ -208,6 +259,61 @@ function createTimeoutError(method, path, timeoutMs) {
|
|
|
208
259
|
return error;
|
|
209
260
|
}
|
|
210
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Normalize a caller-supplied entity version into a bare integer suitable for
|
|
264
|
+
* the If-Match header. The server accepts a bare integer, a quoted ETag, or a
|
|
265
|
+
* W/-prefixed weak ETag (RFC 7232 §3.1) — we emit the bare integer form for
|
|
266
|
+
* simplicity. Returns null when the caller didn't pass a usable version
|
|
267
|
+
* (preserves back-compat with older callers that omit the argument).
|
|
268
|
+
*
|
|
269
|
+
* Logs a single console.warn when a non-null/non-undefined input is rejected so
|
|
270
|
+
* a regression silently dropping the If-Match header is easy to spot in dev.
|
|
271
|
+
*/
|
|
272
|
+
function normalizeIfMatchVersion(version) {
|
|
273
|
+
if (version === undefined || version === null) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (typeof version === "number" && Number.isFinite(version) && version >= 0 && Number.isInteger(version)) {
|
|
277
|
+
return String(version);
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
console.warn(`normalizeIfMatchVersion: ignoring non-integer/negative version ${JSON.stringify(version)} — If-Match header will be omitted`);
|
|
281
|
+
} catch {
|
|
282
|
+
// best-effort logging only
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const ENTITY_KIND_COLLECTION = {
|
|
288
|
+
epic: "epics",
|
|
289
|
+
task: "tasks",
|
|
290
|
+
subtask: "subtasks",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Look up the current version of an entity from the live store snapshot.
|
|
295
|
+
*
|
|
296
|
+
* Resolved lazily at queue-executor fire time (rather than at action-enqueue
|
|
297
|
+
* time) so a second mutation enqueued while the first is in flight reads the
|
|
298
|
+
* post-success version — the server-acked snapshot landed via mutation
|
|
299
|
+
* response or SSE delta before processNext shifts the next mutation off the
|
|
300
|
+
* queue. Without this lazy read, rapid double-edits would carry stale
|
|
301
|
+
* If-Match versions and 409.
|
|
302
|
+
*
|
|
303
|
+
* @param {object} storeState - The live `model.store` mutable state object.
|
|
304
|
+
* @param {"epic"|"task"|"subtask"} kind
|
|
305
|
+
* @param {string} id
|
|
306
|
+
* @returns {number|undefined}
|
|
307
|
+
*/
|
|
308
|
+
function getCurrentVersion(storeState, kind, id) {
|
|
309
|
+
const collection = ENTITY_KIND_COLLECTION[kind];
|
|
310
|
+
if (!collection) return undefined;
|
|
311
|
+
const records = storeState?.snapshot?.[collection];
|
|
312
|
+
if (!Array.isArray(records)) return undefined;
|
|
313
|
+
const record = records.find((entry) => entry?.id === id);
|
|
314
|
+
return typeof record?.version === "number" ? record.version : undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
211
317
|
/**
|
|
212
318
|
* Create a serial mutation queue.
|
|
213
319
|
*
|
|
@@ -222,7 +328,7 @@ function createTimeoutError(method, path, timeoutMs) {
|
|
|
222
328
|
* }}
|
|
223
329
|
*/
|
|
224
330
|
export function createMutationQueue(model, rerender) {
|
|
225
|
-
/** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
|
|
331
|
+
/** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string, resolveIfMatch?: function }>} */
|
|
226
332
|
const queue = [];
|
|
227
333
|
let processing = false;
|
|
228
334
|
/** @type {Array<() => void>} */
|
|
@@ -255,6 +361,19 @@ export function createMutationQueue(model, rerender) {
|
|
|
255
361
|
// the request was in flight survive a rollback.
|
|
256
362
|
let inverseDelta = null;
|
|
257
363
|
|
|
364
|
+
// Resolve the If-Match version LAZILY at fire-time so a queued
|
|
365
|
+
// second mutation on the same entity sees the post-success version that
|
|
366
|
+
// landed via mutation response or SSE delta. Capturing at enqueue time
|
|
367
|
+
// would 409 every rapid double-edit.
|
|
368
|
+
let ifMatchVersion;
|
|
369
|
+
if (typeof mutation.resolveIfMatch === "function") {
|
|
370
|
+
try {
|
|
371
|
+
ifMatchVersion = mutation.resolveIfMatch();
|
|
372
|
+
} catch {
|
|
373
|
+
ifMatchVersion = undefined;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
258
377
|
try {
|
|
259
378
|
if (typeof mutation.optimistic === "function") {
|
|
260
379
|
const previousSnapshot = model.store.snapshot;
|
|
@@ -269,7 +388,7 @@ export function createMutationQueue(model, rerender) {
|
|
|
269
388
|
rerender();
|
|
270
389
|
}
|
|
271
390
|
|
|
272
|
-
const data = await mutation.request();
|
|
391
|
+
const data = await mutation.request({ ifMatchVersion });
|
|
273
392
|
|
|
274
393
|
if (data?.snapshot) {
|
|
275
394
|
model.replaceSnapshot(data.snapshot);
|
|
@@ -285,20 +404,42 @@ export function createMutationQueue(model, rerender) {
|
|
|
285
404
|
? { type: "success", message: mutation.successMessage }
|
|
286
405
|
: null;
|
|
287
406
|
} catch (error) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
407
|
+
const isStaleVersion = error?.code === "precondition_failed";
|
|
408
|
+
|
|
409
|
+
// Revert only the entities this mutation touched, but ALSO drop any
|
|
410
|
+
// entity whose live store version has already advanced past the
|
|
411
|
+
// optimistic version we sent: that means an SSE delta (or another
|
|
412
|
+
// queued mutation reconciliation) landed mid-flight and clobbering it
|
|
413
|
+
// with the pre-optimistic record would lose the user's most recent
|
|
414
|
+
// server-authoritative state.
|
|
415
|
+
const adjustedInverseDelta = inverseDelta
|
|
416
|
+
? stripUpToDateEntitiesFromInverse(inverseDelta, model.store?.snapshot, ifMatchVersion)
|
|
417
|
+
: null;
|
|
418
|
+
|
|
419
|
+
if (adjustedInverseDelta) {
|
|
420
|
+
model.applySnapshotDelta(adjustedInverseDelta);
|
|
292
421
|
}
|
|
293
422
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
423
|
+
if (isStaleVersion) {
|
|
424
|
+
// Typed `stale_version` notice — no retry button because replaying
|
|
425
|
+
// the optimistic payload against the post-advance state would 409
|
|
426
|
+
// again. The user needs to refresh to compose against latest.
|
|
427
|
+
model.store.notice = {
|
|
428
|
+
type: "warning",
|
|
429
|
+
code: "stale_version",
|
|
430
|
+
title: "Stale update",
|
|
431
|
+
message: "Updated by another session — refresh to load the latest version.",
|
|
432
|
+
};
|
|
433
|
+
} else {
|
|
434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
435
|
+
model.store.notice = {
|
|
436
|
+
type: "error",
|
|
437
|
+
title: "Action failed",
|
|
438
|
+
message,
|
|
439
|
+
retryLabel: "Retry",
|
|
440
|
+
retryMutationId: mutation.mutationId,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
302
443
|
|
|
303
444
|
if (typeof mutation.onError === "function") {
|
|
304
445
|
mutation.onError(error);
|
|
@@ -368,7 +509,18 @@ export function createApi(model, options) {
|
|
|
368
509
|
}
|
|
369
510
|
},
|
|
370
511
|
onError(error) {
|
|
371
|
-
|
|
512
|
+
// Stale-version 409s are NOT retryable — the captured optimistic
|
|
513
|
+
// payload was composed against pre-advance state and replaying it
|
|
514
|
+
// would 409 again. Skip lastFailedMutation so the retry path can't
|
|
515
|
+
// even be invoked, and surface the typed stale_version notice (which
|
|
516
|
+
// does not carry a retry button) so the UI stays consistent.
|
|
517
|
+
if (error?.code === "precondition_failed") {
|
|
518
|
+
if (lastFailedMutation?.mutationId === mutationId) {
|
|
519
|
+
lastFailedMutation = null;
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
lastFailedMutation = tagged;
|
|
523
|
+
}
|
|
372
524
|
if (typeof tagged.onError === "function") {
|
|
373
525
|
tagged.onError(error);
|
|
374
526
|
}
|
|
@@ -431,47 +583,94 @@ export function createApi(model, options) {
|
|
|
431
583
|
return true;
|
|
432
584
|
},
|
|
433
585
|
|
|
434
|
-
patchEpic(epicId, updates, optimistic) {
|
|
586
|
+
patchEpic(epicId, updates, optimistic, options) {
|
|
587
|
+
// Per-call override wins; otherwise read the live store at fire-time so
|
|
588
|
+
// queued back-to-back edits on the same entity carry the post-success
|
|
589
|
+
// version rather than a stale enqueue-time snapshot.
|
|
590
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
591
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
592
|
+
? () => explicitVersion
|
|
593
|
+
: () => getCurrentVersion(model.store, "epic", epicId);
|
|
435
594
|
enqueueMutation({
|
|
436
595
|
optimistic,
|
|
437
596
|
successMessage: "Epic saved.",
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
})
|
|
597
|
+
entityKind: "epic",
|
|
598
|
+
entityId: epicId,
|
|
599
|
+
resolveIfMatch,
|
|
600
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
601
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
602
|
+
return request(`/api/epics/${encodeURIComponent(epicId)}`, {
|
|
603
|
+
method: "PATCH",
|
|
604
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
605
|
+
body: JSON.stringify(updates),
|
|
606
|
+
});
|
|
607
|
+
},
|
|
442
608
|
});
|
|
443
609
|
},
|
|
444
610
|
|
|
445
|
-
patchTask(taskId, updates, optimistic) {
|
|
611
|
+
patchTask(taskId, updates, optimistic, options) {
|
|
612
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
613
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
614
|
+
? () => explicitVersion
|
|
615
|
+
: () => getCurrentVersion(model.store, "task", taskId);
|
|
446
616
|
enqueueMutation({
|
|
447
617
|
optimistic,
|
|
448
618
|
successMessage: "Task saved.",
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
})
|
|
619
|
+
entityKind: "task",
|
|
620
|
+
entityId: taskId,
|
|
621
|
+
resolveIfMatch,
|
|
622
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
623
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
624
|
+
return request(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
625
|
+
method: "PATCH",
|
|
626
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
627
|
+
body: JSON.stringify(updates),
|
|
628
|
+
});
|
|
629
|
+
},
|
|
453
630
|
});
|
|
454
631
|
},
|
|
455
632
|
|
|
456
|
-
patchSubtask(subtaskId, updates, optimistic) {
|
|
633
|
+
patchSubtask(subtaskId, updates, optimistic, options) {
|
|
634
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
635
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
636
|
+
? () => explicitVersion
|
|
637
|
+
: () => getCurrentVersion(model.store, "subtask", subtaskId);
|
|
457
638
|
enqueueMutation({
|
|
458
639
|
optimistic,
|
|
459
640
|
successMessage: "Subtask saved.",
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
})
|
|
641
|
+
entityKind: "subtask",
|
|
642
|
+
entityId: subtaskId,
|
|
643
|
+
resolveIfMatch,
|
|
644
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
645
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
646
|
+
return request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
|
|
647
|
+
method: "PATCH",
|
|
648
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
649
|
+
body: JSON.stringify(updates),
|
|
650
|
+
});
|
|
651
|
+
},
|
|
464
652
|
});
|
|
465
653
|
},
|
|
466
654
|
|
|
467
|
-
cascadeEpicStatus(epicId, status, optimistic) {
|
|
655
|
+
cascadeEpicStatus(epicId, status, optimistic, options) {
|
|
656
|
+
const explicitVersion = options?.ifMatchVersion;
|
|
657
|
+
const resolveIfMatch = explicitVersion !== undefined
|
|
658
|
+
? () => explicitVersion
|
|
659
|
+
: () => getCurrentVersion(model.store, "epic", epicId);
|
|
468
660
|
enqueueMutation({
|
|
469
661
|
optimistic,
|
|
470
662
|
successMessage: "Epic cascade status updated.",
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
})
|
|
663
|
+
entityKind: "epic",
|
|
664
|
+
entityId: epicId,
|
|
665
|
+
resolveIfMatch,
|
|
666
|
+
request: ({ ifMatchVersion } = {}) => {
|
|
667
|
+
const ifMatch = normalizeIfMatchVersion(ifMatchVersion);
|
|
668
|
+
return request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
|
|
669
|
+
method: "PATCH",
|
|
670
|
+
headers: ifMatch !== null ? { "if-match": ifMatch } : undefined,
|
|
671
|
+
body: JSON.stringify({ status }),
|
|
672
|
+
});
|
|
673
|
+
},
|
|
475
674
|
});
|
|
476
675
|
},
|
|
477
676
|
|
|
@@ -46,6 +46,21 @@ function normalizeOwner(value) {
|
|
|
46
46
|
return typeof value === "string" ? value : null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a server-provided version into a non-negative integer, or null if
|
|
51
|
+
* the value is missing/invalid. The board uses this to populate If-Match
|
|
52
|
+
* headers; non-integers and negative numbers are silently dropped here so the
|
|
53
|
+
* mutation queue treats the entity as version-less (back-compat path).
|
|
54
|
+
* @param {unknown} value
|
|
55
|
+
* @returns {number|null}
|
|
56
|
+
*/
|
|
57
|
+
function normalizeVersion(value) {
|
|
58
|
+
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/**
|
|
50
65
|
* @param {any[]} tasks
|
|
51
66
|
* @returns {Record<string, number>}
|
|
@@ -89,6 +104,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
89
104
|
owner: normalizeOwner(task.owner),
|
|
90
105
|
createdAt,
|
|
91
106
|
updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
|
|
107
|
+
version: normalizeVersion(task.version),
|
|
92
108
|
blockedBy: [],
|
|
93
109
|
blocks: [],
|
|
94
110
|
dependencyIds: [],
|
|
@@ -118,6 +134,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
118
134
|
owner: normalizeOwner(subtask.owner),
|
|
119
135
|
createdAt,
|
|
120
136
|
updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
|
|
137
|
+
version: normalizeVersion(subtask.version),
|
|
121
138
|
blockedBy: [],
|
|
122
139
|
blocks: [],
|
|
123
140
|
dependencyIds: [],
|
|
@@ -188,6 +205,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
188
205
|
status: normalizeStatus(String(epic.status ?? "todo")),
|
|
189
206
|
createdAt,
|
|
190
207
|
updatedAt: normalizeTimestamp(epic.updatedAt, createdAt),
|
|
208
|
+
version: normalizeVersion(epic.version),
|
|
191
209
|
taskIds: epicTasks.map((task) => task.id),
|
|
192
210
|
counts: deriveCounts(epicTasks),
|
|
193
211
|
searchText: "",
|
package/src/board/routes.ts
CHANGED
|
@@ -612,6 +612,16 @@ function preconditionFailedResponse(details: PreconditionFailedDetails): Respons
|
|
|
612
612
|
});
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
function preconditionRequiredResponse(): Response {
|
|
616
|
+
return jsonResponse(428, {
|
|
617
|
+
ok: false,
|
|
618
|
+
error: {
|
|
619
|
+
code: "precondition_required",
|
|
620
|
+
message: "If-Match header is required for PATCH requests",
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
615
625
|
function readIdempotencyKey(request: Request, body: Record<string, unknown>): string | null {
|
|
616
626
|
const headerKey = request.headers.get("x-trekoon-idempotency-key");
|
|
617
627
|
if (typeof headerKey === "string" && headerKey.trim().length > 0) {
|
|
@@ -705,12 +715,12 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
705
715
|
const body = await parseJsonBody(request);
|
|
706
716
|
const status = readRequiredString(body, "status");
|
|
707
717
|
const ifMatch = parseIfMatchHeader(request);
|
|
718
|
+
if (ifMatch === null) {
|
|
719
|
+
return preconditionRequiredResponse();
|
|
720
|
+
}
|
|
708
721
|
// CAS path: precondition is enforced inside the write transaction
|
|
709
|
-
// (see PreconditionFailedError catch below).
|
|
710
|
-
|
|
711
|
-
const plan = ifMatch !== null
|
|
712
|
-
? mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status)
|
|
713
|
-
: mutations.updateEpicStatusCascade(epicId, status);
|
|
722
|
+
// (see PreconditionFailedError catch below).
|
|
723
|
+
const plan = mutations.updateEpicStatusCascadeWithIfMatch(epicId, ifMatch, status);
|
|
714
724
|
return respondWithMutationDelta(domain, {
|
|
715
725
|
plan,
|
|
716
726
|
}, {
|
|
@@ -725,14 +735,15 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
725
735
|
const epicId = epicMatch[1] ?? "";
|
|
726
736
|
const body = await parseJsonBody(request);
|
|
727
737
|
const ifMatch = parseIfMatchHeader(request);
|
|
738
|
+
if (ifMatch === null) {
|
|
739
|
+
return preconditionRequiredResponse();
|
|
740
|
+
}
|
|
728
741
|
const epicInput = {
|
|
729
742
|
title: readOptionalString(body, "title"),
|
|
730
743
|
description: readOptionalString(body, "description"),
|
|
731
744
|
status: readOptionalString(body, "status"),
|
|
732
745
|
};
|
|
733
|
-
const epic = ifMatch
|
|
734
|
-
? mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput)
|
|
735
|
-
: mutations.updateEpic(epicId, epicInput);
|
|
746
|
+
const epic = mutations.updateEpicWithIfMatch(epicId, ifMatch, epicInput);
|
|
736
747
|
return respondWithMutationDelta(domain, { epic }, { epicIds: [epic.id] });
|
|
737
748
|
}
|
|
738
749
|
|
|
@@ -741,15 +752,16 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
741
752
|
const taskId = taskMatch[1] ?? "";
|
|
742
753
|
const body = await parseJsonBody(request);
|
|
743
754
|
const ifMatch = parseIfMatchHeader(request);
|
|
755
|
+
if (ifMatch === null) {
|
|
756
|
+
return preconditionRequiredResponse();
|
|
757
|
+
}
|
|
744
758
|
const taskInput = {
|
|
745
759
|
title: readOptionalString(body, "title"),
|
|
746
760
|
description: readOptionalString(body, "description"),
|
|
747
761
|
status: readOptionalString(body, "status"),
|
|
748
762
|
owner: readOptionalNullableString(body, "owner"),
|
|
749
763
|
};
|
|
750
|
-
const task = ifMatch
|
|
751
|
-
? mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput)
|
|
752
|
-
: mutations.updateTask(taskId, taskInput);
|
|
764
|
+
const task = mutations.updateTaskWithIfMatch(taskId, ifMatch, taskInput);
|
|
753
765
|
return respondWithMutationDelta(domain, { task }, { epicIds: [task.epicId], taskIds: [task.id] });
|
|
754
766
|
}
|
|
755
767
|
|
|
@@ -758,15 +770,16 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
758
770
|
const subtaskId = subtaskMatch[1] ?? "";
|
|
759
771
|
const body = await parseJsonBody(request);
|
|
760
772
|
const ifMatch = parseIfMatchHeader(request);
|
|
773
|
+
if (ifMatch === null) {
|
|
774
|
+
return preconditionRequiredResponse();
|
|
775
|
+
}
|
|
761
776
|
const subtaskInput = {
|
|
762
777
|
title: readOptionalString(body, "title"),
|
|
763
778
|
description: readOptionalString(body, "description"),
|
|
764
779
|
status: readOptionalString(body, "status"),
|
|
765
780
|
owner: readOptionalNullableString(body, "owner"),
|
|
766
781
|
};
|
|
767
|
-
const subtask = ifMatch
|
|
768
|
-
? mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput)
|
|
769
|
-
: mutations.updateSubtask(subtaskId, subtaskInput);
|
|
782
|
+
const subtask = mutations.updateSubtaskWithIfMatch(subtaskId, ifMatch, subtaskInput);
|
|
770
783
|
const task = domain.getTaskOrThrow(subtask.taskId);
|
|
771
784
|
return respondWithMutationDelta(domain, { subtask }, { epicIds: [task.epicId], taskIds: [task.id], subtaskIds: [subtask.id] });
|
|
772
785
|
}
|