@teamscala/service-boot 0.23.317 → 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 +123 -8
  10. package/src/fn-routers/task-sync-assign.ts +105 -18
  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 -276
  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
@@ -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
  });
@@ -5,56 +5,84 @@
5
5
  *
6
6
  * Unit tests for the self-brief leg: shapeBriefResponse (the pure
7
7
  * no-task/no-comment ⇒ nulls decision) + source-contract assertions that
8
- * readLatestTaskComment reads the LATEST comment and the handler resolves by
8
+ * readFirstTaskComment reads the FIRST launch comment and the handler resolves by
9
9
  * session. Covers `what-must-have-a-test` for the new procedure.
10
10
  */
11
11
  import { describe, it, expect } from "bun:test";
12
- import { shapeBriefResponse, readLatestTaskComment } from "./task-sync-brief.ts";
12
+ import { shapeBriefResponse, readFirstTaskComment } from "./task-sync-brief.ts";
13
13
 
14
14
  describe("shapeBriefResponse (no verdict from no data)", () => {
15
- it("returns all-null when there is no task", () => {
16
- // A lane with no bound task has no brief — null, never an empty fake.
17
- expect(shapeBriefResponse(null, { content: "x", created_at: new Date() })).toEqual({
15
+ it("classifies a missing task as unbound (work_item_id null)", () => {
16
+ // A lane with no resolvable task has no brief — null, never an empty fake.
17
+ // status:"unbound" is the signal a non-fleet lane no-ops on and a fleet
18
+ // lane (which resolves by work_item_id, so cannot reach this) never sees.
19
+ expect(shapeBriefResponse(null, { id: "c-0", content: "x", created_at: new Date() })).toEqual({
18
20
  work_item_id: null,
21
+ comment_id: null,
19
22
  brief: null,
20
23
  comment_created_at: null,
24
+ status: "unbound",
21
25
  });
22
26
  });
23
27
 
24
- it("returns all-null when the task has no comment yet", () => {
25
- // The task exists but no spawn comment landed; the extension must inject
26
- // nothing rather than a fabricated brief.
28
+ it("classifies a task with NO comment as no_comment (work_item_id SET)", () => {
29
+ // The task resolved but no spawn comment landed. work_item_id is now SET
30
+ // (the task IS bound) — distinct from unbound — and status:"no_comment"
31
+ // is the broken state a fleet lane halts on rather than reading as success.
27
32
  expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
28
- work_item_id: null,
33
+ work_item_id: "task-1",
34
+ comment_id: null,
35
+ brief: null,
36
+ comment_created_at: null,
37
+ status: "no_comment",
38
+ });
39
+ });
40
+
41
+ it("classifies a task with a BLANK comment as empty_brief (the unreadable case)", () => {
42
+ // The comment exists but its content is empty/whitespace — "loads it
43
+ // empty". A fleet lane whose spawn minted a task MUST have a non-empty
44
+ // brief, so a blank comment is a defect the hook halts on, not a success.
45
+ expect(shapeBriefResponse({ id: "task-2" }, { id: "c-2", content: " ", created_at: new Date() })).toEqual({
46
+ work_item_id: "task-2",
47
+ comment_id: "c-2",
29
48
  brief: null,
30
49
  comment_created_at: null,
50
+ status: "empty_brief",
51
+ });
52
+ expect(shapeBriefResponse({ id: "task-2b" }, { id: "c-2b", content: "", created_at: new Date() })).toEqual({
53
+ work_item_id: "task-2b",
54
+ comment_id: "c-2b",
55
+ brief: null,
56
+ comment_created_at: null,
57
+ status: "empty_brief",
31
58
  });
32
59
  });
33
60
 
34
- it("returns the comment content + ISO created_at for a resolved task+comment", () => {
61
+ it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
35
62
  const ts = new Date("2026-08-01T03:24:00.000Z");
36
- expect(shapeBriefResponse({ id: "task-7" }, { content: "Do the thing", created_at: ts })).toEqual({
63
+ expect(shapeBriefResponse({ id: "task-7" }, { id: "c-7", content: "Do the thing", created_at: ts })).toEqual({
37
64
  work_item_id: "task-7",
65
+ comment_id: "c-7",
38
66
  brief: "Do the thing",
39
67
  comment_created_at: "2026-08-01T03:24:00.000Z",
68
+ status: "ready",
40
69
  });
41
70
  });
42
71
 
43
72
  it("serializes the comment created_at deterministically (Date → ISO)", () => {
44
73
  const ts = new Date(0);
45
- const out = shapeBriefResponse({ id: "t" }, { content: "c", created_at: ts });
74
+ const out = shapeBriefResponse({ id: "t" }, { id: "c", content: "c", created_at: ts });
46
75
  expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
47
76
  });
48
77
  });
49
78
 
50
- describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
79
+ describe("readAssignedBrief reads the FIRST launch comment (source contract)", () => {
51
80
  const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
52
81
 
53
- it("queries comments newest-first by created_at on work_item_id", async () => {
54
- // The comment IS the brief for a run; ordering by created_at desc makes the
55
- // LATEST comment (this run's) the answer, never a stale prior-run comment.
82
+ it("queries comments oldest-first with an id tie-breaker", async () => {
83
+ // The first comment is immutable launch identity; later comments are dialogue.
56
84
  const text = await briefSrc.text();
57
- expect(text).toContain('orderBy: { created_at: "desc" }');
85
+ expect(text).toContain('orderBy: [{ created_at: "asc" }, { id: "asc" }]');
58
86
  expect(text).toContain("work_item_id: workItemId");
59
87
  });
60
88
 
@@ -64,14 +92,29 @@ describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
64
92
  const text = await briefSrc.text();
65
93
  expect(text).toContain("resolveLaneTask(prisma, session)");
66
94
  });
95
+
96
+ it("resolves by the authoritative work_item_id BEFORE the session (deletes the bind race)", async () => {
97
+ // The spawn's known task id (SCALA_LANE_WORK_ITEM) cannot race — the row
98
+ // exists because the spawn created it — so resolving by it first makes an
99
+ // empty brief for a fleet lane unambiguously broken rather than a
100
+ // transient "retry" the lane can never exit (absent-input-reads-as-success).
101
+ const text = await briefSrc.text();
102
+ expect(text).toContain("workItemId");
103
+ expect(text).toContain("resolveTaskById(prisma, workItemId)");
104
+ // The session bind is no longer load-bearing for brief delivery: it stays
105
+ // only as the fallback for callers without a work_item_id.
106
+ const idIdx = text.indexOf("resolveTaskById(prisma, workItemId)");
107
+ const sessionIdx = text.indexOf("resolveLaneTask(prisma, session)");
108
+ expect(idIdx).toBeGreaterThan(-1);
109
+ expect(sessionIdx).toBeGreaterThan(idIdx);
110
+ });
67
111
  });
68
112
 
69
- describe("readLatestTaskComment is a single latest-comment read", () => {
113
+ describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
70
114
  it("returns null when the prisma client has no comments model (feature-detect)", async () => {
71
- // `comments` is a universal shared model, but the access is feature-detected
72
- // like the fleet_agent_runs reads so a client without it degrades to "no
73
- // brief" rather than throwing.
74
- const out = await readLatestTaskComment({} as never, "task-1");
115
+ // `comments` is a universal shared model; a client without it degrades to
116
+ // "no brief" rather than throwing.
117
+ const out = await readFirstTaskComment({} as never, "task-1");
75
118
  expect(out).toBe(null);
76
119
  });
77
120
  });
@@ -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 LATEST comment.
8
+ * assigned to that session, and reads the brief from that task's FIRST 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;
@@ -25,38 +25,77 @@
25
25
  import type { Prisma, CommentRow } from "./task-sync-router.ts";
26
26
  import type { WorkItem } from "./task-sync-router.ts";
27
27
  import { authed, type Context } from "./auth-router.ts";
28
- import { resolveLaneTask } from "./task-sync-lane.ts";
28
+ import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
29
29
 
30
30
  /**
31
- * Shape the readAssignedBrief response from a resolved task + its latest
31
+ * The outcome of a brief read, single-sourced here so every CLI's self-brief
32
+ * hook (zerostack/codex shell, opencode/pi TS) branches on ONE field rather
33
+ * than each re-deriving "is the brief missing" from null/empty probes — the
34
+ * seam at which absent-input-reads-as-success hid (`the-severe-bugs-live-at-seams`).
35
+ *
36
+ * The hook decides ACTION from this; the classification lives here. A fleet
37
+ * lane (SCALA_LANE_WORK_ITEM set) treats anything but "ready" as a hard halt.
38
+ */
39
+ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
40
+
41
+ /**
42
+ * Shape the readAssignedBrief response from a resolved task + its first
32
43
  * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
33
- * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable. A null
34
- * task or null comment returns all-null rather than an empty/fake brief, so the
35
- * extension can distinguish "nothing to inject" from "inject this".
44
+ * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
45
+ *
46
+ * `work_item_id` is set iff the TASK resolved (independent of whether a comment
47
+ * exists), and `status` distinguishes the four states a hook must act on:
48
+ * - "ready" — task + a non-empty comment: inject the brief.
49
+ * - "empty_brief" — task resolved, comment exists but content is blank: the
50
+ * spawn minted a task with no usable brief — broken.
51
+ * - "no_comment" — task resolved, no comment at all: same broken class.
52
+ * - "unbound" — no task resolved: the lane has no brief task (a non-fleet
53
+ * operator tab, or — for the session-only path — a bind
54
+ * that has not yet commit; the work_item_id path cannot
55
+ * reach this for a fleet lane).
56
+ *
57
+ * "empty_brief" / "no_comment" with the task resolved is the state the
58
+ * absent-input fix exists to catch: a fleet lane whose spawn succeeded MUST
59
+ * have a non-empty brief comment, so its absence is a defect the hook halts on
60
+ * rather than reading as success.
36
61
  */
37
62
  export function shapeBriefResponse(
38
63
  task: { id: string } | null,
39
- comment: Pick<CommentRow, "content" | "created_at"> | null,
40
- ): { work_item_id: string | null; brief: string | null; comment_created_at: string | null } {
41
- if (!task || !comment) {
42
- return { work_item_id: null, brief: null, comment_created_at: null };
64
+ comment: Pick<CommentRow, "id" | "content" | "created_at"> | null,
65
+ ): {
66
+ work_item_id: string | null;
67
+ comment_id: string | null;
68
+ brief: string | null;
69
+ comment_created_at: string | null;
70
+ status: BriefReadStatus;
71
+ } {
72
+ if (!task) {
73
+ return { work_item_id: null, comment_id: null, brief: null, comment_created_at: null, status: "unbound" };
74
+ }
75
+ if (!comment) {
76
+ return { work_item_id: task.id, comment_id: null, brief: null, comment_created_at: null, status: "no_comment" };
77
+ }
78
+ const text = typeof comment.content === "string" ? comment.content : String(comment.content);
79
+ if (text.trim() === "") {
80
+ return { work_item_id: task.id, comment_id: comment.id, brief: null, comment_created_at: null, status: "empty_brief" };
43
81
  }
44
82
  return {
45
83
  work_item_id: task.id,
46
- brief: comment.content,
84
+ comment_id: comment.id,
85
+ brief: text,
47
86
  comment_created_at:
48
87
  comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
88
+ status: "ready",
49
89
  };
50
90
  }
51
91
 
52
92
  /**
53
- * Read the LATEST comment on a work_items task. The comment IS the brief for a
54
- * run: every spawn writes a NEW comment, and the description is never the brief,
55
- * so "what was this lane told" always has exactly one answer
56
- * (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
57
- * task has no comment yet — the extension treats that as "no brief to inject".
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.
58
97
  */
59
- export async function readLatestTaskComment(
98
+ export async function readFirstTaskComment(
60
99
  prisma: Prisma,
61
100
  workItemId: string,
62
101
  ): Promise<CommentRow | null> {
@@ -70,7 +109,7 @@ export async function readLatestTaskComment(
70
109
  if (!comments?.findFirst) return null;
71
110
  return comments.findFirst({
72
111
  where: { work_item_id: workItemId },
73
- orderBy: { created_at: "desc" },
112
+ orderBy: [{ created_at: "asc" }, { id: "asc" }],
74
113
  });
75
114
  }
76
115
 
@@ -84,14 +123,29 @@ export async function readLatestTaskComment(
84
123
  export const readAssignedBriefHandler = authed.handler(async ({ input, context }) => {
85
124
  const ctx = context as Context;
86
125
  const prisma = ctx.prisma as Prisma;
87
- const body = (input ?? {}) as { lane_session?: string };
126
+ const body = (input ?? {}) as { lane_session?: string; work_item_id?: string };
88
127
  const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
89
- if (!session) {
128
+ // The spawn's known task id (SCALA_LANE_WORK_ITEM) — AUTHORITATIVE for brief
129
+ // resolution. Resolving by it first deletes the bindLane race that made an
130
+ // empty brief read as a transient "retry" a fleet lane could never exit
131
+ // (absent-input-reads-as-success). Falls back to session for callers without
132
+ // it (operator tabs / pre-id lanes).
133
+ const workItemId =
134
+ body.work_item_id?.trim() || ctx.headers?.get("x-lane-work-item")?.trim() || null;
135
+ if (!session && !workItemId) {
90
136
  const { ORPCError } = await import("@orpc/server");
91
- throw new ORPCError("BAD_REQUEST", { message: "readAssignedBrief requires lane_session" });
137
+ throw new ORPCError("BAD_REQUEST", {
138
+ message: "readAssignedBrief requires lane_session or work_item_id",
139
+ });
140
+ }
141
+ let task: (WorkItem & { id: string }) | null = null;
142
+ if (workItemId) {
143
+ task = (await resolveTaskById(prisma, workItemId)) as (WorkItem & { id: string }) | null;
144
+ }
145
+ if (!task && session) {
146
+ task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
92
147
  }
93
- const task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
94
148
  if (!task) return shapeBriefResponse(null, null);
95
- const comment = await readLatestTaskComment(prisma, task.id);
149
+ const comment = await readFirstTaskComment(prisma, task.id);
96
150
  return shapeBriefResponse(task, comment);
97
151
  });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { closedTaskState } from "./task-sync-close.ts";
9
+
10
+ const task = {
11
+ id: "task-1",
12
+ kind_data: {
13
+ lane: { session: "acp-session", cli_kind: "zerostack", live: true },
14
+ assignee_type: "agent",
15
+ },
16
+ };
17
+
18
+ describe("closedTaskState", () => {
19
+ it("marks completed work done and the lane non-live", () => {
20
+ expect(closedTaskState(task, "completed")).toEqual({
21
+ task_id: "task-1",
22
+ task_status: "done",
23
+ completed: true,
24
+ kind_data: {
25
+ lane: { session: "acp-session", cli_kind: "zerostack", live: false },
26
+ assignee_type: "agent",
27
+ },
28
+ });
29
+ });
30
+
31
+ it("archives every disposition that does not prove completion", () => {
32
+ for (const disposition of [
33
+ "needs_retry",
34
+ "failed",
35
+ "orchestrator_closed",
36
+ "orphaned",
37
+ ] as const) {
38
+ const state = closedTaskState(task, disposition);
39
+ expect(state.task_status).toBe("archived");
40
+ expect(state.completed).toBe(false);
41
+ expect((state.kind_data.lane as { live: boolean }).live).toBe(false);
42
+ }
43
+ });
44
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane-close write-through for task-sync. The session-owning fleet runtime
7
+ * calls this ORPC procedure before releasing an ACP lane, so work_items remains
8
+ * the durable lifecycle source without a second raw-SQL writer in the host.
9
+ */
10
+
11
+ import { ORPCError } from "@orpc/server";
12
+ import * as v from "valibot";
13
+ import { authed, type Context } from "./auth-router.ts";
14
+ import type { Prisma, WorkItem } from "./task-sync-router.ts";
15
+
16
+ export const closeLaneInput = v.object({
17
+ lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
18
+ disposition: v.picklist([
19
+ "completed",
20
+ "needs_retry",
21
+ "failed",
22
+ "orchestrator_closed",
23
+ "orphaned",
24
+ ]),
25
+ });
26
+
27
+ export interface ClosedTaskState {
28
+ task_id: string;
29
+ task_status: "done" | "archived";
30
+ completed: boolean;
31
+ kind_data: Record<string, unknown>;
32
+ }
33
+
34
+ export function closedTaskState(
35
+ task: Pick<WorkItem, "id" | "kind_data">,
36
+ disposition: v.InferOutput<typeof closeLaneInput>["disposition"],
37
+ ): ClosedTaskState {
38
+ const completed = disposition === "completed";
39
+ const lane =
40
+ task.kind_data.lane && typeof task.kind_data.lane === "object"
41
+ ? (task.kind_data.lane as Record<string, unknown>)
42
+ : {};
43
+ return {
44
+ task_id: task.id,
45
+ task_status: completed ? "done" : "archived",
46
+ completed,
47
+ kind_data: { ...task.kind_data, lane: { ...lane, live: false } },
48
+ };
49
+ }
50
+
51
+ export const closeLaneHandler = authed
52
+ .input(closeLaneInput)
53
+ .handler(async ({ input, context }) => {
54
+ const prisma = (context as Context).prisma as Prisma;
55
+ const task = await prisma.work_items.findFirst({
56
+ where: {
57
+ kind: "task",
58
+ kind_data: { path: ["lane", "session"], equals: input.lane_session },
59
+ },
60
+ orderBy: { created_at: "desc" },
61
+ });
62
+ if (!task) {
63
+ throw new ORPCError("NOT_FOUND", {
64
+ message: `No task is bound to ACP lane ${input.lane_session}`,
65
+ });
66
+ }
67
+ const state = closedTaskState(task, input.disposition);
68
+ await prisma.work_items.update({
69
+ where: { id: task.id },
70
+ data: {
71
+ kind_data: state.kind_data,
72
+ status: state.task_status,
73
+ completed: state.completed,
74
+ },
75
+ select: { id: true },
76
+ });
77
+ return {
78
+ task_id: state.task_id,
79
+ task_status: state.task_status,
80
+ reconciled: true,
81
+ };
82
+ });
@@ -3,42 +3,60 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
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.
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.
11
8
  */
12
9
  import { describe, it, expect } from "bun:test";
13
- import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
10
+ import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
14
11
 
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
- });
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
+ });
26
25
 
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
- });
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
+ });
37
44
 
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);
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
+ });
43
62
  });
44
- });