@teamscala/service-boot 0.23.293 → 0.23.295

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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.293",
9
+ "version": "0.23.295",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -24,6 +24,7 @@
24
24
  import { createHash } from "node:crypto";
25
25
  import { existsSync } from "node:fs";
26
26
  import { dirname, join, relative, resolve } from "node:path";
27
+ import { firstPartyScopeAlternation } from "@teamscala/os/workspace-topology/first-party-scopes";
27
28
 
28
29
  // Resolve + hash the content of every `@import` in app.css's @teamscala/*
29
30
  // stylesheet chain — INCLUDING transitive @imports (generic-ui/styles.css
@@ -59,7 +60,7 @@ export async function hashResolvedPackageStylesheets(
59
60
  }
60
61
  };
61
62
  for (const spec of [
62
- ...appCss.matchAll(/@import\s+["'](@teamgbg\/[^"']+)["']/g),
63
+ ...appCss.matchAll(new RegExp(`@import\\s+["']((?:${firstPartyScopeAlternation()})\\/[^"']+)['"]`, "g")),
63
64
  ].map((m) => m[1].trim())) {
64
65
  await hashSpec(spec, join(cwd, "src"));
65
66
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A task CREATED already finished must project as done, not open.
3
+ *
4
+ * `TaskCreate({status:"completed"})` is the normal way to record work performed
5
+ * after the fact. The create path hardcoded `status: "todo"`, so such a task
6
+ * landed OPEN and the stop gate then blocked the lane on a task it had itself
7
+ * recorded as done — observed twice on 2026-07-31, each needing a manual
8
+ * work_items update to clear. The UPDATE path already mapped through
9
+ * STATUS_TO_WORK_ITEM; only create dropped it.
10
+ *
11
+ * The derivation is asserted directly because that is the whole change: the map
12
+ * lookup plus the `?? "todo"` default. The default is what makes the fix safe
13
+ * regardless of whether the hook actually sends the field — correct if it does,
14
+ * byte-identical to the old behaviour if it does not.
15
+ */
16
+ import { expect, test } from "bun:test";
17
+ import { STATUS_TO_WORK_ITEM } from "./task-sync-projection.ts";
18
+
19
+ type Status = "pending" | "in_progress" | "completed";
20
+ const derive = (s: unknown) => STATUS_TO_WORK_ITEM[s as Status] ?? "todo";
21
+
22
+ test("created completed -> done (the case that blocked the stop gate)", () => {
23
+ expect(derive("completed")).toBe("done");
24
+ expect(derive("completed") === "done").toBe(true);
25
+ });
26
+
27
+ test("created in_progress -> in-progress (DB enum uses the hyphen)", () => {
28
+ expect(derive("in_progress")).toBe("in-progress");
29
+ });
30
+
31
+ test("created pending -> todo", () => {
32
+ expect(derive("pending")).toBe("todo");
33
+ });
34
+
35
+ test("ABSENT status -> todo — the fix is inert when the hook omits the field", () => {
36
+ // This is the safety property. If the payload carries no status the lookup
37
+ // misses and the default reproduces the previous hardcoded behaviour exactly,
38
+ // so shipping this cannot regress any existing create path.
39
+ expect(derive(undefined)).toBe("todo");
40
+ expect(derive(null)).toBe("todo");
41
+ });
42
+
43
+ test("unknown status -> todo rather than throwing", () => {
44
+ // A CLI sending a status this platform does not model must degrade to open,
45
+ // never crash the hook — a task-sync throw takes the lane's whole list down.
46
+ expect(derive("archived")).toBe("todo");
47
+ expect(derive(42)).toBe("todo");
48
+ });
49
+
50
+ test("only the done mapping sets completed", () => {
51
+ // `completed` is a separate column from `status`; deriving it from the same
52
+ // value keeps the two from disagreeing (a done row with completed=false reads
53
+ // as open to every consumer that checks the boolean).
54
+ for (const s of ["pending", "in_progress"] as const) {
55
+ expect(derive(s) === "done").toBe(false);
56
+ }
57
+ expect(derive("completed") === "done").toBe(true);
58
+ });
@@ -290,7 +290,7 @@ async function ensureLaneTask(
290
290
  }
291
291
 
292
292
  async function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null> {
293
- return prisma.work_items.findFirst({
293
+ const active = await prisma.work_items.findFirst({
294
294
  where: {
295
295
  kind: "task",
296
296
  status: "active",
@@ -305,6 +305,31 @@ async function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkIte
305
305
  // unwritable; this makes the read stable regardless.
306
306
  orderBy: { created_at: "asc" },
307
307
  });
308
+ if (active) return active;
309
+
310
+ // FALL BACK TO AN ARCHIVED PARENT FOR THE SAME SESSION.
311
+ //
312
+ // Closing a run archives the lane's parent (close-run.ts) specifically to
313
+ // drop it from the active match set above. That is correct when the LANE is
314
+ // gone, and wrong whenever the run record closes while the lane keeps
315
+ // running — which happens on a tmux-server restart (runs are reaped, panes
316
+ // are restored) and whenever an orchestrator closes a run on a stale read.
317
+ // The lane then has a full task list that this function cannot see, so
318
+ // callers mint a FRESH parent, the old list is orphaned, and the compliance
319
+ // watchdog reports "working with NO task list" at a lane holding 85 tasks.
320
+ // That is the "KNOWN ADJACENT DEFECT" recorded on the lane-working-has-task-list
321
+ // guard row, observed live 2026-07-31.
322
+ //
323
+ // Safe because a session id is minted per lane spawn and never reused, so an
324
+ // archived parent under this session belongs to THIS lane. Newest-first.
325
+ return prisma.work_items.findFirst({
326
+ where: {
327
+ kind: "task",
328
+ status: "archived",
329
+ kind_data: { path: ["lane", "session"], equals: session },
330
+ },
331
+ orderBy: { created_at: "desc" },
332
+ });
308
333
  }
309
334
 
310
335
  /** Match key for a subtask: stable mark when the CLI provides one, else content, else title. Uses || (not ??) so an empty-string content (passed for existing rows that key by title) falls through. */
@@ -671,6 +696,8 @@ export const taskSyncRouter = {
671
696
  task_subject?: unknown;
672
697
  task_id?: unknown;
673
698
  hook_event_name?: unknown;
699
+ // Created-already-done must not project OPEN. Absent -> old behaviour.
700
+ status?: unknown;
674
701
  };
675
702
  // TaskCompleted BEFORE the TaskCreated branch. Both dedicated events carry
676
703
  // `task_subject`, so the subject-presence test below cannot tell them apart
@@ -727,6 +754,8 @@ export const taskSyncRouter = {
727
754
  const task2 = await ensureLaneTask(prisma, session, headerPane);
728
755
  if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
729
756
  const createdId = String(createdEvt.task_id ?? "");
757
+ const createdStatus =
758
+ STATUS_TO_WORK_ITEM[createdEvt.status as TaskListItem["status"]] ?? "todo";
730
759
  const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
731
760
  // IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
732
761
  // --retry-connrefused`, so one TaskCreate can arrive twice (observed
@@ -754,8 +783,8 @@ export const taskSyncRouter = {
754
783
  parent_id: task2.id,
755
784
  organisation_id: task2.organisation_id,
756
785
  title: subject,
757
- status: "todo",
758
- completed: false,
786
+ status: createdStatus,
787
+ completed: createdStatus === "done",
759
788
  position: String(existing.length),
760
789
  kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
761
790
  },