@teamscala/service-boot 0.23.346 → 0.23.348
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/package.json
CHANGED
package/src/fn-routers/index.ts
CHANGED
|
@@ -32,12 +32,10 @@ import { publicAgreementRouter } from "./public-agreement-router.ts";
|
|
|
32
32
|
import { publicAssessmentRouter } from "./public-assessment-router.ts";
|
|
33
33
|
import { publicGptSessionRouter } from "./public-gpt-session-router.ts";
|
|
34
34
|
import { signInFlowRouter } from "./sign-in-flow-router.ts";
|
|
35
|
-
import { taskSyncRouter } from "./task-sync-router.ts";
|
|
36
35
|
import { tasksRouter } from "./tasks-router.ts";
|
|
37
36
|
import { userPreferencesRouter } from "./user-preferences-router.ts";
|
|
38
37
|
import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
|
|
39
38
|
import { workItemsRouter } from "./work-items-router.ts";
|
|
40
|
-
import { orchestratorRegistrationRouter } from "./orchestrator-registration-router.ts";
|
|
41
39
|
|
|
42
40
|
export function registerFnRouters(): void {
|
|
43
41
|
registerCustomRouter("fn", {
|
|
@@ -52,9 +50,7 @@ export function registerFnRouters(): void {
|
|
|
52
50
|
publicGptSession: publicGptSessionRouter,
|
|
53
51
|
signInFlow: signInFlowRouter,
|
|
54
52
|
tasks: tasksRouter,
|
|
55
|
-
taskSync: taskSyncRouter,
|
|
56
53
|
workItems: workItemsRouter,
|
|
57
|
-
orchestratorRegistration: orchestratorRegistrationRouter,
|
|
58
54
|
userPreferences: userPreferencesRouter,
|
|
59
55
|
portalPreferences: portalPreferencesRouter,
|
|
60
56
|
plans: plansRouter,
|
|
@@ -1,361 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Unit tests for the pure task-sync reconciliation logic (planReconcile +
|
|
7
|
-
* status mapping). The ORPC handlers only EXECUTE the plan against prisma; the
|
|
8
|
-
* matching / status / positioning decisions live here so they are unit-testable
|
|
9
|
-
* without a DB. Covers `what-must-have-a-test` for the public API.
|
|
10
|
-
*/
|
|
11
|
-
import { describe, it, expect } from "bun:test";
|
|
12
|
-
import {
|
|
13
|
-
resolveTaskUpdateTarget,
|
|
14
|
-
planReconcile,
|
|
15
|
-
normalizeStatus,
|
|
16
|
-
resolveProjectedStatus,
|
|
17
|
-
subtaskKey,
|
|
18
|
-
STATUS_TO_WORK_ITEM,
|
|
19
|
-
} from "./task-sync-projection.ts";
|
|
20
|
-
import { type TaskListItem, upsertFromTaskListInput } from "./task-sync-router.ts";
|
|
21
|
-
import * as v from "valibot";
|
|
22
|
-
|
|
23
|
-
interface WorkItemLike {
|
|
24
|
-
id: string;
|
|
25
|
-
kind: string;
|
|
26
|
-
parent_id: string | null;
|
|
27
|
-
organisation_id: string;
|
|
28
|
-
title: string;
|
|
29
|
-
status: string | null;
|
|
30
|
-
position: string | null;
|
|
31
|
-
completed: boolean | null;
|
|
32
|
-
kind_data: Record<string, unknown>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function subtask(opts: Partial<WorkItemLike> & { id: string; title: string }): WorkItemLike {
|
|
36
|
-
return {
|
|
37
|
-
kind: "subtask",
|
|
38
|
-
parent_id: "task-1",
|
|
39
|
-
organisation_id: "org-1",
|
|
40
|
-
status: "todo",
|
|
41
|
-
position: null,
|
|
42
|
-
completed: false,
|
|
43
|
-
kind_data: {},
|
|
44
|
-
...opts,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const item = (content: string, status: TaskListItem["status"] = "pending", mark?: string): TaskListItem => ({
|
|
49
|
-
content,
|
|
50
|
-
status,
|
|
51
|
-
...(mark !== undefined ? { mark } : {}),
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("task-sync status mapping", () => {
|
|
55
|
-
it("maps normalized status to the work_items DB enum (hyphenated in-progress)", () => {
|
|
56
|
-
expect(STATUS_TO_WORK_ITEM.pending).toBe("todo");
|
|
57
|
-
expect(STATUS_TO_WORK_ITEM.in_progress).toBe("in-progress");
|
|
58
|
-
expect(STATUS_TO_WORK_ITEM.completed).toBe("done");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("normalizes work_items status back to the CLI form", () => {
|
|
62
|
-
expect(normalizeStatus("todo")).toBe("pending");
|
|
63
|
-
expect(normalizeStatus("in-progress")).toBe("in_progress");
|
|
64
|
-
expect(normalizeStatus("done")).toBe("completed");
|
|
65
|
-
expect(normalizeStatus("active")).toBe("in_progress");
|
|
66
|
-
expect(normalizeStatus(null)).toBe("pending");
|
|
67
|
-
expect(normalizeStatus("unknown-state")).toBe("pending");
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("task-sync subtaskKey", () => {
|
|
72
|
-
it("prefers a stable mark over content when present", () => {
|
|
73
|
-
expect(subtaskKey({ mark: "m1", content: "ignored", title: "ignored" })).toBe("m1");
|
|
74
|
-
});
|
|
75
|
-
it("falls back to content when no mark", () => {
|
|
76
|
-
expect(subtaskKey({ content: "Build it", title: "Build it" })).toBe("Build it");
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
describe("task-sync planReconcile", () => {
|
|
81
|
-
it("creates every incoming item when there are no existing subtasks", () => {
|
|
82
|
-
const plan = planReconcile([], [item("A"), item("B", "in_progress")]);
|
|
83
|
-
expect(plan.toCreate).toHaveLength(2);
|
|
84
|
-
expect(plan.toCreate[0]).toMatchObject({ content: "A", status: "todo", position: "0", completed: false });
|
|
85
|
-
expect(plan.toCreate[1]).toMatchObject({ content: "B", status: "in-progress", position: "1", completed: false });
|
|
86
|
-
expect(plan.toUpdate).toHaveLength(0);
|
|
87
|
-
expect(plan.toDelete).toHaveLength(0);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("emits no ops when existing already matches incoming (idempotent)", () => {
|
|
91
|
-
const existing = [
|
|
92
|
-
subtask({ id: "1", title: "A", status: "todo", position: "0", completed: false }),
|
|
93
|
-
];
|
|
94
|
-
const plan = planReconcile(existing, [item("A")]);
|
|
95
|
-
expect(plan.toCreate).toHaveLength(0);
|
|
96
|
-
expect(plan.toUpdate).toHaveLength(0);
|
|
97
|
-
expect(plan.toDelete).toHaveLength(0);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("updates status + completed when a matched item changed", () => {
|
|
101
|
-
const existing = [subtask({ id: "1", title: "A", status: "todo", position: "0", completed: false })];
|
|
102
|
-
const plan = planReconcile(existing, [item("A", "completed")]);
|
|
103
|
-
expect(plan.toUpdate).toEqual([
|
|
104
|
-
expect.objectContaining({ id: "1", status: "done", completed: true, position: "0" }),
|
|
105
|
-
]);
|
|
106
|
-
expect(plan.toCreate).toHaveLength(0);
|
|
107
|
-
expect(plan.toDelete).toHaveLength(0);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("updates position on reorder (same content, different index)", () => {
|
|
111
|
-
const existing = [
|
|
112
|
-
subtask({ id: "1", title: "A", status: "todo", position: "0" }),
|
|
113
|
-
subtask({ id: "2", title: "B", status: "todo", position: "1" }),
|
|
114
|
-
];
|
|
115
|
-
const plan = planReconcile(existing, [item("B"), item("A")]);
|
|
116
|
-
expect(plan.toUpdate.map((u) => [u.id, u.position])).toEqual([
|
|
117
|
-
["2", "0"],
|
|
118
|
-
["1", "1"],
|
|
119
|
-
]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("deletes existing subtasks absent from the incoming list", () => {
|
|
123
|
-
const existing = [
|
|
124
|
-
subtask({ id: "1", title: "A", position: "0" }),
|
|
125
|
-
subtask({ id: "2", title: "B", position: "1" }),
|
|
126
|
-
];
|
|
127
|
-
const plan = planReconcile(existing, [item("A")]);
|
|
128
|
-
expect(plan.toDelete).toEqual(["2"]);
|
|
129
|
-
expect(plan.toUpdate).toHaveLength(0);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("matches by stable mark across a content rename, updating content", () => {
|
|
133
|
-
const existing = [
|
|
134
|
-
subtask({ id: "1", title: "Old text", status: "todo", position: "0", kind_data: { task_sync: { mark: "m1" } } }),
|
|
135
|
-
];
|
|
136
|
-
const plan = planReconcile(existing, [item("New text", "pending", "m1")]);
|
|
137
|
-
expect(plan.toUpdate).toEqual([
|
|
138
|
-
expect.objectContaining({ id: "1", content: "New text" }),
|
|
139
|
-
]);
|
|
140
|
-
expect(plan.toCreate).toHaveLength(0);
|
|
141
|
-
expect(plan.toDelete).toHaveLength(0);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("ignores blank incoming content", () => {
|
|
145
|
-
const plan = planReconcile([], [item(" "), item("Real")]);
|
|
146
|
-
expect(plan.toCreate).toHaveLength(1);
|
|
147
|
-
expect(plan.toCreate[0].content).toBe("Real");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("carries source into kind_data.task_sync for traceability", () => {
|
|
151
|
-
const plan = planReconcile([], [item("A")], "claude-code");
|
|
152
|
-
expect(plan.toCreate[0].kind_data).toEqual({ task_sync: { mark: undefined, source: "claude-code" } });
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// --- resolveTaskUpdateTarget (added 2026-07-28) ----------------------------
|
|
157
|
-
// The gap these close: the update path matched ONLY on kind_data.task_sync.mark,
|
|
158
|
-
// so a subtask whose mark was never stamped — or a TaskCreate that silently
|
|
159
|
-
// produced no row at all — was unaddressable forever. Every later update for it
|
|
160
|
-
// was dropped as `no-matching-subtask`: 26 of 30 tasksync discards in six hours,
|
|
161
|
-
// across seven distinct lane sessions, all one reason. One lane had marks "1"
|
|
162
|
-
// and "3" stamped correctly while "2" was missing entirely, so a whole work item
|
|
163
|
-
// left no trace in the fleet's record.
|
|
164
|
-
describe("task-sync resolveTaskUpdateTarget", () => {
|
|
165
|
-
const marked = (id: string, title: string, mark?: string) => ({
|
|
166
|
-
id,
|
|
167
|
-
title,
|
|
168
|
-
kind_data: mark ? { task_sync: { mark } } : {},
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("matches by mark when stamped", () => {
|
|
172
|
-
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "1" });
|
|
173
|
-
expect(r).toEqual({ op: "update", id: "a" });
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("falls back to exact title when the mark was never stamped", () => {
|
|
177
|
-
// The unmarked-row case: mark lookup misses, title still identifies it.
|
|
178
|
-
const r = resolveTaskUpdateTarget([marked("a", "Alpha")], {
|
|
179
|
-
taskId: "1",
|
|
180
|
-
subject: "Alpha",
|
|
181
|
-
});
|
|
182
|
-
expect(r).toEqual({ op: "update", id: "a" });
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("does not cross-match a different task's mark", () => {
|
|
186
|
-
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "2" });
|
|
187
|
-
expect(r).toEqual({ op: "discard" });
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("heals a missed create when the event carries a subject", () => {
|
|
191
|
-
// The mark-"2"-never-written case: nothing to match, but the subject is
|
|
192
|
-
// enough to rebuild the row instead of orphaning the task forever.
|
|
193
|
-
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], {
|
|
194
|
-
taskId: "2",
|
|
195
|
-
subject: "Bravo",
|
|
196
|
-
});
|
|
197
|
-
expect(r).toEqual({ op: "heal", title: "Bravo" });
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("discards only when there is no subject to rebuild a title from", () => {
|
|
201
|
-
const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "2" });
|
|
202
|
-
expect(r).toEqual({ op: "discard" });
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("prefers the mark over a same-subject title collision", () => {
|
|
206
|
-
const r = resolveTaskUpdateTarget(
|
|
207
|
-
[marked("a", "Same", "1"), marked("b", "Same", "2")],
|
|
208
|
-
{ taskId: "2", subject: "Same" },
|
|
209
|
-
);
|
|
210
|
-
expect(r).toEqual({ op: "update", id: "b" });
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("ignores a blank subject rather than healing a titleless row", () => {
|
|
214
|
-
const r = resolveTaskUpdateTarget([], { taskId: "9", subject: " " });
|
|
215
|
-
expect(r).toEqual({ op: "discard" });
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// --- rename must not orphan an unmarked row (added 2026-07-28) --------------
|
|
220
|
-
describe("a rename cannot orphan an unmarked subtask", () => {
|
|
221
|
-
// Matching is mark-first, then EXACT TITLE. A row that has never been stamped
|
|
222
|
-
// can therefore only ever be found by its title — so the moment a task is
|
|
223
|
-
// RENAMED, neither match succeeds, the event heals into a NEW row, and the
|
|
224
|
-
// pre-rename row is orphaned at whatever status it held. Nothing in the
|
|
225
|
-
// single-event path will ever address it again.
|
|
226
|
-
//
|
|
227
|
-
// Observed on this lane 2026-07-28: three subtasks stranded at `todo` and
|
|
228
|
-
// `in-progress` under their OLD titles while the CLI's own list read empty,
|
|
229
|
-
// which is what made the stop gate refuse a lane that had genuinely finished.
|
|
230
|
-
// `task-projection-converges` requires the projection to express every state a
|
|
231
|
-
// task reaches; a row it can no longer address expresses nothing.
|
|
232
|
-
|
|
233
|
-
it("an unmarked row is still reachable by exact title", () => {
|
|
234
|
-
// The pre-condition for the whole failure: without a mark, TITLE is the
|
|
235
|
-
// only handle, so it works right up until the title changes.
|
|
236
|
-
const existing = [{ id: "row-1", title: "Original title", kind_data: null }];
|
|
237
|
-
expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Original title" })).toEqual({
|
|
238
|
-
op: "update",
|
|
239
|
-
id: "row-1",
|
|
240
|
-
});
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("renaming an UNMARKED row heals into a new one — the orphan", () => {
|
|
244
|
-
// This is the defect, asserted so the cost of NOT stamping is explicit: the
|
|
245
|
-
// update does not reach row-1, which stays at its old status forever.
|
|
246
|
-
const existing = [{ id: "row-1", title: "Original title", kind_data: null }];
|
|
247
|
-
expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Renamed title" })).toEqual({
|
|
248
|
-
op: "heal",
|
|
249
|
-
title: "Renamed title",
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("once marked, a rename updates the SAME row", () => {
|
|
254
|
-
// The fix: the first title-match stamps the mark, so every later rename is
|
|
255
|
-
// mark-addressable and lands on the original row. The heal path already
|
|
256
|
-
// stamped on create; the update path was the missing half.
|
|
257
|
-
const existing = [
|
|
258
|
-
{ id: "row-1", title: "Original title", kind_data: { task_sync: { mark: "7" } } },
|
|
259
|
-
];
|
|
260
|
-
expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Renamed title" })).toEqual({
|
|
261
|
-
op: "update",
|
|
262
|
-
id: "row-1",
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("the update path stamps the mark when the row has none", async () => {
|
|
267
|
-
// Asserted against the source because the write is inside an ORPC handler
|
|
268
|
-
// that needs a live prisma context. The regression is the absence of a
|
|
269
|
-
// stamp, which no pure test on resolveTaskUpdateTarget can observe.
|
|
270
|
-
const src = await Bun.file(
|
|
271
|
-
new URL("./task-sync-router.ts", import.meta.url).pathname,
|
|
272
|
-
).text();
|
|
273
|
-
expect(src).toContain("const alreadyMarked");
|
|
274
|
-
expect(src).toContain("if (taskId && !alreadyMarked)");
|
|
275
|
-
// Prior kind_data must be preserved — the lane binding and any other
|
|
276
|
-
// sibling keys live there, and clobbering them would trade this bug for a
|
|
277
|
-
// worse one.
|
|
278
|
-
expect(src).toContain("...priorKindData");
|
|
279
|
-
expect(src).toContain("...priorSync");
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// --- upsertFromTaskList input schema (added 2026-07-29) ---------------------
|
|
284
|
-
// The gap this closes: upsertFromTaskList read `input.lane_session`, but oRPC
|
|
285
|
-
// leaves `input` empty for a schema-less procedure, so EVERY whole-list publish
|
|
286
|
-
// 400'd with "requires lane_session" — confirmed by curl (a body containing
|
|
287
|
-
// lane_session still 400'd). The pi/opencode adapter was always sending a valid
|
|
288
|
-
// payload; the server never parsed it. Adding `.input()` is the fix.
|
|
289
|
-
describe("task-sync upsertFromTaskListInput schema", () => {
|
|
290
|
-
it("accepts a valid whole-list payload (the pi/opencode adapter shape)", () => {
|
|
291
|
-
const out = v.parse(upsertFromTaskListInput, {
|
|
292
|
-
lane_session: "ses-1",
|
|
293
|
-
task_list: [{ content: "Do X", status: "in_progress" }],
|
|
294
|
-
source: "pi",
|
|
295
|
-
});
|
|
296
|
-
expect(out.lane_session).toBe("ses-1");
|
|
297
|
-
expect(out.task_list).toHaveLength(1);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it("accepts an optional mark on items", () => {
|
|
301
|
-
const out = v.parse(upsertFromTaskListInput, {
|
|
302
|
-
lane_session: "ses-1",
|
|
303
|
-
task_list: [{ content: "Do X", status: "pending", mark: "m1" }],
|
|
304
|
-
});
|
|
305
|
-
expect(out.task_list[0].mark).toBe("m1");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
it("rejects a payload missing lane_session (the field the procedure requires)", () => {
|
|
309
|
-
expect(() => v.parse(upsertFromTaskListInput, { task_list: [] })).toThrow();
|
|
310
|
-
});
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
describe("a blocked row survives a CLI list that cannot express it", () => {
|
|
314
|
-
it("an incoming todo does NOT overwrite a blocked row", () => {
|
|
315
|
-
// The CLI emits todo/pending for every status it cannot represent, so a
|
|
316
|
-
// todo arriving for a blocked row asserts nothing about the work.
|
|
317
|
-
expect(resolveProjectedStatus("blocked", "todo")).toBe("blocked");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it("real transitions the CLI CAN express still win", () => {
|
|
321
|
-
// in-progress and done are genuine user signals, not projection artifacts.
|
|
322
|
-
expect(resolveProjectedStatus("blocked", "in-progress")).toBe("in-progress");
|
|
323
|
-
expect(resolveProjectedStatus("blocked", "done")).toBe("done");
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it("a non-blocked row is unaffected", () => {
|
|
327
|
-
expect(resolveProjectedStatus("todo", "in-progress")).toBe("in-progress");
|
|
328
|
-
expect(resolveProjectedStatus("in-progress", "done")).toBe("done");
|
|
329
|
-
expect(resolveProjectedStatus("done", "todo")).toBe("todo");
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("a missing current status never resurrects a block", () => {
|
|
333
|
-
expect(resolveProjectedStatus(null, "todo")).toBe("todo");
|
|
334
|
-
expect(resolveProjectedStatus(undefined, "todo")).toBe("todo");
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
it("planReconcile leaves a blocked subtask blocked when the list says pending", () => {
|
|
338
|
-
// The end-to-end shape: this is what re-blocked the lane three times.
|
|
339
|
-
const plan = planReconcile(
|
|
340
|
-
[
|
|
341
|
-
{
|
|
342
|
-
id: "row-1",
|
|
343
|
-
kind: "subtask",
|
|
344
|
-
parent_id: "task-1",
|
|
345
|
-
organisation_id: "org-1",
|
|
346
|
-
title: "Deploy the fix",
|
|
347
|
-
status: "blocked",
|
|
348
|
-
position: "0",
|
|
349
|
-
completed: false,
|
|
350
|
-
kind_data: { task_sync: { mark: "1" } },
|
|
351
|
-
},
|
|
352
|
-
],
|
|
353
|
-
[{ content: "Deploy the fix", status: "pending" }],
|
|
354
|
-
"claude-code",
|
|
355
|
-
);
|
|
356
|
-
const update = plan.toUpdate.find((u) => u.id === "row-1");
|
|
357
|
-
|
|
358
|
-
// Either untouched, or updated while KEEPING blocked — never downgraded.
|
|
359
|
-
expect(update?.status ?? "blocked").toBe("blocked");
|
|
360
|
-
});
|
|
361
|
-
});
|