@teamscala/service-boot 0.23.319 → 0.23.321

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 (164) hide show
  1. package/package.json +526 -526
  2. package/src/agreement-gate.d.ts +19 -0
  3. package/src/agreement-gate.ts +0 -12
  4. package/src/asset-cache-key.d.ts +11 -0
  5. package/src/asset-optimize.d.ts +17 -0
  6. package/src/asset-proxy.d.ts +11 -0
  7. package/src/asset-social-card.d.ts +17 -0
  8. package/src/attach-agreement-magic.d.ts +20 -0
  9. package/src/attach-portal-members.d.ts +23 -0
  10. package/src/attach-proposal-accept.d.ts +26 -0
  11. package/src/attach-proposal-magic.d.ts +18 -0
  12. package/src/attach-transfers-upload.d.ts +27 -0
  13. package/src/auth-gate.d.ts +28 -0
  14. package/src/auth-gate.ts +6 -68
  15. package/src/bind-page-handler.d.ts +18 -0
  16. package/src/build-client-assets/input-hashing.d.ts +25 -0
  17. package/src/build-client-assets/source-candidates.d.ts +27 -0
  18. package/src/build-client-assets.d.ts +50 -0
  19. package/src/build-client-assets.test.ts +1 -1
  20. package/src/build-client-assets.ts +1 -1
  21. package/src/compose-page-title.d.ts +41 -0
  22. package/src/content-gate/access.d.ts +15 -0
  23. package/src/content-gate/config-types.d.ts +99 -0
  24. package/src/content-gate/gate.d.ts +13 -0
  25. package/src/content-gate/parse-cookie.d.ts +9 -0
  26. package/src/content-gate/token.d.ts +48 -0
  27. package/src/content-page-floor.d.ts +39 -0
  28. package/src/discover-blocks.d.ts +28 -0
  29. package/src/discover-blocks.ts +1 -1
  30. package/src/expand-roster.d.ts +34 -0
  31. package/src/fn-routers/agreement-completion-email.d.ts +14 -0
  32. package/src/fn-routers/agreement-send-router.d.ts +82 -0
  33. package/src/fn-routers/ai-health-router.d.ts +35 -0
  34. package/src/fn-routers/auth-router.d.ts +47 -0
  35. package/src/fn-routers/cache-admin-router.d.ts +57 -0
  36. package/src/fn-routers/chat-session-router.d.ts +219 -0
  37. package/src/fn-routers/email-defaults.d.ts +1 -0
  38. package/src/fn-routers/email-link-ttl.d.ts +1 -0
  39. package/src/fn-routers/index.d.ts +18 -0
  40. package/src/fn-routers/index.ts +0 -2
  41. package/src/fn-routers/panel-data-router.d.ts +19 -0
  42. package/src/fn-routers/persist-chat-messages.d.ts +36 -0
  43. package/src/fn-routers/plans-router.d.ts +25 -0
  44. package/src/fn-routers/proposal-send-router.d.ts +58 -0
  45. package/src/fn-routers/public-agreement-router.d.ts +289 -0
  46. package/src/fn-routers/public-assessment-router.d.ts +68 -0
  47. package/src/fn-routers/public-gpt-session-router.d.ts +143 -0
  48. package/src/fn-routers/sign-in-flow-router.d.ts +107 -0
  49. package/src/fn-routers/task-sync-assign.d.ts +305 -0
  50. package/src/fn-routers/task-sync-assign.test.ts +1 -2
  51. package/src/fn-routers/task-sync-assign.ts +4 -4
  52. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  53. package/src/fn-routers/task-sync-brief.d.ts +96 -0
  54. package/src/fn-routers/task-sync-brief.test.ts +17 -20
  55. package/src/fn-routers/task-sync-brief.ts +14 -15
  56. package/src/fn-routers/task-sync-dispatch.d.ts +61 -0
  57. package/src/fn-routers/task-sync-dispatch.test.ts +33 -51
  58. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  59. package/src/fn-routers/task-sync-lane.d.ts +49 -0
  60. package/src/fn-routers/task-sync-lane.ts +125 -0
  61. package/src/fn-routers/task-sync-projection.d.ts +121 -0
  62. package/src/fn-routers/task-sync-projection.test.ts +0 -26
  63. package/src/fn-routers/task-sync-projection.ts +0 -51
  64. package/src/fn-routers/task-sync-router.d.ts +377 -0
  65. package/src/fn-routers/task-sync-router.test.ts +2 -14
  66. package/src/fn-routers/task-sync-router.ts +1075 -0
  67. package/src/fn-routers/tasks-router.d.ts +25 -0
  68. package/src/fn-routers/user-preferences-router.d.ts +77 -0
  69. package/src/fn-routers/work-items-router.d.ts +102 -0
  70. package/src/gate-ttls.d.ts +6 -0
  71. package/src/gpt-gate.d.ts +25 -0
  72. package/src/handlers/bun-stable-watch.d.ts +1 -0
  73. package/src/handlers/pane-outbox-drain.d.ts +1 -0
  74. package/src/handlers/projects-digest.d.ts +26 -0
  75. package/src/handlers/prune-stale-data.d.ts +30 -0
  76. package/src/handlers/registry-stale-inactive-sweep.d.ts +1 -0
  77. package/src/handlers/scala-tools-currency-watch.d.ts +1 -0
  78. package/src/handlers/singleton-tools-currency-watch.d.ts +1 -0
  79. package/src/handlers/singleton-tools-currency-watch.ts +11 -114
  80. package/src/handlers/sweep-event-log.d.ts +1 -0
  81. package/src/handlers/sweep-host-cache.d.ts +1 -0
  82. package/src/handlers/sweep-host-cache.ts +26 -143
  83. package/src/handlers/upstream-issue-sync.d.ts +1 -0
  84. package/src/handlers/whatsapp-liveness-logic.d.ts +34 -0
  85. package/src/handlers/whatsapp-liveness.d.ts +4 -0
  86. package/src/hono-error-handler.d.ts +25 -0
  87. package/src/install-harness.d.ts +45 -0
  88. package/src/install-logging.d.ts +34 -0
  89. package/src/install-logging.ts +0 -3
  90. package/src/load-service-install-row.d.ts +24 -0
  91. package/src/load-service-prisma-client.d.ts +18 -0
  92. package/src/mount-orpc-router.d.ts +15 -0
  93. package/src/mount-orpc-router.ts +1 -1
  94. package/src/mount-server-packages.d.ts +29 -0
  95. package/src/mount.d.ts +9 -0
  96. package/src/mutable-app-ref.d.ts +44 -0
  97. package/src/page-route-shell-types.d.ts +12 -0
  98. package/src/page-server-orpc/audit-revert-router.d.ts +37 -0
  99. package/src/page-server-orpc/hub-mcp-client.d.ts +12 -0
  100. package/src/page-server-orpc/portal-diagnostics-router.d.ts +49 -0
  101. package/src/page-server-orpc/puck-pages-router.d.ts +58 -0
  102. package/src/page-server-orpc/server-client.d.ts +16 -0
  103. package/src/page-server-orpc/templates-router.d.ts +34 -0
  104. package/src/page-server-orpc/user-preferences-router.d.ts +79 -0
  105. package/src/pg-boss-handler-registry.d.ts +27 -0
  106. package/src/pg-boss-mount.d.ts +42 -0
  107. package/src/proposal-authorized-emails.d.ts +28 -0
  108. package/src/proposal-chat-inject.d.ts +17 -0
  109. package/src/proposal-gate.d.ts +18 -0
  110. package/src/read-html-template.d.ts +38 -0
  111. package/src/read-viewport-from-request.d.ts +18 -0
  112. package/src/readiness-gate.d.ts +16 -0
  113. package/src/register-api-routes.d.ts +18 -0
  114. package/src/register-crash-handlers.d.ts +15 -0
  115. package/src/register-page-routes.d.ts +27 -0
  116. package/src/register-page-routes.ts +3 -0
  117. package/src/register-page-server-client.d.ts +11 -0
  118. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +13 -0
  119. package/src/register-registry-page-route/graph-cache.d.ts +30 -0
  120. package/src/register-registry-page-route/meta-title.d.ts +2 -0
  121. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +14 -0
  122. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +28 -0
  123. package/src/register-registry-page-route/resolve-page-data.d.ts +1 -0
  124. package/src/register-registry-page-route.d.ts +27 -0
  125. package/src/register-static-assets.d.ts +13 -0
  126. package/src/register-ws-routes.d.ts +28 -0
  127. package/src/resolve-app-base-url.d.ts +13 -0
  128. package/src/resolve-brand-favicon.d.ts +28 -0
  129. package/src/resolve-brand-favicon.ts +1 -1
  130. package/src/resolve-from-loader-config.d.ts +32 -0
  131. package/src/resolve-service-context.d.ts +17 -0
  132. package/src/resolve-service-port.d.ts +75 -0
  133. package/src/resolve-theme-mode.d.ts +42 -0
  134. package/src/resolvers/fetch-org-brand.d.ts +2 -0
  135. package/src/resolvers/portal-route-paths.d.ts +39 -0
  136. package/src/resolvers/website-by-host-map.d.ts +12 -0
  137. package/src/resource-chat-inject.d.ts +46 -0
  138. package/src/resource-not-found.d.ts +60 -0
  139. package/src/resource-not-found.ts +0 -10
  140. package/src/run-boot-hook-list.d.ts +19 -0
  141. package/src/run-boot-hooks.d.ts +31 -0
  142. package/src/scala-tools-currency-watchdog.d.ts +10 -0
  143. package/src/security-headers.d.ts +12 -0
  144. package/src/serve/serve.d.ts +46 -0
  145. package/src/serve/stamp-transport-identity.d.ts +32 -0
  146. package/src/serve/stamp-transport-identity.ts +2 -14
  147. package/src/server-error-page.d.ts +29 -0
  148. package/src/start-scheduled-jobs.d.ts +18 -0
  149. package/src/two-phase-boot.d.ts +136 -0
  150. package/src/two-phase-boot.ts +17 -85
  151. package/src/two-phase-health-gate.test.ts +2 -13
  152. package/src/types.d.ts +46 -0
  153. package/src/ui-service.d.ts +46 -0
  154. package/src/ws-boot-self-test.d.ts +19 -0
  155. package/src/auth-gate.test.ts +0 -70
  156. package/src/fn-routers/task-sync-close.test.ts +0 -44
  157. package/src/fn-routers/task-sync-close.ts +0 -82
  158. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
  159. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  160. package/src/fn-routers/task-sync-rename.test.ts +0 -53
  161. package/src/fn-routers/task-sync-rename.ts +0 -111
  162. package/src/handlers/sweep-host-cache.test.ts +0 -133
  163. package/src/serve/stamp-transport-identity.test.ts +0 -24
  164. package/src/two-phase-health-gate-client-only.test.ts +0 -77
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
7
7
  * lane's CLI extension reads its own session id on load, resolves the task
