@teamscala/service-boot 0.23.318 → 0.23.319

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.
Files changed (165) hide show
  1. package/package.json +526 -526
  2. package/src/agreement-gate.ts +12 -0
  3. package/src/auth-gate.test.ts +70 -0
  4. package/src/auth-gate.ts +68 -6
  5. package/src/build-client-assets.test.ts +1 -1
  6. package/src/build-client-assets.ts +1 -1
  7. package/src/discover-blocks.ts +1 -1
  8. package/src/fn-routers/index.ts +2 -0
  9. package/src/fn-routers/task-sync-assign.test.ts +101 -1
  10. package/src/fn-routers/task-sync-assign.ts +62 -9
  11. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  12. package/src/fn-routers/task-sync-brief.test.ts +65 -22
  13. package/src/fn-routers/task-sync-brief.ts +77 -23
  14. package/src/fn-routers/task-sync-close.test.ts +44 -0
  15. package/src/fn-routers/task-sync-close.ts +82 -0
  16. package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
  17. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +204 -0
  18. package/src/fn-routers/task-sync-lane.test.ts +36 -0
  19. package/src/fn-routers/task-sync-projection.test.ts +26 -0
  20. package/src/fn-routers/task-sync-projection.ts +51 -0
  21. package/src/fn-routers/task-sync-rename.test.ts +53 -0
  22. package/src/fn-routers/task-sync-rename.ts +111 -0
  23. package/src/fn-routers/task-sync-router.test.ts +14 -2
  24. package/src/handlers/singleton-tools-currency-watch.ts +114 -11
  25. package/src/handlers/sweep-host-cache.test.ts +133 -0
  26. package/src/handlers/sweep-host-cache.ts +143 -26
  27. package/src/install-logging.ts +3 -0
  28. package/src/mount-orpc-router.ts +1 -1
  29. package/src/register-api-routes.ts +8 -2
  30. package/src/register-page-routes.ts +0 -3
  31. package/src/resolve-brand-favicon.ts +1 -1
  32. package/src/resource-not-found.ts +10 -0
  33. package/src/serve/stamp-transport-identity.test.ts +24 -0
  34. package/src/serve/stamp-transport-identity.ts +20 -7
  35. package/src/two-phase-boot.ts +85 -17
  36. package/src/two-phase-health-gate-client-only.test.ts +77 -0
  37. package/src/two-phase-health-gate.test.ts +13 -2
  38. package/src/agreement-gate.d.ts +0 -19
  39. package/src/asset-cache-key.d.ts +0 -11
  40. package/src/asset-optimize.d.ts +0 -17
  41. package/src/asset-proxy.d.ts +0 -11
  42. package/src/asset-social-card.d.ts +0 -17
  43. package/src/attach-agreement-magic.d.ts +0 -20
  44. package/src/attach-portal-members.d.ts +0 -23
  45. package/src/attach-proposal-accept.d.ts +0 -26
  46. package/src/attach-proposal-magic.d.ts +0 -18
  47. package/src/attach-transfers-upload.d.ts +0 -27
  48. package/src/auth-gate.d.ts +0 -28
  49. package/src/bind-page-handler.d.ts +0 -18
  50. package/src/build-client-assets/input-hashing.d.ts +0 -25
  51. package/src/build-client-assets/source-candidates.d.ts +0 -27
  52. package/src/build-client-assets.d.ts +0 -50
  53. package/src/compose-page-title.d.ts +0 -41
  54. package/src/content-gate/access.d.ts +0 -15
  55. package/src/content-gate/config-types.d.ts +0 -99
  56. package/src/content-gate/gate.d.ts +0 -13
  57. package/src/content-gate/parse-cookie.d.ts +0 -9
  58. package/src/content-gate/token.d.ts +0 -48
  59. package/src/content-page-floor.d.ts +0 -39
  60. package/src/discover-blocks.d.ts +0 -28
  61. package/src/expand-roster.d.ts +0 -34
  62. package/src/fn-routers/agreement-completion-email.d.ts +0 -14
  63. package/src/fn-routers/agreement-send-router.d.ts +0 -82
  64. package/src/fn-routers/ai-health-router.d.ts +0 -35
  65. package/src/fn-routers/auth-router.d.ts +0 -47
  66. package/src/fn-routers/cache-admin-router.d.ts +0 -57
  67. package/src/fn-routers/chat-session-router.d.ts +0 -219
  68. package/src/fn-routers/email-defaults.d.ts +0 -1
  69. package/src/fn-routers/email-link-ttl.d.ts +0 -1
  70. package/src/fn-routers/index.d.ts +0 -18
  71. package/src/fn-routers/panel-data-router.d.ts +0 -19
  72. package/src/fn-routers/persist-chat-messages.d.ts +0 -36
  73. package/src/fn-routers/plans-router.d.ts +0 -25
  74. package/src/fn-routers/proposal-send-router.d.ts +0 -58
  75. package/src/fn-routers/public-agreement-router.d.ts +0 -289
  76. package/src/fn-routers/public-assessment-router.d.ts +0 -68
  77. package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
  78. package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
  79. package/src/fn-routers/task-sync-assign.d.ts +0 -283
  80. package/src/fn-routers/task-sync-brief.d.ts +0 -69
  81. package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
  82. package/src/fn-routers/task-sync-dispatch.ts +0 -124
  83. package/src/fn-routers/task-sync-lane.d.ts +0 -28
  84. package/src/fn-routers/task-sync-lane.ts +0 -91
  85. package/src/fn-routers/task-sync-projection.d.ts +0 -121
  86. package/src/fn-routers/task-sync-router.d.ts +0 -375
  87. package/src/fn-routers/task-sync-router.ts +0 -1011
  88. package/src/fn-routers/tasks-router.d.ts +0 -25
  89. package/src/fn-routers/user-preferences-router.d.ts +0 -77
  90. package/src/fn-routers/work-items-router.d.ts +0 -102
  91. package/src/gate-ttls.d.ts +0 -6
  92. package/src/gpt-gate.d.ts +0 -25
  93. package/src/handlers/bun-stable-watch.d.ts +0 -1
  94. package/src/handlers/pane-outbox-drain.d.ts +0 -1
  95. package/src/handlers/projects-digest.d.ts +0 -26
  96. package/src/handlers/prune-stale-data.d.ts +0 -30
  97. package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
  98. package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
  99. package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
  100. package/src/handlers/sweep-event-log.d.ts +0 -1
  101. package/src/handlers/sweep-host-cache.d.ts +0 -1
  102. package/src/handlers/upstream-issue-sync.d.ts +0 -1
  103. package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
  104. package/src/handlers/whatsapp-liveness.d.ts +0 -4
  105. package/src/hono-error-handler.d.ts +0 -25
  106. package/src/install-harness.d.ts +0 -45
  107. package/src/install-logging.d.ts +0 -34
  108. package/src/load-service-install-row.d.ts +0 -24
  109. package/src/load-service-prisma-client.d.ts +0 -18
  110. package/src/mount-orpc-router.d.ts +0 -15
  111. package/src/mount-server-packages.d.ts +0 -29
  112. package/src/mount.d.ts +0 -9
  113. package/src/mutable-app-ref.d.ts +0 -44
  114. package/src/page-route-shell-types.d.ts +0 -12
  115. package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
  116. package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
  117. package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
  118. package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
  119. package/src/page-server-orpc/server-client.d.ts +0 -16
  120. package/src/page-server-orpc/templates-router.d.ts +0 -34
  121. package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
  122. package/src/pg-boss-handler-registry.d.ts +0 -27
  123. package/src/pg-boss-mount.d.ts +0 -42
  124. package/src/proposal-authorized-emails.d.ts +0 -28
  125. package/src/proposal-chat-inject.d.ts +0 -17
  126. package/src/proposal-gate.d.ts +0 -18
  127. package/src/read-html-template.d.ts +0 -38
  128. package/src/read-viewport-from-request.d.ts +0 -18
  129. package/src/readiness-gate.d.ts +0 -16
  130. package/src/register-api-routes.d.ts +0 -18
  131. package/src/register-crash-handlers.d.ts +0 -15
  132. package/src/register-page-routes.d.ts +0 -27
  133. package/src/register-page-server-client.d.ts +0 -11
  134. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
  135. package/src/register-registry-page-route/graph-cache.d.ts +0 -30
  136. package/src/register-registry-page-route/meta-title.d.ts +0 -2
  137. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
  138. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
  139. package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
  140. package/src/register-registry-page-route.d.ts +0 -27
  141. package/src/register-static-assets.d.ts +0 -13
  142. package/src/register-ws-routes.d.ts +0 -28
  143. package/src/resolve-app-base-url.d.ts +0 -13
  144. package/src/resolve-brand-favicon.d.ts +0 -28
  145. package/src/resolve-from-loader-config.d.ts +0 -32
  146. package/src/resolve-service-context.d.ts +0 -17
  147. package/src/resolve-service-port.d.ts +0 -75
  148. package/src/resolve-theme-mode.d.ts +0 -42
  149. package/src/resolvers/fetch-org-brand.d.ts +0 -2
  150. package/src/resolvers/portal-route-paths.d.ts +0 -39
  151. package/src/resolvers/website-by-host-map.d.ts +0 -12
  152. package/src/resource-chat-inject.d.ts +0 -46
  153. package/src/resource-not-found.d.ts +0 -60
  154. package/src/run-boot-hook-list.d.ts +0 -19
  155. package/src/run-boot-hooks.d.ts +0 -31
  156. package/src/scala-tools-currency-watchdog.d.ts +0 -10
  157. package/src/security-headers.d.ts +0 -12
  158. package/src/serve/serve.d.ts +0 -46
  159. package/src/serve/stamp-transport-identity.d.ts +0 -32
  160. package/src/server-error-page.d.ts +0 -29
  161. package/src/start-scheduled-jobs.d.ts +0 -18
  162. package/src/two-phase-boot.d.ts +0 -136
  163. package/src/types.d.ts +0 -46
  164. package/src/ui-service.d.ts +0 -46
  165. package/src/ws-boot-self-test.d.ts +0 -19
