@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Tests for classifyTaskHookEvent — the discriminator between the Claude task
7
+ * lifecycle legs.
8
+ *
9
+ * The regression this locks: `TaskCreated` and `TaskCompleted` BOTH carry
10
+ * `task_subject`, so the handler's original "has a subject → it's a creation"
11
+ * test could not tell them apart. Only `TaskCreated` was ever wired, so nothing
12
+ * exercised the ambiguity — but the moment completion is wired, a completion
13
+ * classified as a creation mints a DUPLICATE subtask instead of closing one.
14
+ * That is the operator-visible symptom exactly: a task list that only ever
15
+ * grows, with everything reading incomplete (`task-projection-converges`).
16
+ */
17
+
18
+ import { describe, expect, test } from "bun:test";
19
+ import { classifyTaskHookEvent, sanitizeTaskSubject } from "./task-sync-router.ts";
20
+
21
+ describe("classifyTaskHookEvent", () => {
22
+ test("a completion carrying a subject is COMPLETED, not a creation", () => {
23
+ // The exact shape the TaskCompleted hook delivers: subject present.
24
+ expect(
25
+ classifyTaskHookEvent({
26
+ hook_event_name: "TaskCompleted",
27
+ task_subject: "Verify bindLane populates Task",
28
+ }),
29
+ ).toBe("completed");
30
+ });
31
+
32
+ test("a creation carrying a subject is CREATED", () => {
33
+ expect(
34
+ classifyTaskHookEvent({
35
+ hook_event_name: "TaskCreated",
36
+ task_subject: "Verify bindLane populates Task",
37
+ }),
38
+ ).toBe("created");
39
+ });
40
+
41
+ test("subject alone still classifies as created (payloads predating hook_event_name)", () => {
42
+ expect(classifyTaskHookEvent({ task_subject: "Some task" })).toBe("created");
43
+ });
44
+
45
+ test("a PostToolUse-shaped payload with no subject falls through to the tool leg", () => {
46
+ expect(classifyTaskHookEvent({})).toBe("tool");
47
+ expect(classifyTaskHookEvent({ hook_event_name: "PostToolUse" })).toBe("tool");
48
+ });
49
+
50
+ test("event name WINS over subject presence — the whole point", () => {
51
+ // If this ever inverts, completions silently become duplicate creations.
52
+ const completed = classifyTaskHookEvent({
53
+ hook_event_name: "TaskCompleted",
54
+ task_subject: "x",
55
+ });
56
+ const created = classifyTaskHookEvent({
57
+ hook_event_name: "TaskCreated",
58
+ task_subject: "x",
59
+ });
60
+ expect(completed).not.toBe(created);
61
+ });
62
+ });
63
+
64
+ // The subjects below are the REAL corrupted values found in work_items on
65
+ // 2026-07-25 — three rows whose titles rendered on the fleet dashboard as a
66
+ // wall of XML where the operator expects a task name. The malformation happens
67
+ // upstream in the CLI's hook payload, so the write boundary is the only place
68
+ // that can stop it reaching the single source of truth every surface reads.
69
+ describe("sanitizeTaskSubject", () => {
70
+ test("cuts the leaked tool call, keeping the real subject", () => {
71
+ expect(
72
+ sanitizeTaskSubject(
73
+ 'Cut judge false positives on clean work</subject>\n<parameter name="description">Live gate blocks ~half of clean drafts (7 of 7 failures were clean).',
74
+ ),
75
+ ).toBe("Cut judge false positives on clean work");
76
+
77
+ expect(
78
+ sanitizeTaskSubject(
79
+ 'Give benchmark drafts synthetic tool activity</subject>\n<parameter name="description">The labelled suite sends drafts with no tool activity.',
80
+ ),
81
+ ).toBe("Give benchmark drafts synthetic tool activity");
82
+ });
83
+
84
+ test("cuts on a bare next-parameter marker with no closing tag", () => {
85
+ expect(sanitizeTaskSubject('Do the thing<parameter name="description">blah')).toBe(
86
+ "Do the thing",
87
+ );
88
+ });
89
+
90
+ test("leaves an ordinary subject untouched", () => {
91
+ // Including ones with punctuation that merely LOOKS structural — angle
92
+ // brackets alone are not a leak, only the tool-call markers are.
93
+ expect(sanitizeTaskSubject("Bound the pg-boss pool")).toBe("Bound the pg-boss pool");
94
+ expect(sanitizeTaskSubject(" Fix a < b comparison ")).toBe("Fix a < b comparison");
95
+ expect(sanitizeTaskSubject("Rename foo -> bar")).toBe("Rename foo -> bar");
96
+ });
97
+
98
+ test("truncates rather than discards, so the task is not lost", () => {
99
+ // Dropping the row would lose a task the lane genuinely created; the text
100
+ // before the marker is the real subject and is worth keeping.
101
+ expect(sanitizeTaskSubject("Real subject</subject>junk").length).toBeGreaterThan(0);
102
+ });
103
+
104
+ test("an entirely-markup subject collapses to empty so the caller discards it", () => {
105
+ // The create leg treats "" as no-subject and records a discard — correct,
106
+ // because there is no real title to keep here.
107
+ expect(sanitizeTaskSubject('</subject><parameter name="description">x')).toBe("");
108
+ });
109
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
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.
11
+ */
12
+ import { describe, it, expect } from "bun:test";
13
+ import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
14
+
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
+ });
26
+
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
+ });
37
+
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);
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,279 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Unit tests for the pure task-sync reconciliation logic (planReconcile +
7
+ * status mapping). The ORPC handlers only EXECUTE the plan against prisma; the
8
+ * matching / status / positioning decisions live here so they are unit-testable
9
+ * without a DB. Covers `what-must-have-a-test` for the public API.
10
+ */
11
+ import { describe, it, expect } from "bun:test";
12
+ import {
13
+ resolveTaskUpdateTarget,
14
+ planReconcile,
15
+ normalizeStatus,
16
+ subtaskKey,
17
+ STATUS_TO_WORK_ITEM,
18
+ type TaskListItem,
19
+ } from "./task-sync-router.ts";
20
+
21
+ interface WorkItemLike {
22
+ id: string;
23
+ kind: string;
24
+ parent_id: string | null;
25
+ organisation_id: string;
26
+ title: string;
27
+ status: string | null;
28
+ position: string | null;
29
+ completed: boolean | null;
30
+ kind_data: Record<string, unknown>;
31
+ }
32
+
33
+ function subtask(opts: Partial<WorkItemLike> & { id: string; title: string }): WorkItemLike {
34
+ return {
35
+ kind: "subtask",
36
+ parent_id: "task-1",
37
+ organisation_id: "org-1",
38
+ status: "todo",
39
+ position: null,
40
+ completed: false,
41
+ kind_data: {},
42
+ ...opts,
43
+ };
44
+ }
45
+
46
+ const item = (content: string, status: TaskListItem["status"] = "pending", mark?: string): TaskListItem => ({
47
+ content,
48
+ status,
49
+ ...(mark !== undefined ? { mark } : {}),
50
+ });
51
+
52
+ describe("task-sync status mapping", () => {
53
+ it("maps normalized status to the work_items DB enum (hyphenated in-progress)", () => {
54
+ expect(STATUS_TO_WORK_ITEM.pending).toBe("todo");
55
+ expect(STATUS_TO_WORK_ITEM.in_progress).toBe("in-progress");
56
+ expect(STATUS_TO_WORK_ITEM.completed).toBe("done");
57
+ });
58
+
59
+ it("normalizes work_items status back to the CLI form", () => {
60
+ expect(normalizeStatus("todo")).toBe("pending");
61
+ expect(normalizeStatus("in-progress")).toBe("in_progress");
62
+ expect(normalizeStatus("done")).toBe("completed");
63
+ expect(normalizeStatus("active")).toBe("in_progress");
64
+ expect(normalizeStatus(null)).toBe("pending");
65
+ expect(normalizeStatus("unknown-state")).toBe("pending");
66
+ });
67
+ });
68
+
69
+ describe("task-sync subtaskKey", () => {
70
+ it("prefers a stable mark over content when present", () => {
71
+ expect(subtaskKey({ mark: "m1", content: "ignored", title: "ignored" })).toBe("m1");
72
+ });
73
+ it("falls back to content when no mark", () => {
74
+ expect(subtaskKey({ content: "Build it", title: "Build it" })).toBe("Build it");
75
+ });
76
+ });
77
+
78
+ describe("task-sync planReconcile", () => {
79
+ it("creates every incoming item when there are no existing subtasks", () => {
80
+ const plan = planReconcile([], [item("A"), item("B", "in_progress")]);
81
+ expect(plan.toCreate).toHaveLength(2);
82
+ expect(plan.toCreate[0]).toMatchObject({ content: "A", status: "todo", position: "0", completed: false });
83
+ expect(plan.toCreate[1]).toMatchObject({ content: "B", status: "in-progress", position: "1", completed: false });
84
+ expect(plan.toUpdate).toHaveLength(0);
85
+ expect(plan.toDelete).toHaveLength(0);
86
+ });
87
+
88
+ it("emits no ops when existing already matches incoming (idempotent)", () => {
89
+ const existing = [
90
+ subtask({ id: "1", title: "A", status: "todo", position: "0", completed: false }),
91
+ ];
92
+ const plan = planReconcile(existing, [item("A")]);
93
+ expect(plan.toCreate).toHaveLength(0);
94
+ expect(plan.toUpdate).toHaveLength(0);
95
+ expect(plan.toDelete).toHaveLength(0);
96
+ });
97
+
98
+ it("updates status + completed when a matched item changed", () => {
99
+ const existing = [subtask({ id: "1", title: "A", status: "todo", position: "0", completed: false })];
100
+ const plan = planReconcile(existing, [item("A", "completed")]);
101
+ expect(plan.toUpdate).toEqual([
102
+ expect.objectContaining({ id: "1", status: "done", completed: true, position: "0" }),
103
+ ]);
104
+ expect(plan.toCreate).toHaveLength(0);
105
+ expect(plan.toDelete).toHaveLength(0);
106
+ });
107
+
108
+ it("updates position on reorder (same content, different index)", () => {
109
+ const existing = [
110
+ subtask({ id: "1", title: "A", status: "todo", position: "0" }),
111
+ subtask({ id: "2", title: "B", status: "todo", position: "1" }),
112
+ ];
113
+ const plan = planReconcile(existing, [item("B"), item("A")]);
114
+ expect(plan.toUpdate.map((u) => [u.id, u.position])).toEqual([
115
+ ["2", "0"],
116
+ ["1", "1"],
117
+ ]);
118
+ });
119
+
120
+ it("deletes existing subtasks absent from the incoming list", () => {
121
+ const existing = [
122
+ subtask({ id: "1", title: "A", position: "0" }),
123
+ subtask({ id: "2", title: "B", position: "1" }),
124
+ ];
125
+ const plan = planReconcile(existing, [item("A")]);
126
+ expect(plan.toDelete).toEqual(["2"]);
127
+ expect(plan.toUpdate).toHaveLength(0);
128
+ });
129
+
130
+ it("matches by stable mark across a content rename, updating content", () => {
131
+ const existing = [
132
+ subtask({ id: "1", title: "Old text", status: "todo", position: "0", kind_data: { task_sync: { mark: "m1" } } }),
133
+ ];
134
+ const plan = planReconcile(existing, [item("New text", "pending", "m1")]);
135
+ expect(plan.toUpdate).toEqual([
136
+ expect.objectContaining({ id: "1", content: "New text" }),
137
+ ]);
138
+ expect(plan.toCreate).toHaveLength(0);
139
+ expect(plan.toDelete).toHaveLength(0);
140
+ });
141
+
142
+ it("ignores blank incoming content", () => {
143
+ const plan = planReconcile([], [item(" "), item("Real")]);
144
+ expect(plan.toCreate).toHaveLength(1);
145
+ expect(plan.toCreate[0].content).toBe("Real");
146
+ });
147
+
148
+ it("carries source into kind_data.task_sync for traceability", () => {
149
+ const plan = planReconcile([], [item("A")], "claude-code");
150
+ expect(plan.toCreate[0].kind_data).toEqual({ task_sync: { mark: undefined, source: "claude-code" } });
151
+ });
152
+ });
153
+
154
+ // --- resolveTaskUpdateTarget (added 2026-07-28) ----------------------------
155
+ // The gap these close: the update path matched ONLY on kind_data.task_sync.mark,
156
+ // so a subtask whose mark was never stamped — or a TaskCreate that silently
157
+ // produced no row at all — was unaddressable forever. Every later update for it
158
+ // was dropped as `no-matching-subtask`: 26 of 30 tasksync discards in six hours,
159
+ // across seven distinct lane sessions, all one reason. One lane had marks "1"
160
+ // and "3" stamped correctly while "2" was missing entirely, so a whole work item
161
+ // left no trace in the fleet's record.
162
+ describe("task-sync resolveTaskUpdateTarget", () => {
163
+ const marked = (id: string, title: string, mark?: string) => ({
164
+ id,
165
+ title,
166
+ kind_data: mark ? { task_sync: { mark } } : {},
167
+ });
168
+
169
+ it("matches by mark when stamped", () => {
170
+ const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "1" });
171
+ expect(r).toEqual({ op: "update", id: "a" });
172
+ });
173
+
174
+ it("falls back to exact title when the mark was never stamped", () => {
175
+ // The unmarked-row case: mark lookup misses, title still identifies it.
176
+ const r = resolveTaskUpdateTarget([marked("a", "Alpha")], {
177
+ taskId: "1",
178
+ subject: "Alpha",
179
+ });
180
+ expect(r).toEqual({ op: "update", id: "a" });
181
+ });
182
+
183
+ it("does not cross-match a different task's mark", () => {
184
+ const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "2" });
185
+ expect(r).toEqual({ op: "discard" });
186
+ });
187
+
188
+ it("heals a missed create when the event carries a subject", () => {
189
+ // The mark-"2"-never-written case: nothing to match, but the subject is
190
+ // enough to rebuild the row instead of orphaning the task forever.
191
+ const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], {
192
+ taskId: "2",
193
+ subject: "Bravo",
194
+ });
195
+ expect(r).toEqual({ op: "heal", title: "Bravo" });
196
+ });
197
+
198
+ it("discards only when there is no subject to rebuild a title from", () => {
199
+ const r = resolveTaskUpdateTarget([marked("a", "Alpha", "1")], { taskId: "2" });
200
+ expect(r).toEqual({ op: "discard" });
201
+ });
202
+
203
+ it("prefers the mark over a same-subject title collision", () => {
204
+ const r = resolveTaskUpdateTarget(
205
+ [marked("a", "Same", "1"), marked("b", "Same", "2")],
206
+ { taskId: "2", subject: "Same" },
207
+ );
208
+ expect(r).toEqual({ op: "update", id: "b" });
209
+ });
210
+
211
+ it("ignores a blank subject rather than healing a titleless row", () => {
212
+ const r = resolveTaskUpdateTarget([], { taskId: "9", subject: " " });
213
+ expect(r).toEqual({ op: "discard" });
214
+ });
215
+ });
216
+
217
+ // --- rename must not orphan an unmarked row (added 2026-07-28) --------------
218
+ describe("a rename cannot orphan an unmarked subtask", () => {
219
+ // Matching is mark-first, then EXACT TITLE. A row that has never been stamped
220
+ // can therefore only ever be found by its title — so the moment a task is
221
+ // RENAMED, neither match succeeds, the event heals into a NEW row, and the
222
+ // pre-rename row is orphaned at whatever status it held. Nothing in the
223
+ // single-event path will ever address it again.
224
+ //
225
+ // Observed on this lane 2026-07-28: three subtasks stranded at `todo` and
226
+ // `in-progress` under their OLD titles while the CLI's own list read empty,
227
+ // which is what made the stop gate refuse a lane that had genuinely finished.
228
+ // `task-projection-converges` requires the projection to express every state a
229
+ // task reaches; a row it can no longer address expresses nothing.
230
+
231
+ it("an unmarked row is still reachable by exact title", () => {
232
+ // The pre-condition for the whole failure: without a mark, TITLE is the
233
+ // only handle, so it works right up until the title changes.
234
+ const existing = [{ id: "row-1", title: "Original title", kind_data: null }];
235
+ expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Original title" })).toEqual({
236
+ op: "update",
237
+ id: "row-1",
238
+ });
239
+ });
240
+
241
+ it("renaming an UNMARKED row heals into a new one — the orphan", () => {
242
+ // This is the defect, asserted so the cost of NOT stamping is explicit: the
243
+ // update does not reach row-1, which stays at its old status forever.
244
+ const existing = [{ id: "row-1", title: "Original title", kind_data: null }];
245
+ expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Renamed title" })).toEqual({
246
+ op: "heal",
247
+ title: "Renamed title",
248
+ });
249
+ });
250
+
251
+ it("once marked, a rename updates the SAME row", () => {
252
+ // The fix: the first title-match stamps the mark, so every later rename is
253
+ // mark-addressable and lands on the original row. The heal path already
254
+ // stamped on create; the update path was the missing half.
255
+ const existing = [
256
+ { id: "row-1", title: "Original title", kind_data: { task_sync: { mark: "7" } } },
257
+ ];
258
+ expect(resolveTaskUpdateTarget(existing, { taskId: "7", subject: "Renamed title" })).toEqual({
259
+ op: "update",
260
+ id: "row-1",
261
+ });
262
+ });
263
+
264
+ it("the update path stamps the mark when the row has none", async () => {
265
+ // Asserted against the source because the write is inside an ORPC handler
266
+ // that needs a live prisma context. The regression is the absence of a
267
+ // stamp, which no pure test on resolveTaskUpdateTarget can observe.
268
+ const src = await Bun.file(
269
+ new URL("./task-sync-router.ts", import.meta.url).pathname,
270
+ ).text();
271
+ expect(src).toContain("const alreadyMarked");
272
+ expect(src).toContain("if (taskId && !alreadyMarked)");
273
+ // Prior kind_data must be preserved — the lane binding and any other
274
+ // sibling keys live there, and clobbering them would trade this bug for a
275
+ // worse one.
276
+ expect(src).toContain("...priorKindData");
277
+ expect(src).toContain("...priorSync");
278
+ });
279
+ });