@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
@@ -1062,5 +1062,5 @@
1062
1062
  "repository": "https://github.com/teamgbg/service-boot",
1063
1063
  "sideEffects": false,
1064
1064
  "type": "module",
1065
- "version": "0.23.346"
1065
+ "version": "0.23.348"
1066
1066
  }
@@ -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
- });