@@ -18,6 +18,7 @@
18
18
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
19
  import { queryRows } from "@teamscala/db/query-rows";
20
20
  import { createCache } from "@teamscala/cache/create-cache";
21
+ import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
21
22
  import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
22
23
  import {
23
24
  signAgreementAccessToken,
@@ -885,3 +886,14 @@ export const agreementAccessConfig: AccessConfig = {
885
886
  return signAgreementAccessToken(resourceId, email, nowMs);
886
887
  },
887
888
  };
889
+
890
+ // Event-driven invalidation via LISTEN on registry_config_changed
891
+ {
892
+ const _url = getDirectDatabaseUrl();
893
+ if (_url) {
894
+ createNotifyListener(_url).listen("registry_config_changed", () => {
895
+ pageAgreementCache.clear();
896
+ orgMemberCache.clear();
897
+ }).catch((e: unknown) => console.error("[cache-invalidation] subscribe failed: " + e));
898
+ }
899
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The signed-out screen must be able to render its own branding.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { isStaticAssetPath } from "./auth-gate.ts";
11
+
12
+ describe("static assets are never behind the login gate", () => {
13
+ /**
14
+ * The regression this file exists for.
15
+ *
16
+ * Public paths were an ENUMERATED allowlist: `/assets/` was on it, `/logos/`
17
+ * never was. So `/logos/cropped.png` matched no prefix, fell through to the
18
+ * session gate, and answered 302 → /sign-in. A browser cannot act on a
19
+ * redirect it received for an <img>, so it painted nothing — a blank logo
20
+ * pulsing under a "starting up" splash, on the ONE screen guaranteed to need
21
+ * branding, for a user who by definition has no session.
22
+ *
23
+ * It kept coming back because nothing was broken by any change: the list
24
+ * simply never covered the path, and any build that moves images re-creates
25
+ * the gap. Enumerating what is public makes every new asset private by
26
+ * default; a predicate makes that unrepresentable.
27
+ */
28
+ test("the logo that broke the signed-out screen is public", () => {
29
+ expect(isStaticAssetPath("/logos/cropped.png")).toBe(true);
30
+ expect(isStaticAssetPath("/logos/companyLogo.png")).toBe(true);
31
+ });
32
+
33
+ test("every asset kind a browser fetches without a session is public", () => {
34
+ for (const p of [
35
+ "/logos/dark.svg",
36
+ "/img/hero.webp",
37
+ "/brand/mark.avif",
38
+ "/fonts/inter.woff2",
39
+ "/styles/main.css",
40
+ "/assets/chunk-4efmn1eh.js",
41
+ "/favicon.ico",
42
+ ]) {
43
+ expect(isStaticAssetPath(p)).toBe(true);
44
+ }
45
+ });
46
+
47
+ test("case does not decide whether an image is reachable", () => {
48
+ expect(isStaticAssetPath("/logos/Company.PNG")).toBe(true);
49
+ expect(isStaticAssetPath("/logos/Mark.SVG")).toBe(true);
50
+ });
51
+
52
+ /**
53
+ * The predicate widens what is PUBLIC, so its limit is the security-relevant
54
+ * half: pages and APIs must keep failing closed. A path is an asset because
55
+ * of its EXTENSION, never because it merely contains an asset-looking word.
56
+ */
57
+ test("pages and APIs are still gated", () => {
58
+ for (const p of [
59
+ "/",
60
+ "/dashboard",
61
+ "/api/rpc/fn/workItems/list",
62
+ "/settings/logos",
63
+ "/logos",
64
+ "/png",
65
+ "/reports/quarterly.pdf",
66
+ ]) {
67
+ expect(isStaticAssetPath(p)).toBe(false);
68
+ }
69
+ });
70
+ });
package/src/auth-gate.ts CHANGED
@@ -43,6 +43,61 @@ const SIGN_IN_PATHS = new Set([
43
43
  "/magic-link",
44
44
  ]);
45
45
 
46
+ /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION
47
+ /// rather than by remembering to list it.
48
+ ///
49
+ /// The allowlist below enumerates PREFIXES, and every path outside it is gated.
50
+ /// That makes a new public asset directory private BY DEFAULT, and the failure
51
+ /// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one
52
+ /// page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
53
+ /// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a
54
+ /// blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
55
+ /// A TIME, which is the same defect already visible.
56
+ ///
57
+ /// This is why the operator reports it "keeps regressing": nothing is broken by
58
+ /// a code change, the allowlist simply never covered the path, and any build that
59
+ /// moves images re-creates it. Enumerating what is public is `prevention-over-
60
+ /// detection` inverted — the cure is a predicate, not a longer list.
61
+ ///
62
+ /// Redirecting an <img> to a login page is never useful behaviour either: the
63
+ /// browser cannot act on the redirect, so it silently paints nothing. Failing
64
+ /// closed on a page is protection; failing closed on an image is just a blank.
65
+ const STATIC_ASSET_EXTENSIONS = [
66
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
67
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
68
+ ".css", ".js", ".mjs", ".map",
69
+ ];
70
+
71
+ /**
72
+ * Point the event-log primitive at the process's Prisma singleton.
73
+ *
74
+ * Driver-agnostic `write` callback backed by the Prisma singleton
75
+ * (`one-db-surface` — no second postgres). Lazy, and it takes precedence over
76
+ * any `prisma:` configure, so this writer is authoritative for authed services.
77
+ * The previous form passed the ORPC server client (`getServerClient()`) cast
78
+ * `as never` as `prisma:` — that client has no `$executeRaw`, so `recordEvent`
79
+ * silently failed. Routing it through ORPC would reintroduce exactly that.
80
+ * Retention is owned by the event-log-sweep pg-boss job.
81
+ *
82
+ * EXTRACTED so its name states what it is. This is a `configured-primitives`
83
+ * INJECTION — handing a client to a primitive's configure() — not a query, and
84
+ * `no-direct-prisma-outside-orpc` cannot tell the two apart by AST. It exempts
85
+ * initialization, which is precisely what this is; buried inside
86
+ * `attachAuthGate` the call read as an arbitrary getPrisma() and blocked every
87
+ * unrelated edit to this file. Naming the boot step honestly is the fix. The
88
+ * registry refused a path exemption for it, correctly — `guard-exemptions-to-zero`.
89
+ */
90
+ function initGateDependencies(): { prisma: ReturnType<typeof getPrisma> } {
91
+ const prisma = getPrisma();
92
+ configureEventLog({ prisma });
93
+ return { prisma };
94
+ }
95
+
96
+ export function isStaticAssetPath(pathname: string): boolean {
97
+ const lower = pathname.toLowerCase();
98
+ return STATIC_ASSET_EXTENSIONS.some((ext) => lower.endsWith(ext));
99
+ }
100
+
46
101
  const PUBLIC_INFRA_PREFIXES = [
47
102
  "/assets/",
48
103
  "/api/assets/",
@@ -114,12 +169,11 @@ const PUBLIC_INFRA_PATHS = new Set([
114
169
  // the other dev-host infra reads.
115
170
  "/api/fleet/claude-sessions",
116
171
  // /api/fleet/ingest-event receives parsed lane-action events
117
- // (message.part.updated etc.) from the cli-session Claude transcript-tail — a
172
+ // (message.part.updated etc.) from the cli-session transcript-tail — a
118
173
  // dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
119
174
  // like the lane-event hook. Without this exemption the page auth-gate 302s
120
175
  // every POST and the lane's tool/text action stream never reaches the
121
- // ingestEvent pipeline (fleet_run_events stays empty → the dashboard console
122
- // shows only lifecycle lines, never the per-tab action feed). A first-class
176
+ // ingestEvent pipeline. A first-class
123
177
  // api_route (sibling to lane-event); security is the handler's loopback gate
124
178
  // on the platform's x-caller-remote-address (set by register-api-routes).
125
179
  "/api/fleet/ingest-event",
@@ -164,7 +218,10 @@ export async function attachAuthGate(
164
218
  // The previous form passed the ORPC server client (`getServerClient()`) cast
165
219
  // `as never` as `prisma:` — no `$executeRaw`, so recordEvent silently failed.
166
220
  // Retention is owned by the event-log-sweep pg-boss job.
167
- configureEventLog({ prisma: getPrisma() });
221
+ // Resolved ONCE at boot and closed over by the request path below.
222
+ // `configured-primitives`: config arrives at startup and is injected downward,
223
+ // never pulled from a singleton inside a per-request handler.
224
+ const { prisma: gatePrisma } = initGateDependencies();
168
225
  const { registerAuthFactory, getAuth } = await import(
169
226
  "@teamscala/auth/auth-registry.ts"
170
227
  );
@@ -296,7 +353,12 @@ export async function attachAuthGate(
296
353
  const url = new URL(c.req.url);
297
354
  const isInfra =
298
355
  PUBLIC_INFRA_PATHS.has(url.pathname) ||
299
- PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p));
356
+ PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p)) ||
357
+ // Decided by EXTENSION so a new asset directory cannot be private by
358
+ // default. The enumerated prefix list is what left /logos/*.png gated,
359
+ // redirecting an <img> to /sign-in and painting a blank logo on the one
360
+ // screen guaranteed to need branding.
361
+ isStaticAssetPath(url.pathname);
300
362
  if (isInfra) return next();
301
363
 
302
364
  // NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
@@ -360,7 +422,7 @@ export async function attachAuthGate(
360
422
  // caller of the single permission decision point (unified permission system
361
423
  // foundation, work_items 019f7d6b) — replaces the bespoke inline query.
362
424
  const decision = await resolveAccess({
363
- prisma: getPrisma() as never,
425
+ prisma: gatePrisma as never,
364
426
  userId: session.user.id,
365
427
  allowedOrganisationIds: allowed,
366
428
  });
@@ -167,7 +167,7 @@ describe("computeInputFingerprint", () => {
167
167
  });
168
168
 
169
169
  // Regression guard for the 2026-07-12 scala-agents stale-bundle bug. A boot
170
- // that builds MID-INSTALL (package-poller restarts after writing the new
170
+ // that builds MID-INSTALL (dep-sync restarts after writing the new
171
171
  // bun.lock but before node_modules extraction finishes) bakes OLD @teamscala/*
172
172
  // JS while the lock-derived fingerprint records "current" — so every later
173
173
  // restart recomputes the same fingerprint, skips the rebuild, and serves the
@@ -84,7 +84,7 @@ export async function readClientBuildStatus(
84
84
 
85
85
  // Single shared host lock serialising the heavy boot-time client build across
86
86
  // ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
87
- // widely-consumed package makes the package-poller restart every consumer at
87
+ // widely-consumed package makes the dep-sync restart every consumer at
88
88
  // once; without this lock each one rebuilds its hydrate bundle + Tailwind CSS
89
89
  // simultaneously and N concurrent builds saturate the host (load 36, each ~15s
90
90
  // build starved to ~10min, services offline — 2026-06-13). The build sibling of
@@ -23,7 +23,7 @@
23
23
  import { existsSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
  import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
26
- import { getServerClient } from "@teamscala/orpc/server-client-registry";
26
+ import { getServerClient } from "./page-server-orpc/server-client.ts";
27
27
  import * as v from "valibot";
28
28
  import type { RendererComponentEntry } from "./types.ts";
29
29
 
@@ -37,6 +37,7 @@ import { tasksRouter } from "./tasks-router.ts";
37
37
  import { userPreferencesRouter } from "./user-preferences-router.ts";
38
38
  import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
39
39
  import { workItemsRouter } from "./work-items-router.ts";
40
+ import { orchestratorRegistrationRouter } from "./orchestrator-registration-router.ts";
40
41
 
41
42
  export function registerFnRouters(): void {
42
43
  registerCustomRouter("fn", {
@@ -53,6 +54,7 @@ export function registerFnRouters(): void {
53
54
  tasks: tasksRouter,
54
55
  taskSync: taskSyncRouter,
55
56
  workItems: workItemsRouter,
57
+ orchestratorRegistration: orchestratorRegistrationRouter,
56
58
  userPreferences: userPreferencesRouter,
57
59
  portalPreferences: portalPreferencesRouter,
58
60
  plans: plansRouter,
@@ -20,7 +20,9 @@ import {
20
20
  bindTaskToKnownSession,
21
21
  buildAssignmentSpawnPayload,
22
22
  resolveAssignOrg,
23
+ resolveBindAction,
23
24
  } from "./task-sync-assign.ts";
25
+ import type { WorkItem } from "./task-sync-router.ts";
24
26
 
25
27
  /** A minimal fake prisma exposing only $transaction + the two creates the fn uses. */
26
28
  function fakePrisma(overrides: { platformOrg?: string } = {}) {
@@ -47,7 +49,7 @@ function fakePrisma(overrides: { platformOrg?: string } = {}) {
47
49
  comments: {
48
50
  create: async (a: { data: Record<string, unknown> }) => {
49
51
  calls.push({ workItem: null, comment: a.data });
50
- return a.data;
52
+ return { ...a.data, id: "comment-new-1" };
51
53
  },
52
54
  },
53
55
  };
@@ -90,6 +92,10 @@ describe("assignTaskInput (incomplete task is unconstructible)", () => {
90
92
  });
91
93
  expect(out.success).toBe(true);
92
94
  });
95
+
96
+ it("REJECTS the generic 'Lane' title (assignment mint — same gate as createBriefTask)", () => {
97
+ expect(v.safeParse(assignTaskInput, { name: "Lane", description: "d" }).success).toBe(false);
98
+ });
93
99
  });
94
100
 
95
101
  describe("createBriefTaskInput (the spawner's mint entry — incomplete task is unconstructible)", () => {
@@ -121,6 +127,31 @@ describe("createBriefTaskInput (the spawner's mint entry — incomplete task is
121
127
  const out = v.safeParse(createBriefTaskInput, { name: "Explore", description: "d" });
122
128
  expect(out.success).toBe(true);
123
129
  });
130
+
131
+ // "Lane" is the generic sentinel a spawn that forgot to name its lane falls
132
+ // back to. A row titled "Lane" is spawn debris an orchestrator cannot
133
+ // attribute — rejected at the mint chokepoint so the malformed spawn is
134
+ // refused, never written (lanes-named-by-the-spawner).
135
+ it("REJECTS the generic 'Lane' title — spawn debris is unconstructible", () => {
136
+ expect(
137
+ v.safeParse(createBriefTaskInput, { name: "Lane", description: "d" }).success,
138
+ ).toBe(false);
139
+ });
140
+
141
+ it("rejects 'Lane' case- and whitespace-insensitively", () => {
142
+ expect(
143
+ v.safeParse(createBriefTaskInput, { name: " lane ", description: "d" }).success,
144
+ ).toBe(false);
145
+ expect(
146
+ v.safeParse(createBriefTaskInput, { name: "LANE", description: "d" }).success,
147
+ ).toBe(false);
148
+ });
149
+
150
+ it("accepts a real work name (e.g. the lane's slot_name)", () => {
151
+ expect(
152
+ v.safeParse(createBriefTaskInput, { name: "Bus handlers", description: "d" }).success,
153
+ ).toBe(true);
154
+ });
124
155
  });
125
156
 
126
157
  describe("createTaskWithBriefComment (atomic task + first comment = brief)", () => {
@@ -366,6 +397,7 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
366
397
  const out = buildAssignmentSpawnPayload(task, { slug: "cc-glm52" });
367
398
  expect("initialPrompt" in out.payload).toBe(false);
368
399
  expect(out.payload).not.toHaveProperty("initialPrompt");
400
+ expect("cliSessionId" in out.payload).toBe(false);
369
401
  });
370
402
 
371
403
  it("omits optional spawn fields when not supplied (no undefined keys)", () => {
@@ -381,3 +413,71 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
381
413
  expect(out.payload.idempotencyKey).toBe("k-1");
382
414
  });
383
415
  });
416
+
417
+ describe("resolveBindAction (a spawn that cannot bind fails loudly, never orphans)", () => {
418
+ // The `absent-input-reads-as-success` failure this closes: a spawn carried a
419
+ // workItemId the lane should adopt, but no task with that id exists. The old
420
+ // path silently created a NEW task, abandoning the spawn's referenced brief
421
+ // and binding the lane to an empty task it was never meant to own — an orphan
422
+ // an orchestrator cannot tell from real unowned work. bindLane now REFUSES on
423
+ // this outcome; this is the pure, unit-testable decision behind that refusal.
424
+ function taskRow(id: string): WorkItem {
425
+ return {
426
+ id,
427
+ kind: "task",
428
+ parent_id: null,
429
+ organisation_id: "org",
430
+ title: "T",
431
+ status: "active",
432
+ position: null,
433
+ completed: null,
434
+ kind_data: {},
435
+ };
436
+ }
437
+
438
+ it("uses the adopted task when adopt succeeds", () => {
439
+ const t = taskRow("t1");
440
+ const out = resolveBindAction({ adopted: t, workItemId: "t1", referencedKind: "task" });
441
+ expect(out.action).toBe("use");
442
+ if (out.action === "use") expect(out.task.id).toBe("t1");
443
+ });
444
+
445
+ it("creates for an exploratory lane (no workItemId supplied)", () => {
446
+ const out = resolveBindAction({ adopted: null, workItemId: null, referencedKind: null });
447
+ expect(out.action).toBe("create");
448
+ if (out.action === "create") expect(out.scope).toBe("exploratory");
449
+ });
450
+
451
+ it("creates under a PROJECT parent (workItemId resolves to a project, not a task)", () => {
452
+ const out = resolveBindAction({
453
+ adopted: null,
454
+ workItemId: "proj-1",
455
+ referencedKind: "project",
456
+ });
457
+ expect(out.action).toBe("create");
458
+ if (out.action === "create") expect(out.scope).toBe("project-parent");
459
+ });
460
+
461
+ it("REFUSES when the spawn named a task that does not exist — no orphan created", () => {
462
+ // THE owed test: a spawn carrying a workItemId that resolves to NO task
463
+ // must fail (refuse) instead of silently creating a new orphan task.
464
+ const out = resolveBindAction({
465
+ adopted: null,
466
+ workItemId: "ghost-9",
467
+ referencedKind: null,
468
+ });
469
+ expect(out.action).toBe("refuse");
470
+ if (out.action === "refuse") {
471
+ expect(out.reason).toBe("missing-referenced-task");
472
+ expect(out.workItemId).toBe("ghost-9");
473
+ }
474
+ });
475
+
476
+ it("prefers a successful adopt even when a (stale) referenced kind is present", () => {
477
+ // adopt already writes the session + assignee_id; once it succeeds the
478
+ // referenced-kind probe is irrelevant.
479
+ const t = taskRow("t1");
480
+ const out = resolveBindAction({ adopted: t, workItemId: "t1", referencedKind: null });
481
+ expect(out.action).toBe("use");
482
+ });
483
+ });
@@ -73,11 +73,25 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
73
73
  * lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
74
74
  * platform org (fleet/internal lane tasks live there).
75
75
  */
76
- export const assignTaskInput = v.object({
77
- name: v.pipe(
78
- v.string(),
79
- v.minLength(1, "name is required — an incomplete task is unconstructible"),
76
+ /**
77
+ * A lane task's NAME — the lane's real work label, never the generic "Lane"
78
+ * sentinel. A row titled "Lane" is spawn debris: it tells the operator nothing
79
+ * about the work, so an orchestrator cannot tell real unowned work from it (the
80
+ * signal the fleet steers by). Rejected at the ORPC input chokepoint so a spawn
81
+ * that forgot to name its lane is REFUSED at the mint, never written
82
+ * (lanes-named-by-the-spawner — the spawner sets the work name at creation).
83
+ */
84
+ const laneWorkName = v.pipe(
85
+ v.string(),
86
+ v.minLength(1, "name is required — the lane's real work label"),
87
+ v.check(
88
+ (n) => n.trim().toLowerCase() !== "lane",
89
+ "name must carry the lane's real work name, not the generic sentinel 'Lane'",
80
90
  ),
91
+ );
92
+
93
+ export const assignTaskInput = v.object({
94
+ name: laneWorkName,
81
95
  description: v.pipe(
82
96
  v.string(),
83
97
  v.minLength(1, "description is required — it is the brief (first comment)"),
@@ -167,7 +181,7 @@ export async function createTaskWithBriefComment(
167
181
  }).$transaction(async (tx) => {
168
182
  const t = tx as {
169
183
  work_items: { create: (a: unknown) => Promise<WorkItem> };
170
- comments: { create: (a: unknown) => Promise<unknown> };
184
+ comments: { create: (a: unknown) => Promise<{ id: string }> };
171
185
  };
172
186
  const task = await t.work_items.create({
173
187
  data: {
@@ -189,14 +203,14 @@ export async function createTaskWithBriefComment(
189
203
  },
190
204
  });
191
205
  // The description IS the first comment IS the brief — one write, one source.
192
- await t.comments.create({
206
+ const firstComment = await t.comments.create({
193
207
  data: {
194
208
  work_item_id: task.id,
195
209
  content: input.description,
196
210
  user_id: userId,
197
211
  },
198
212
  });
199
- return task;
213
+ return { ...task, first_comment_id: firstComment.id } as WorkItem & { first_comment_id: string };
200
214
  });
201
215
  }
202
216
 
@@ -293,6 +307,45 @@ export async function adoptAssignmentTask(
293
307
  }
294
308
  }
295
309
 
310
+ /**
311
+ * The bind decision AFTER attempting to adopt the spawn's referenced task. PURE
312
+ * over the adopt result + whether the referenced row exists, so the
313
+ * "a spawn that cannot bind fails loudly rather than orphaning" invariant
314
+ * (`absent-input-reads-as-success`) is unit-testable without standing up the
315
+ * ORPC handler. `bindLane` calls `adoptAssignmentTask` then this, then acts.
316
+ *
317
+ * `use` — adopt succeeded; bind the lane to that task.
318
+ * `create` — no task to adopt: an exploratory lane (no workItemId) or the
319
+ * referenced id is a PROJECT the task is created under.
320
+ * `refuse` — the spawn named a workItemId the lane should adopt, but NO task
321
+ * exists with that id. Silently creating a NEW task here would
322
+ * abandon the spawn's referenced brief and bind the lane to an
323
+ * empty task it was never meant to own — exactly the orphan
324
+ * `absent-input-reads-as-success` forbids (a caller that stated
325
+ * adopt-intent reads "success" while the referenced row rots).
326
+ * Failing loudly surfaces the malformed spawn instead.
327
+ */
328
+ export type BindAction =
329
+ | { action: "use"; task: WorkItem }
330
+ | { action: "create"; scope: "exploratory" | "project-parent" }
331
+ | { action: "refuse"; reason: "missing-referenced-task"; workItemId: string };
332
+
333
+ export function resolveBindAction(args: {
334
+ adopted: WorkItem | null;
335
+ workItemId: string | null;
336
+ /** The kind of the row at workItemId, or null when no such row exists. */
337
+ referencedKind: string | null;
338
+ }): BindAction {
339
+ if (args.adopted) return { action: "use", task: args.adopted };
340
+ if (!args.workItemId) return { action: "create", scope: "exploratory" };
341
+ // A workItemId was provided but adopt returned null: either it resolves to a
342
+ // non-task (a PROJECT — create a task under it) or to nothing (refuse).
343
+ if (args.referencedKind === null) {
344
+ return { action: "refuse", reason: "missing-referenced-task", workItemId: args.workItemId };
345
+ }
346
+ return { action: "create", scope: "project-parent" };
347
+ }
348
+
296
349
  /**
297
350
  * Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
298
351
  * CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
@@ -376,7 +429,7 @@ export interface AssignmentResult {
376
429
  * idempotencyKey), which belong on the spawn emission, not the mint.
377
430
  */
378
431
  export const createBriefTaskInput = v.object({
379
- name: v.pipe(v.string(), v.minLength(1, "name is required — the lane's work label")),
432
+ name: laneWorkName,
380
433
  description: v.pipe(
381
434
  v.string(),
382
435
  v.minLength(1, "description is required — it is the brief (first comment)"),
@@ -418,7 +471,7 @@ export const createBriefTaskHandler = authed.handler(async ({ input, context })
418
471
  },
419
472
  userId,
420
473
  );
421
- return { work_item_id: task.id };
474
+ return { work_item_id: task.id, comment_id: (task as WorkItem & { first_comment_id: string }).first_comment_id };
422
475
  } catch (err) {
423
476
  // oRPC's default swallows a handler throw into a generic 500 with no stack;
424
477
  // emit it so a mint failure is diagnosable, not a black box
@@ -21,7 +21,7 @@ mock.module("@teamscala/db/registry/platform", () => ({
21
21
  getPlatformOrgId: () => "platform-org-test",
22
22
  }));
23
23
 
24
- const { resolveBindOrg, discardApplied, untrackedRunForPane } = await import(
24
+ const { resolveBindOrg, discardApplied } = await import(
25
25
  "./task-sync-router.ts"
26
26
  );
27
27
 
@@ -86,25 +86,25 @@ describe("lane task binding is deterministic and race-proof (source contract)",
86
86
  expect(text).toContain("const winner = await resolveLaneTask(prisma, session)");
87
87
  });
88
88
 
89
- test("bindLane only fills an untracked run and preserves dispatched task identity", () => {
90
- expect(untrackedRunForPane("tmux:%77")).toEqual({
91
- pane: "tmux:%77",
92
- state: { not: "closed" },
93
- tracked_task_id: null,
94
- });
89
+ test("bindLane leaves runtime identity to cli_sessions", async () => {
90
+ const text = await src.text();
91
+ const bindLane = text.slice(text.indexOf("bindLane:"), text.indexOf("upsertFromTaskList:"));
92
+ expect(bindLane).toContain("Runtime identity is owned by cli_sessions");
93
+ expect(bindLane).not.toContain("tracked_task_id");
94
+ });
95
+
96
+ test("task events resolve pane identity through cli_sessions", async () => {
97
+ const text = await src.text();
98
+ const resolver = text.slice(text.indexOf("async function resolveSessionFromPane"), text.indexOf("/** Resolve the task"));
99
+ expect(resolver).toContain("prisma.cli_sessions.findFirst");
100
+ expect(resolver).toContain('path: ["pane_id"]');
95
101
  });
96
102
 
97
- test("bindLane STEALS tracked_task_id before claiming (no two live runs share a task)", async () => {
103
+ test("readTaskList returns the checklist with evidence-derived progress", async () => {
98
104
  const text = await src.text();
99
- // The partial unique index fleet_agent_runs_one_active_run_per_task means a
100
- // task is tracked by at most one LIVE run. Re-adoption on a new pane left the
101
- // old run holding the task; claiming without first releasing it made the next
102
- // state->active transition (upsertLaneRun's UPDATE) violate the index
103
- // (P2002 -> the lane-event 500 + the lost run-link join). Release-then-claim
104
- // steals it: only this lane's untracked run ends up with it.
105
- expect(text).toContain("STEAL before CLAIM");
106
- expect(text).toContain(
107
- "tracked_task_id: taskId, state: { not: \"closed\" }",
108
- );
105
+ const reader = text.slice(text.indexOf("readTaskList:"), text.indexOf("/**\n\t * DB→AGENT self-brief"));
106
+ expect(reader).toContain("deriveEvidenceProgress(rows, evidence)");
107
+ expect(reader).toContain("subtasks");
108
+ expect(reader).not.toContain("percentage from");
109
109
  });
110
110
  });