8
- * assigned to that session, and reads the brief from that task's FIRST comment.
8
+ * assigned to that session, and reads the brief from that task's LATEST comment.
9
9
  * Nothing is pasted, typed, passed as an argument or sent over a channel
10
10
  * (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
11
11
  * brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
@@ -39,7 +39,7 @@ import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
39
39
  export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
40
40
 
41
41
  /**
42
- * Shape the readAssignedBrief response from a resolved task + its first
42
+ * Shape the readAssignedBrief response from a resolved task + its latest
43
43
  * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
44
44
  * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
45
45
  *
@@ -61,27 +61,25 @@ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound"
61
61
  */
62
62
  export function shapeBriefResponse(
63
63
  task: { id: string } | null,
64
- comment: Pick<CommentRow, "id" | "content" | "created_at"> | null,
64
+ comment: Pick<CommentRow, "content" | "created_at"> | null,
65
65
  ): {
66
66
  work_item_id: string | null;
67
- comment_id: string | null;
68
67
  brief: string | null;
69
68
  comment_created_at: string | null;
70
69
  status: BriefReadStatus;
71
70
  } {
72
71
  if (!task) {
73
- return { work_item_id: null, comment_id: null, brief: null, comment_created_at: null, status: "unbound" };
72
+ return { work_item_id: null, brief: null, comment_created_at: null, status: "unbound" };
74
73
  }
75
74
  if (!comment) {
76
- return { work_item_id: task.id, comment_id: null, brief: null, comment_created_at: null, status: "no_comment" };
75
+ return { work_item_id: task.id, brief: null, comment_created_at: null, status: "no_comment" };
77
76
  }
78
77
  const text = typeof comment.content === "string" ? comment.content : String(comment.content);
79
78
  if (text.trim() === "") {
80
- return { work_item_id: task.id, comment_id: comment.id, brief: null, comment_created_at: null, status: "empty_brief" };
79
+ return { work_item_id: task.id, brief: null, comment_created_at: null, status: "empty_brief" };
81
80
  }
82
81
  return {
83
82
  work_item_id: task.id,
84
- comment_id: comment.id,
85
83
  brief: text,
86
84
  comment_created_at:
87
85
  comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
@@ -90,12 +88,13 @@ export function shapeBriefResponse(
90
88
  }
91
89
 
92
90
  /**
93
- * Read the immutable FIRST comment on a work_items task. It is the launch brief;
94
- * later comments are conversation and must never change the launch identity.
95
- * Oldest-first by created_at with id as a deterministic tie-breaker. Returns null
96
- * when the task has no comment yet.
91
+ * Read the LATEST comment on a work_items task. The comment IS the brief for a
92
+ * run: every spawn writes a NEW comment, and the description is never the brief,
93
+ * so "what was this lane told" always has exactly one answer
94
+ * (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
95
+ * task has no comment yet — the extension treats that as "no brief to inject".
97
96
  */
98
- export async function readFirstTaskComment(
97
+ export async function readLatestTaskComment(
99
98
  prisma: Prisma,
100
99
  workItemId: string,
101
100
  ): Promise<CommentRow | null> {
@@ -109,7 +108,7 @@ export async function readFirstTaskComment(
109
108
  if (!comments?.findFirst) return null;
110
109
  return comments.findFirst({
111
110
  where: { work_item_id: workItemId },
112
- orderBy: [{ created_at: "asc" }, { id: "asc" }],
111
+ orderBy: { created_at: "desc" },
113
112
  });
114
113
  }
115
114
 
@@ -146,6 +145,6 @@ export const readAssignedBriefHandler = authed.handler(async ({ input, context }
146
145
  task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
147
146
  }
148
147
  if (!task) return shapeBriefResponse(null, null);
149
- const comment = await readFirstTaskComment(prisma, task.id);
148
+ const comment = await readLatestTaskComment(prisma, task.id);
150
149
  return shapeBriefResponse(task, comment);
151
150
  });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * DB→AGENT dispatch for task-sync — notifies a lane's live pane that its
7
+ * work_items task list changed out-of-band (operator toggled a subtask in the
8
+ * projects UI, another lane edited a shared task). The pane's adapter then
9
+ * re-reads (fn.taskSync.readTaskList) and updates its native list.
10
+ *
11
+ * MECHANISM — reuses the existing CLI-agnostic channel delivery, no new
12
+ * transport: a POST to scala-channel's fleet-events publish entry
13
+ * (POST /api/fleet/events — @teamscala/channel/fleet-events-publish) carrying an
14
+ * `orchestrator.message` FleetEvent with slotName "cross-pane". That slot is
15
+ * the watchEvents bypass for events NOT tied to a fleet_agent_runs row
16
+ * (FleetEventOrchestratorMessageSchema comment) — exactly the system-originated
17
+ * case. scala-channel re-emits onto its eventBus and streamFleetEvents delivers
18
+ * to the subscriber filtered by orchestratorSessionId (the target lane pane).
19
+ * Both the Claude Code relay and the OpenCode scala-hooks plugin subscribe to
20
+ * that SSE stream for their own pane identity and inject each event natively —
21
+ * so ONE publish reaches every CLI that has a live projection, no per-CLI
22
+ * dispatch code here.
23
+ *
24
+ * NOT the dispatch entry (POST /api/channel/send): that path requires a real
25
+ * sender pane identity (channel-sender-identity-validated anti-spoof) and so
26
+ * cannot carry a system-originated notification. The publish path is
27
+ * internal-trusted (localhost super-admin) and accepts a synthetic
28
+ * fromOrchestratorId — the correct path for a system→pane message.
29
+ *
30
+ * scala-channel's address is resolved from its service_routing row (local_port)
31
+ * — never hardcoded (service-ports-from-registry). Fire-and-forget: a dispatch
32
+ * failure (scala-channel down, port unresolvable) is logged and swallowed; it
33
+ * must never block the work_items write that triggered it, and the lane's
34
+ * SessionStart/UserPromptSubmit backstop picks up the change on its next turn
35
+ * regardless.
36
+ */
37
+ /**
38
+ * The `orchestrator.message` FleetEvent payload that notifies a lane its task
39
+ * list changed. Pure (no I/O) so it is unit-testable; notifyLaneOfTaskChange
40
+ * posts it. slotName "cross-pane" is REQUIRED — it is the watchEvents bypass for
41
+ * events not tied to a fleet_agent_runs row. orchestratorSessionId is the TARGET
42
+ * lane pane (the SSE subscriber filter). The message is single-line (the
43
+ * <channel> tag renders it inline).
44
+ */
45
+ export interface TaskChangedEvent {
46
+ type: "orchestrator.message";
47
+ message: string;
48
+ fromOrchestratorId: string;
49
+ runId: string;
50
+ slotName: "cross-pane";
51
+ ts: number;
52
+ orchestratorSessionId: string;
53
+ }
54
+ export declare function buildTaskChangedEvent(lanePane: string, taskId: string, now?: number): TaskChangedEvent;
55
+ /**
56
+ * Publish a "your task list changed" notification to the owning lane's pane.
57
+ * The pane identity is the stable tmux id ("tmux:%42") every CLI relay/plugin
58
+ * receives for. `taskId` is carried in the message so the recipient can re-read
59
+ * just that task. Fire-and-forget.
60
+ */
61
+ export declare function notifyLaneOfTaskChange(lanePane: string, taskId: string): Promise<void>;
@@ -3,60 +3,42 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Unit tests for the pure durable comment-payload builder in task-sync-dispatch.
7
- * The runtime path writes this payload through the canonical comments model.
6
+ * Unit tests for the pure event-payload builder in task-sync-dispatch. The HTTP
7
+ * POST + registry resolution are side-effectful (covered end-to-end by the
8
+ * DB->AGENT direction demo — a published orchestrator.message with slotName
9
+ * cross-pane delivers to the target pane's <channel> tag); buildTaskChangedEvent
10
+ * is pure and tested here. Satisfies what-must-have-a-test / primitive-api-has-test.
8
11
  */
9
12
  import { describe, it, expect } from "bun:test";
10
- import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
13
+ import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
11
14
 
12
- describe("task-sync buildTaskChangeComment", () => {
13
- it("addresses the canonical task and runtime with no pane transport", () => {
14
- const comment = buildTaskChangeComment("task-7", "session-5", "subtask-1:completed");
15
- expect(comment).toMatchObject({
16
- work_item_id: "task-7",
17
- source_cli_session_id: "session-5",
18
- sender_role: "system",
19
- sender_name: "TaskSync",
20
- });
21
- expect(comment.system_key).toBe("task-sync:task-7:session-5:subtask-1:completed");
22
- expect(comment.content).toContain("readTaskList");
23
- expect(comment).not.toHaveProperty("pane");
24
- });
15
+ describe("task-sync buildTaskChangedEvent", () => {
16
+ it("is an orchestrator.message addressed to the owning lane pane", () => {
17
+ const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
18
+ expect(e.type).toBe("orchestrator.message");
19
+ expect(e.orchestratorSessionId).toBe("tmux:%5");
20
+ });
21
+
22
+ it("uses slotName cross-pane so watchEvents bypasses the fleet-run-existence check", () => {
23
+ const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
24
+ expect(e.slotName).toBe("cross-pane");
25
+ });
25
26
 
26
- it("writes one durable comment addressed by the active cli_session", async () => {
27
- const calls: unknown[] = [];
28
- const result = await notifyLaneOfTaskChange(
29
- {
30
- cli_sessions: { findFirst: async () => ({ id: "session-5" }) },
31
- comments: {
32
- upsert: async (args) => {
33
- calls.push(args);
34
- return { id: "comment-1" };
35
- },
36
- },
37
- },
38
- "task-7",
39
- "subtask-1:completed",
40
- );
41
- expect(result).toMatchObject({ delivered: true, sessionId: "session-5", commentId: "comment-1" });
42
- expect(calls[0]).toMatchObject({ create: { work_item_id: "task-7", source_cli_session_id: "session-5" } });
43
- });
27
+ it("carries the task id in the message so the recipient can re-read just it", () => {
28
+ const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
29
+ expect(e.message).toContain("task-7");
30
+ expect(e.message).toContain("readTaskList");
31
+ });
32
+
33
+ it("produces a single-line message (the <channel> tag renders it inline)", () => {
34
+ const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
35
+ expect(e.message.includes("\n")).toBe(false);
36
+ });
44
37
 
45
- it("does not fabricate a pre-launch session or comment", async () => {
46
- let writes = 0;
47
- const result = await notifyLaneOfTaskChange(
48
- {
49
- cli_sessions: { findFirst: async () => null },
50
- comments: {
51
- upsert: async () => {
52
- writes++;
53
- return { id: "unexpected" };
54
- },
55
- },
56
- },
57
- "task-7",
58
- );
59
- expect(result).toEqual({ delivered: false, sessionId: null, commentId: null });
60
- expect(writes).toBe(0);
61
- });
38
+ it("stamps the system sender + a deterministic runId + the provided epoch-ms ts", () => {
39
+ const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
40
+ expect(e.fromOrchestratorId).toBe("task-sync");
41
+ expect(e.runId).toBe("task-sync-task-7-1700000000000");
42
+ expect(e.ts).toBe(1700000000000);
62
43
  });
44
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * DB→AGENT dispatch for task-sync — notifies a lane's live pane that its
7
+ * work_items task list changed out-of-band (operator toggled a subtask in the
8
+ * projects UI, another lane edited a shared task). The pane's adapter then
9
+ * re-reads (fn.taskSync.readTaskList) and updates its native list.
10
+ *
11
+ * MECHANISM — reuses the existing CLI-agnostic channel delivery, no new
12
+ * transport: a POST to scala-channel's fleet-events publish entry
13
+ * (POST /api/fleet/events — @teamscala/channel/fleet-events-publish) carrying an
14
+ * `orchestrator.message` FleetEvent with slotName "cross-pane". That slot is
15
+ * the watchEvents bypass for events NOT tied to a fleet_agent_runs row
16
+ * (FleetEventOrchestratorMessageSchema comment) — exactly the system-originated
17
+ * case. scala-channel re-emits onto its eventBus and streamFleetEvents delivers
18
+ * to the subscriber filtered by orchestratorSessionId (the target lane pane).
19
+ * Both the Claude Code relay and the OpenCode scala-hooks plugin subscribe to
20
+ * that SSE stream for their own pane identity and inject each event natively —
21
+ * so ONE publish reaches every CLI that has a live projection, no per-CLI
22
+ * dispatch code here.
23
+ *
24
+ * NOT the dispatch entry (POST /api/channel/send): that path requires a real
25
+ * sender pane identity (channel-sender-identity-validated anti-spoof) and so
26
+ * cannot carry a system-originated notification. The publish path is
27
+ * internal-trusted (localhost super-admin) and accepts a synthetic
28
+ * fromOrchestratorId — the correct path for a system→pane message.
29
+ *
30
+ * scala-channel's address is resolved from its service_routing row (local_port)
31
+ * — never hardcoded (service-ports-from-registry). Fire-and-forget: a dispatch
32
+ * failure (scala-channel down, port unresolvable) is logged and swallowed; it
33
+ * must never block the work_items write that triggered it, and the lane's
34
+ * SessionStart/UserPromptSubmit backstop picks up the change on its next turn
35
+ * regardless.
36
+ */
37
+
38
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
39
+ import { ServiceRoutingConfigSchema } from "@teamscala/db-validation/registry-schemas/service-routing";
40
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
41
+
42
+ let cachedChannelBaseUrl: string | null | undefined;
43
+
44
+ async function resolveChannelBaseUrl(): Promise<string | null> {
45
+ if (cachedChannelBaseUrl !== undefined) return cachedChannelBaseUrl;
46
+ try {
47
+ const row = await loadRegistryConfig("service_routing", "scala-channel", ServiceRoutingConfigSchema);
48
+ const port = row?.local_port;
49
+ cachedChannelBaseUrl = port ? `http://127.0.0.1:${port}` : null;
50
+ } catch {
51
+ cachedChannelBaseUrl = null;
52
+ }
53
+ return cachedChannelBaseUrl;
54
+ }
55
+
56
+ /** The system sender identity stamped on fromOrchestratorId. */
57
+ const FROM_ORCHESTRATOR_ID = "task-sync";
58
+
59
+ /**
60
+ * The `orchestrator.message` FleetEvent payload that notifies a lane its task
61
+ * list changed. Pure (no I/O) so it is unit-testable; notifyLaneOfTaskChange
62
+ * posts it. slotName "cross-pane" is REQUIRED — it is the watchEvents bypass for
63
+ * events not tied to a fleet_agent_runs row. orchestratorSessionId is the TARGET
64
+ * lane pane (the SSE subscriber filter). The message is single-line (the
65
+ * <channel> tag renders it inline).
66
+ */
67
+ export interface TaskChangedEvent {
68
+ type: "orchestrator.message";
69
+ message: string;
70
+ fromOrchestratorId: string;
71
+ runId: string;
72
+ slotName: "cross-pane";
73
+ ts: number;
74
+ orchestratorSessionId: string;
75
+ }
76
+
77
+ export function buildTaskChangedEvent(lanePane: string, taskId: string, now: number = Date.now()): TaskChangedEvent {
78
+ return {
79
+ type: "orchestrator.message",
80
+ message: `TaskSync: your work_items task list changed externally (task ${taskId}). Re-read via fn.taskSync.readTaskList and update your todo list.`,
81
+ fromOrchestratorId: FROM_ORCHESTRATOR_ID,
82
+ runId: `task-sync-${taskId}-${now}`,
83
+ slotName: "cross-pane",
84
+ ts: now,
85
+ orchestratorSessionId: lanePane,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Publish a "your task list changed" notification to the owning lane's pane.
91
+ * The pane identity is the stable tmux id ("tmux:%42") every CLI relay/plugin
92
+ * receives for. `taskId` is carried in the message so the recipient can re-read
93
+ * just that task. Fire-and-forget.
94
+ */
95
+ export async function notifyLaneOfTaskChange(
96
+ lanePane: string,
97
+ taskId: string,
98
+ ): Promise<void> {
99
+ const log = getAppLogger();
100
+ if (!lanePane) return;
101
+ const base = await resolveChannelBaseUrl();
102
+ if (!base) {
103
+ log.warn("[task-sync] notifyLaneOfTaskChange: scala-channel address unresolvable", { taskId });
104
+ return;
105
+ }
106
+ const payload = buildTaskChangedEvent(lanePane, taskId);
107
+ try {
108
+ const res = await fetch(`${base}/api/fleet/events`, {
109
+ method: "POST",
110
+ headers: { "content-type": "application/json" },
111
+ body: JSON.stringify(payload),
112
+ signal: AbortSignal.timeout(3000),
113
+ });
114
+ if (!res.ok) {
115
+ log.warn("[task-sync] notifyLaneOfTaskChange: non-2xx from scala-channel", {
116
+ taskId, status: res.status,
117
+ });
118
+ }
119
+ } catch (err) {
120
+ log.warn("[task-sync] notifyLaneOfTaskChange: POST failed", {
121
+ taskId, error: err instanceof Error ? err.message : String(err),
122
+ });
123
+ }
124
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane RESOLUTION for task-sync: given a session or a pane, which work_items
7
+ * task IS this lane, and what is it called.
8
+ *
9
+ * Split out of task-sync-router.ts, which had reached 998 of its 1000-line
10
+ * budget — two lines from frozen. That is not tidiness: the budget is a commit
11
+ * gate, so at 998 lines ANY change to task-sync was blocked for every lane, and
12
+ * the four-line fix that needed to land could not. The router header records
13
+ * being split once before for exactly this reason; it grew back.
14
+ *
15
+ * The seam matches the one that split the projection. Everything here answers
16
+ * "which lane, called what" from an identity, and none of it touches the ORPC
17
+ * surface or decides what a task list should become — which is why these move
18
+ * together rather than one at a time: resolveLaneTask alone has seven callers.
19
+ *
20
+ * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
21
+ * candidate. Checked against the 14 platform singletons and the package
22
+ * catalogue; the doctrine test fails plainly, because every export is bound to
23
+ * the work_items schema and to task-sync own kind_data.lane convention, so no
24
+ * unrelated app could install this unmodified.
25
+ */
26
+ import type { Prisma, WorkItem } from "./task-sync-router.ts";
27
+ export declare function resolveLaneWorkName(prisma: Prisma, pane: string): Promise<string | null>;
28
+ export declare function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null>;
29
+ /**
30
+ * Resolve a lane's task by its AUTHORITATIVE work_item id — the id the spawn
31
+ * minted (createBriefTask) and exported into the pane as SCALA_LANE_WORK_ITEM.
32
+ *
33
+ * WHY this exists alongside the session resolution: readAssignedBrief used to
34
+ * resolve ONLY by kind_data.lane.session, which is written by the lane's OWN
35
+ * bindLane hook at boot. That bind is a fire-and-forget POST racing the brief
36
+ * read, so a fleet lane whose bind had not yet commit read "no task" and the
37
+ * self-brief hook silently exited 0 — absent-input-reads-as-success at the
38
+ * dispatch boundary (a lane booted, was told its brief was loaded, had none,
39
+ * ran nothing, and was archived having done nothing — measured 2026-08-02).
40
+ *
41
+ * The work_item id is known to the spawn before the lane even starts
42
+ * (mintBriefTask returns it; the spawn REFUSES if minting fails for a capture
43
+ * CLI), so resolving by it CANNOT race: the row exists because the spawn
44
+ * created it. The session bind stays for task-list writes (upsertFromTaskList)
45
+ * — it is no longer load-bearing for brief delivery. Any status is matched
46
+ * (active OR archived) because the brief is the brief regardless of the run's
47
+ * state — closing a run archives the task, not the brief.
48
+ */
49
+ export declare function resolveTaskById(prisma: Prisma, workItemId: string): Promise<WorkItem | null>;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane RESOLUTION for task-sync: given a session or a pane, which work_items
7
+ * task IS this lane, and what is it called.
8
+ *
9
+ * Split out of task-sync-router.ts, which had reached 998 of its 1000-line
10
+ * budget — two lines from frozen. That is not tidiness: the budget is a commit
11
+ * gate, so at 998 lines ANY change to task-sync was blocked for every lane, and
12
+ * the four-line fix that needed to land could not. The router header records
13
+ * being split once before for exactly this reason; it grew back.
14
+ *
15
+ * The seam matches the one that split the projection. Everything here answers
16
+ * "which lane, called what" from an identity, and none of it touches the ORPC
17
+ * surface or decides what a task list should become — which is why these move
18
+ * together rather than one at a time: resolveLaneTask alone has seven callers.
19
+ *
20
+ * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
21
+ * candidate. Checked against the 14 platform singletons and the package
22
+ * catalogue; the doctrine test fails plainly, because every export is bound to
23
+ * the work_items schema and to task-sync own kind_data.lane convention, so no
24
+ * unrelated app could install this unmodified.
25
+ */
26
+
27
+ import type { Prisma, WorkItem } from "./task-sync-router.ts";
28
+
29
+ export async function resolveLaneWorkName(prisma: Prisma, pane: string): Promise<string | null> {
30
+ try {
31
+ const runs = (
32
+ prisma as unknown as {
33
+ fleet_agent_runs?: {
34
+ findFirst: (a: unknown) => Promise<{ slot_name?: string | null } | null>;
35
+ };
36
+ }
37
+ ).fleet_agent_runs;
38
+ if (!runs?.findFirst) return null;
39
+ // Most recent run for the pane, REGARDLESS of state. A lane's work name does
40
+ // not vanish when its run closes, and filtering `state != closed` here is
41
+ // exactly why task rows were titled "Lane": a task-list write (ensureLaneTask)
42
+ // that fires after the run closed resolved no name and fell back to the
43
+ // generic sentinel — spawn debris an orchestrator cannot attribute
44
+ // (measured 2026-08-01: 207 "Lane" rows). Newest-first so a reused pane
45
+ // resolves its CURRENT lane, not a prior one.
46
+ const run = await runs.findFirst({
47
+ where: { pane },
48
+ orderBy: { created_at: "desc" },
49
+ select: { slot_name: true },
50
+ });
51
+ const name = run?.slot_name?.trim();
52
+ return name ? name : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export async function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null> {
59
+ const active = await prisma.work_items.findFirst({
60
+ where: {
61
+ kind: "task",
62
+ status: "active",
63
+ kind_data: { path: ["lane", "session"], equals: session },
64
+ },
65
+ // Deterministic by oldest. An unordered findFirst returns whichever row
66
+ // Postgres happens to hand back, so with two parents for one lane (the
67
+ // bindLane race, below) one turn wrote the lane's todos under parent A
68
+ // and the next read parent B and found nothing — the lane's task list
69
+ // appeared to empty itself, and the watchdog accused lanes that were
70
+ // holding a populated list. Migration 109 makes the duplicate
71
+ // unwritable; this makes the read stable regardless.
72
+ orderBy: { created_at: "asc" },
73
+ });
74
+ if (active) return active;
75
+
76
+ // FALL BACK TO AN ARCHIVED PARENT FOR THE SAME SESSION.
77
+ //
78
+ // Closing a run archives the lane's parent (close-run.ts) specifically to
79
+ // drop it from the active match set above. That is correct when the LANE is
80
+ // gone, and wrong whenever the run record closes while the lane keeps
81
+ // running — which happens on a tmux-server restart (runs are reaped, panes
82
+ // are restored) and whenever an orchestrator closes a run on a stale read.
83
+ // The lane then has a full task list that this function cannot see, so
84
+ // callers mint a FRESH parent, the old list is orphaned, and the compliance
85
+ // watchdog reports "working with NO task list" at a lane holding 85 tasks.
86
+ // That is the "KNOWN ADJACENT DEFECT" recorded on the lane-working-has-task-list
87
+ // guard row, observed live 2026-07-31.
88
+ //
89
+ // Safe because a session id is minted per lane spawn and never reused, so an
90
+ // archived parent under this session belongs to THIS lane. Newest-first.
91
+ return prisma.work_items.findFirst({
92
+ where: {
93
+ kind: "task",
94
+ status: "archived",
95
+ kind_data: { path: ["lane", "session"], equals: session },
96
+ },
97
+ orderBy: { created_at: "desc" },
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Resolve a lane's task by its AUTHORITATIVE work_item id — the id the spawn
103
+ * minted (createBriefTask) and exported into the pane as SCALA_LANE_WORK_ITEM.
104
+ *
105
+ * WHY this exists alongside the session resolution: readAssignedBrief used to
106
+ * resolve ONLY by kind_data.lane.session, which is written by the lane's OWN
107
+ * bindLane hook at boot. That bind is a fire-and-forget POST racing the brief
108
+ * read, so a fleet lane whose bind had not yet commit read "no task" and the
109
+ * self-brief hook silently exited 0 — absent-input-reads-as-success at the
110
+ * dispatch boundary (a lane booted, was told its brief was loaded, had none,
111
+ * ran nothing, and was archived having done nothing — measured 2026-08-02).
112
+ *
113
+ * The work_item id is known to the spawn before the lane even starts
114
+ * (mintBriefTask returns it; the spawn REFUSES if minting fails for a capture
115
+ * CLI), so resolving by it CANNOT race: the row exists because the spawn
116
+ * created it. The session bind stays for task-list writes (upsertFromTaskList)
117
+ * — it is no longer load-bearing for brief delivery. Any status is matched
118
+ * (active OR archived) because the brief is the brief regardless of the run's
119
+ * state — closing a run archives the task, not the brief.
120
+ */
121
+ export async function resolveTaskById(prisma: Prisma, workItemId: string): Promise<WorkItem | null> {
122
+ return prisma.work_items.findFirst({
123
+ where: { id: workItemId, kind: "task" },
124
+ });
125
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The task-sync PROJECTION: how a CLI's task list maps onto `work_items` rows,
7
+ * and what reconciling one against the other should do.
8
+ *
9
+ * Split out of `task-sync-router.ts`, which had grown past the 1000-line budget
10
+ * and so could not be committed by anyone touching it. The seam is real rather
11
+ * than arbitrary: everything here is PURE and decides WHAT should change, while
12
+ * the router owns the ORPC surface and performs the writes. That is exactly why
13
+ * this half is directly testable with no database.
14
+ *
15
+ * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
16
+ * candidate. Checked against the 14 platform singletons and the package catalog
17
+ * — nothing generic to fold into, and the doctrine test fails: no unrelated app
18
+ * could install this unmodified, because every export is bound to the
19
+ * `work_items` schema and task-sync's own contract (STATUS_TO_WORK_ITEM encodes
20
+ * the work_items status enum, subtaskKey is this projection's key convention,
21
+ * planReconcile diffs against work_items subtask rows).
22
+ *
23
+ * The near-miss is worth naming so the next reader does not re-derive it: the
24
+ * SHAPE — diff a desired list against existing rows into create/update/delete —
25
+ * is generic, and `document-signing` is precedent for lifting a pure domain half
26
+ * out of service-boot into a package. It stays here because it has exactly one
27
+ * consumer; extracting it now would be speculative packaging, which
28
+ * platform-capabilities treats as drift. Revisit if a SECOND surface needs
29
+ * list-against-rows reconciliation — then the generic diff core (not the
30
+ * work_items-specific mapping) is what earns a package.
31
+ */
32
+ import type { TaskListItem, WorkItem } from "./task-sync-router.ts";
33
+ /** CLI task-list status → work_items.status. The canonical forward direction. */
34
+ export declare const STATUS_TO_WORK_ITEM: Record<"completed" | "in_progress" | "pending", string>;
35
+ export declare function normalizeStatus(s: string | null): TaskListItem["status"];
36
+ /**
37
+ * Decide the status to WRITE for an existing row, given what the CLI list says.
38
+ *
39
+ * The CLI task list has no `blocked` state, so a blocked row round-trips as
40
+ * `pending` (via `normalizeStatus`, which collapses unknown/internal statuses)
41
+ * and then maps back to `todo` — silently destroying the block. That is not a
42
+ * cosmetic drift: the stop gate reads these rows, so un-blocking one re-blocks
43
+ * the LANE from stopping, and the lane's only apparent fix is to touch the list
44
+ * again, which destroys it again.
45
+ *
46
+ * A `todo` arriving from the CLI therefore carries NO information: it is what
47
+ * the projection emits for every status it cannot represent, not an assertion
48
+ * that the work became un-blocked. `in-progress` and `done` are different —
49
+ * those are real transitions the CLI CAN express, so they win normally.
50
+ *
51
+ * Measured 2026-07-30: a lane set a row to `blocked` with a named blocker, the
52
+ * next TaskUpdate reset it to `todo`, and the stop gate reported the lane
53
+ * unfinished again — three turns of ping-pong before the cause was found.
54
+ */
55
+ export declare function resolveProjectedStatus(currentStatus: string | null | undefined, incomingStatus: string): string;
56
+ export declare function subtaskKey(item: {
57
+ mark?: string;
58
+ content: string;
59
+ title: string;
60
+ }): string;
61
+ /**
62
+ * Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
63
+ * event address, and what should happen when none does. Extracted for the same
64
+ * reason planReconcile was — the handler should only EXECUTE, so the matching
65
+ * rule is unit-testable.
66
+ *
67
+ * Three resolutions:
68
+ * - `update` — matched by mark, or (mark absent/unstamped) by exact title.
69
+ * - `heal` — nothing matched but the event carries a subject, so the row can
70
+ * be reconstructed. Covers a TaskCreate that silently produced no
71
+ * subtask, which previously orphaned the task forever.
72
+ * - `discard` — nothing matched and there is no subject to rebuild a title from.
73
+ *
74
+ * Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
75
+ * "2" was never written at all, and every later update for it was dropped as
76
+ * `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
77
+ * distinct lane sessions, were that one reason.
78
+ */
79
+ export declare function resolveTaskUpdateTarget(existing: Array<{
80
+ id: string;
81
+ title: string;
82
+ kind_data?: Record<string, unknown> | null;
83
+ }>, evt: {
84
+ taskId: string;
85
+ subject?: string;
86
+ }): {
87
+ op: "update";
88
+ id: string;
89
+ } | {
90
+ op: "heal";
91
+ title: string;
92
+ } | {
93
+ op: "discard";
94
+ };
95
+ /**
96
+ * Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
97
+ * (current) task list, compute the create/update/delete ops that make the
98
+ * subtask set match the list — WITHOUT touching prisma. Extracted so the
99
+ * matching/status/positioning logic is unit-testable (the handler only executes
100
+ * the plan). Matching is by stable mark when the CLI provides one, else by
101
+ * content. Position follows incoming index, so a reorder is an update.
102
+ */
103
+ export interface ReconcilePlan {
104
+ toCreate: Array<{
105
+ content: string;
106
+ status: string;
107
+ completed: boolean;
108
+ position: string;
109
+ kind_data: Record<string, unknown>;
110
+ }>;
111
+ toUpdate: Array<{
112
+ id: string;
113
+ content: string;
114
+ status: string;
115
+ completed: boolean;
116
+ position: string;
117
+ kind_data: Record<string, unknown>;
118
+ }>;
119
+ toDelete: string[];
120
+ }
121
+ export declare function planReconcile(existing: WorkItem[], incoming: TaskListItem[], source?: string): ReconcilePlan;