@teamscala/service-boot 0.23.291 → 0.23.293

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.
@@ -41,6 +41,15 @@ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
41
41
  import { authed, type Context } from "./auth-router.ts";
42
42
  import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
43
43
  import * as v from "valibot";
44
+ import {
45
+ STATUS_TO_WORK_ITEM,
46
+ normalizeStatus,
47
+ resolveProjectedStatus,
48
+ subtaskKey,
49
+ resolveTaskUpdateTarget,
50
+ planReconcile,
51
+ type ReconcilePlan,
52
+ } from "./task-sync-projection.ts";
44
53
 
45
54
  /** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
46
55
  export interface TaskListItem {
@@ -92,7 +101,7 @@ export interface LaneIdentity {
92
101
  organisation_id?: string;
93
102
  }
94
103
 
95
- interface WorkItem {
104
+ export interface WorkItem {
96
105
  id: string;
97
106
  kind: string;
98
107
  parent_id: string | null;
@@ -127,26 +136,6 @@ export function untrackedRunForPane(pane: string) {
127
136
  };
128
137
  }
129
138
 
130
- // TodoWrite-style status (the normalized form) → work_items.status (DB enum).
131
- // The DB enum uses a hyphen for the active state ("in-progress"); see
132
- // COLUMN_ENUMS["work_items.status"] in db-validation.
133
- export const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
134
- pending: "todo",
135
- in_progress: "in-progress",
136
- completed: "done",
137
- };
138
- // work_items.status → normalized. Unknown/internal statuses collapse to pending.
139
- const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
140
- todo: "pending",
141
- "in-progress": "in_progress",
142
- active: "in_progress",
143
- done: "completed",
144
- completed: "completed",
145
- };
146
-
147
- export function normalizeStatus(s: string | null): TaskListItem["status"] {
148
- return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
149
- }
150
139
 
151
140
  /**
152
141
  * Resolve the org a lane's task is created under. Explicit when the lane knows
@@ -372,121 +361,6 @@ export function classifyTaskHookEvent(evt: {
372
361
  return "tool";
373
362
  }
374
363
 
375
- export function subtaskKey(item: { mark?: string; content: string; title: string }): string {
376
- return item.mark || item.content || item.title;
377
- }
378
-
379
- /**
380
- * Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
381
- * event address, and what should happen when none does. Extracted for the same
382
- * reason planReconcile was — the handler should only EXECUTE, so the matching
383
- * rule is unit-testable.
384
- *
385
- * Three resolutions:
386
- * - `update` — matched by mark, or (mark absent/unstamped) by exact title.
387
- * - `heal` — nothing matched but the event carries a subject, so the row can
388
- * be reconstructed. Covers a TaskCreate that silently produced no
389
- * subtask, which previously orphaned the task forever.
390
- * - `discard` — nothing matched and there is no subject to rebuild a title from.
391
- *
392
- * Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
393
- * "2" was never written at all, and every later update for it was dropped as
394
- * `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
395
- * distinct lane sessions, were that one reason.
396
- */
397
- export function resolveTaskUpdateTarget(
398
- existing: Array<{ id: string; title: string; kind_data?: Record<string, unknown> | null }>,
399
- evt: { taskId: string; subject?: string },
400
- ): { op: "update"; id: string } | { op: "heal"; title: string } | { op: "discard" } {
401
- const byMark = existing.find(
402
- (s) =>
403
- evt.taskId &&
404
- (s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark === evt.taskId,
405
- );
406
- if (byMark) return { op: "update", id: byMark.id };
407
- const subject = (evt.subject ?? "").trim();
408
- if (subject) {
409
- const byTitle = existing.find((s) => s.title === subject);
410
- if (byTitle) return { op: "update", id: byTitle.id };
411
- return { op: "heal", title: subject };
412
- }
413
- return { op: "discard" };
414
- }
415
-
416
- /**
417
- * Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
418
- * (current) task list, compute the create/update/delete ops that make the
419
- * subtask set match the list — WITHOUT touching prisma. Extracted so the
420
- * matching/status/positioning logic is unit-testable (the handler only executes
421
- * the plan). Matching is by stable mark when the CLI provides one, else by
422
- * content. Position follows incoming index, so a reorder is an update.
423
- */
424
- export interface ReconcilePlan {
425
- toCreate: Array<{
426
- content: string;
427
- status: string;
428
- completed: boolean;
429
- position: string;
430
- kind_data: Record<string, unknown>;
431
- }>;
432
- toUpdate: Array<{
433
- id: string;
434
- content: string;
435
- status: string;
436
- completed: boolean;
437
- position: string;
438
- kind_data: Record<string, unknown>;
439
- }>;
440
- toDelete: string[];
441
- }
442
-
443
- export function planReconcile(
444
- existing: WorkItem[],
445
- incoming: TaskListItem[],
446
- source?: string,
447
- ): ReconcilePlan {
448
- const byKey = new Map<string, WorkItem>();
449
- for (const s of existing) {
450
- const mark = (s.kind_data?.task_sync as { mark?: string } | undefined)?.mark;
451
- byKey.set(subtaskKey({ mark, content: "", title: s.title }), s);
452
- }
453
- const toCreate: ReconcilePlan["toCreate"] = [];
454
- const toUpdate: ReconcilePlan["toUpdate"] = [];
455
- const seen = new Set<string>();
456
- for (let i = 0; i < incoming.length; i++) {
457
- const item = incoming[i];
458
- const content = (item.content ?? "").trim();
459
- if (!content) continue;
460
- const key = subtaskKey({ mark: item.mark, content, title: content });
461
- const status = STATUS_TO_WORK_ITEM[item.status] ?? "todo";
462
- const completed = item.status === "completed";
463
- const position = String(i);
464
- const taskSync = { task_sync: { mark: item.mark, source } };
465
- const match = byKey.get(key);
466
- if (match) {
467
- seen.add(match.id);
468
- const changed =
469
- match.title !== content ||
470
- match.status !== status ||
471
- (match.completed ?? false) !== completed ||
472
- match.position !== position;
473
- if (changed) {
474
- toUpdate.push({
475
- id: match.id,
476
- content,
477
- status,
478
- completed,
479
- position,
480
- kind_data: { ...match.kind_data, ...taskSync },
481
- });
482
- }
483
- } else {
484
- toCreate.push({ content, status, completed, position, kind_data: taskSync });
485
- }
486
- }
487
- const toDelete = existing.filter((s) => !seen.has(s.id)).map((s) => s.id);
488
- return { toCreate, toUpdate, toDelete };
489
- }
490
364
 
491
365
  /**
492
366
  * Execute a reconcile against prisma for a lane's task — the shared write path
@@ -501,10 +375,31 @@ async function reconcileTaskList(
501
375
  laneSession: string,
502
376
  list: TaskListItem[],
503
377
  source?: string,
378
+ pane?: string | null,
504
379
  ): Promise<{ task_id: string; created: number; updated: number; deleted: number; total: number }> {
505
- const task = await resolveLaneTask(prisma, laneSession);
380
+ // BIND ON DEMAND — this path used to `resolveLaneTask` and THROW, which is the
381
+ // exact failure `ensureLaneTask` was written to make impossible (see its
382
+ // comment above, and the operator ruling it cites: "ALL lanes can comply,
383
+ // there is no reason why a tab cant comply"). The backstop existed and this
384
+ // caller did not use it, so a lane whose SessionStart bindLane never ran — or
385
+ // ran while the endpoint was failing — could never sync a task list again.
386
+ // Not degraded: permanently, with a 404 on every attempt and no path to
387
+ // self-recover.
388
+ //
389
+ // Measured 2026-07-30: a live lane reported ~9 consecutive failures over
390
+ // hours and correctly diagnosed its own lane-compliance flag as a false
391
+ // positive it was structurally unable to clear. The gate was telling it to
392
+ // keep a task list while the only endpoint that records one refused it.
393
+ //
394
+ // A lane calling this HAS everything the bind needs — its session, its pane,
395
+ // its list — so requiring a prior call is a sequencing demand with no
396
+ // informational content. `friction-is-a-stop-condition`: the compliant path
397
+ // must exist, or the rule is unenforceable against the lane it binds.
398
+ const task = await ensureLaneTask(prisma, laneSession, pane ?? null);
506
399
  if (!task) {
507
- throw new Error(`No task bound to session ${laneSession}. Call taskSync.bindLane first (SessionStart adapter).`);
400
+ throw new Error(
401
+ `Could not bind a task for session ${laneSession} — the create failed and no existing task was found.`,
402
+ );
508
403
  }
509
404
  const existing = await prisma.work_items.findMany({
510
405
  where: { parent_id: task.id, kind: "subtask" },
@@ -701,8 +596,13 @@ export const taskSyncRouter = {
701
596
  const { ORPCError } = await import("@orpc/server");
702
597
  throw new ORPCError("BAD_REQUEST", { message: "upsertFromTaskList requires lane_session" });
703
598
  }
599
+ // The pane rides in the header the callers already send (`x-lane-pane`),
600
+ // and it is what lets the on-demand bind link the new task to the lane's
601
+ // run. Absent, the bind still succeeds — it just cannot link the run — so
602
+ // this is passed opportunistically rather than required.
603
+ const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
704
604
  try {
705
- const res = await reconcileTaskList(prisma, session, list, body.source);
605
+ const res = await reconcileTaskList(prisma, session, list, body.source, headerPane);
706
606
  log.info("[task-sync] upsertFromTaskList reconciled", { session, ...res });
707
607
  return res;
708
608
  } catch (err) {
@@ -710,6 +610,13 @@ export const taskSyncRouter = {
710
610
  const { ORPCError } = await import("@orpc/server");
711
611
  throw new ORPCError("NOT_FOUND", { message: err.message });
712
612
  }
613
+ // The 500 body is masked by oRPC (INTERNAL_SERVER_ERROR), so without
614
+ // this the cause is invisible — measured 2026-07-30:
615
+ // upsertFromTaskList 500ing consistently with no surfaced error,
616
+ // blocking task-list projection + the watchdog for every lane. Log
617
+ // the actual error before re-throwing (protective-layers-fail-
618
+ // explicitly: a failure with no observability is the worst shape).
619
+ log.error("[task-sync] upsertFromTaskList FAILED", { session, err: err instanceof Error ? `${err.name}: ${err.message}` : String(err) });
713
620
  throw err;
714
621
  }
715
622
  }),
@@ -17,7 +17,7 @@ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schem
17
17
  export type PgBossJobHandler = (ctx: {
18
18
  config: ScheduledJobConfig;
19
19
  }) => Promise<void> | void;
20
- export declare const pgBossHandlerRegistry: any;
20
+ export declare const pgBossHandlerRegistry: import("@teamscala/generic-registry/create-registry").Registry<PgBossJobHandler>;
21
21
  /**
22
22
  * Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
23
23
  * (createRegistry.register overwrites silently — last registration wins).
@@ -7,8 +7,8 @@ type GraphCache = {
7
7
  ttlMs?: number;
8
8
  }): void;
9
9
  };
10
- export declare const staticShellGraphCache: any;
11
- export declare const paramRouteGraphCache: any;
10
+ export declare const staticShellGraphCache: import("@teamscala/cache/cache").ManagedCache<unknown>;
11
+ export declare const paramRouteGraphCache: import("@teamscala/cache/cache").ManagedCache<unknown>;
12
12
  /**
13
13
  * Stale-while-revalidate read of a cached loader graph.
14
14
  *
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Co-located tests for the resource-not-found public API: the error class +
7
+ * the response builder are pure (no S3), so the behaviour is exercised directly.
8
+ */
9
+ import { describe, expect, it } from "bun:test";
10
+ import { PrimaryResourceMissingError, buildResourceNotFoundResponse } from "./resource-not-found.ts";
11
+
12
+ describe("PrimaryResourceMissingError", () => {
13
+ it("is an Error carrying resourceType + httpStatus", () => {
14
+ const err = new PrimaryResourceMissingError("project", 404);
15
+ expect(err).toBeInstanceOf(Error);
16
+ expect(err.resourceType).toBe("project");
17
+ expect(err.httpStatus).toBe(404);
18
+ expect(err.empty).toBe(false);
19
+ expect(err.message).toContain("project");
20
+ });
21
+
22
+ it("defaults httpStatus to 404 + supports the empty variant", () => {
23
+ const dead = new PrimaryResourceMissingError("roadmap");
24
+ expect(dead.httpStatus).toBe(404);
25
+ const empty = new PrimaryResourceMissingError("roadmap", 200, { empty: true });
26
+ expect(empty.httpStatus).toBe(200);
27
+ expect(empty.empty).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe("buildResourceNotFoundResponse", () => {
32
+ it("returns a 404 Response for a dead link", () => {
33
+ const res = buildResourceNotFoundResponse("project");
34
+ expect(res).toBeInstanceOf(Response);
35
+ expect(res.status).toBe(404);
36
+ });
37
+
38
+ it("returns a 200 Response for the bare root (no resource selected)", () => {
39
+ const res = buildResourceNotFoundResponse("project", undefined, 200);
40
+ expect(res.status).toBe(200);
41
+ });
42
+
43
+ it("renders the empty-page surface when opts.empty", () => {
44
+ const res = buildResourceNotFoundResponse("project", undefined, 200, { empty: true });
45
+ expect(res.status).toBe(200);
46
+ });
47
+ });
@@ -28,7 +28,7 @@
28
28
  * floor that must always exist.
29
29
  */
30
30
 
31
- import { getClient } from "@teamscala/aws/create/client/get-client";
31
+ import { getClient } from "@teamscala/object-storage/create/client/get-client";
32
32
  import { createCache } from "@teamscala/cache/create-cache";
33
33
  import { getServerClient } from "@teamscala/orpc/server-client-registry";
34
34
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";