@teamscala/service-boot 0.23.284 → 0.23.285

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 (121) hide show
  1. package/package.json +1006 -1014
  2. package/src/agreement-gate.d.ts +19 -0
  3. package/src/asset-cache-key.d.ts +11 -0
  4. package/src/asset-optimize.d.ts +17 -0
  5. package/src/asset-proxy.d.ts +11 -0
  6. package/src/asset-social-card.d.ts +17 -0
  7. package/src/attach-agreement-magic.d.ts +20 -0
  8. package/src/attach-portal-members.d.ts +23 -0
  9. package/src/attach-proposal-accept.d.ts +26 -0
  10. package/src/attach-proposal-magic.d.ts +18 -0
  11. package/src/attach-transfers-upload.d.ts +27 -0
  12. package/src/auth-gate.d.ts +28 -0
  13. package/src/bind-page-handler.d.ts +18 -0
  14. package/src/build-client-assets/input-hashing.d.ts +25 -0
  15. package/src/build-client-assets/source-candidates.d.ts +27 -0
  16. package/src/build-client-assets.d.ts +50 -0
  17. package/src/compose-page-title.d.ts +41 -0
  18. package/src/content-gate/access.d.ts +15 -0
  19. package/src/content-gate/config-types.d.ts +99 -0
  20. package/src/content-gate/gate.d.ts +13 -0
  21. package/src/content-gate/parse-cookie.d.ts +9 -0
  22. package/src/content-gate/token.d.ts +48 -0
  23. package/src/content-page-floor.d.ts +39 -0
  24. package/src/discover-blocks.d.ts +28 -0
  25. package/src/expand-roster.d.ts +34 -0
  26. package/src/fn-routers/agreement-completion-email.d.ts +14 -0
  27. package/src/fn-routers/agreement-send-router.d.ts +82 -0
  28. package/src/fn-routers/ai-health-router.d.ts +35 -0
  29. package/src/fn-routers/auth-router.d.ts +47 -0
  30. package/src/fn-routers/cache-admin-router.d.ts +57 -0
  31. package/src/fn-routers/chat-session-router.d.ts +219 -0
  32. package/src/fn-routers/email-defaults.d.ts +1 -0
  33. package/src/fn-routers/email-link-ttl.d.ts +1 -0
  34. package/src/fn-routers/index.d.ts +18 -0
  35. package/src/fn-routers/panel-data-router.d.ts +19 -0
  36. package/src/fn-routers/persist-chat-messages.d.ts +36 -0
  37. package/src/fn-routers/plans-router.d.ts +25 -0
  38. package/src/fn-routers/proposal-send-router.d.ts +58 -0
  39. package/src/fn-routers/public-agreement-router.d.ts +281 -0
  40. package/src/fn-routers/public-assessment-router.d.ts +68 -0
  41. package/src/fn-routers/public-gpt-session-router.d.ts +125 -0
  42. package/src/fn-routers/sign-in-flow-router.d.ts +75 -0
  43. package/src/fn-routers/task-sync-dispatch.d.ts +61 -0
  44. package/src/fn-routers/task-sync-router.d.ts +376 -0
  45. package/src/fn-routers/task-sync-router.test.ts +32 -0
  46. package/src/fn-routers/task-sync-router.ts +27 -1
  47. package/src/fn-routers/tasks-router.d.ts +25 -0
  48. package/src/fn-routers/user-preferences-router.d.ts +77 -0
  49. package/src/fn-routers/work-items-router.d.ts +102 -0
  50. package/src/gate-ttls.d.ts +6 -0
  51. package/src/gpt-gate.d.ts +25 -0
  52. package/src/handlers/bun-stable-watch.d.ts +1 -0
  53. package/src/handlers/projects-digest.d.ts +26 -0
  54. package/src/handlers/prune-stale-data.d.ts +30 -0
  55. package/src/handlers/registry-stale-inactive-sweep.d.ts +1 -0
  56. package/src/handlers/scala-tools-currency-watch.d.ts +1 -0
  57. package/src/handlers/scheduled-browser-tests.d.ts +1 -0
  58. package/src/handlers/sweep-event-log.d.ts +1 -0
  59. package/src/handlers/sweep-host-cache.d.ts +1 -0
  60. package/src/handlers/upstream-issue-sync.d.ts +1 -0
  61. package/src/handlers/whatsapp-liveness-logic.d.ts +34 -0
  62. package/src/handlers/whatsapp-liveness.d.ts +4 -0
  63. package/src/hono-error-handler.d.ts +25 -0
  64. package/src/install-harness.d.ts +45 -0
  65. package/src/install-logging.d.ts +34 -0
  66. package/src/load-service-install-row.d.ts +24 -0
  67. package/src/load-service-prisma-client.d.ts +18 -0
  68. package/src/mount-orpc-router.d.ts +15 -0
  69. package/src/mount-server-packages.d.ts +29 -0
  70. package/src/mount.d.ts +9 -0
  71. package/src/mutable-app-ref.d.ts +38 -0
  72. package/src/page-route-shell-types.d.ts +12 -0
  73. package/src/page-server-orpc/audit-revert-router.d.ts +37 -0
  74. package/src/page-server-orpc/hub-mcp-client.d.ts +12 -0
  75. package/src/page-server-orpc/portal-diagnostics-router.d.ts +49 -0
  76. package/src/page-server-orpc/puck-pages-router.d.ts +58 -0
  77. package/src/page-server-orpc/server-client.d.ts +16 -0
  78. package/src/page-server-orpc/templates-router.d.ts +34 -0
  79. package/src/page-server-orpc/user-preferences-router.d.ts +79 -0
  80. package/src/pg-boss-handler-registry.d.ts +25 -0
  81. package/src/pg-boss-mount.d.ts +39 -0
  82. package/src/proposal-authorized-emails.d.ts +28 -0
  83. package/src/proposal-chat-inject.d.ts +17 -0
  84. package/src/proposal-gate.d.ts +18 -0
  85. package/src/read-html-template.d.ts +38 -0
  86. package/src/read-viewport-from-request.d.ts +18 -0
  87. package/src/readiness-gate.d.ts +16 -0
  88. package/src/register-api-routes.d.ts +18 -0
  89. package/src/register-crash-handlers.d.ts +15 -0
  90. package/src/register-page-routes.d.ts +27 -0
  91. package/src/register-page-server-client.d.ts +11 -0
  92. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +13 -0
  93. package/src/register-registry-page-route/graph-cache.d.ts +30 -0
  94. package/src/register-registry-page-route/meta-title.d.ts +2 -0
  95. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +14 -0
  96. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +28 -0
  97. package/src/register-registry-page-route/resolve-page-data.d.ts +1 -0
  98. package/src/register-registry-page-route.d.ts +27 -0
  99. package/src/register-static-assets.d.ts +13 -0
  100. package/src/register-ws-routes.d.ts +28 -0
  101. package/src/resolve-app-base-url.d.ts +13 -0
  102. package/src/resolve-brand-favicon.d.ts +28 -0
  103. package/src/resolve-from-loader-config.d.ts +32 -0
  104. package/src/resolve-service-context.d.ts +17 -0
  105. package/src/resolve-theme-mode.d.ts +42 -0
  106. package/src/resolvers/fetch-org-brand.d.ts +2 -0
  107. package/src/resolvers/portal-route-paths.d.ts +39 -0
  108. package/src/resolvers/website-by-host-map.d.ts +12 -0
  109. package/src/resource-chat-inject.d.ts +46 -0
  110. package/src/resource-not-found.d.ts +60 -0
  111. package/src/run-boot-hook-list.d.ts +19 -0
  112. package/src/run-boot-hooks.d.ts +31 -0
  113. package/src/scala-tools-currency-watchdog.d.ts +10 -0
  114. package/src/security-headers.d.ts +12 -0
  115. package/src/serve/serve.d.ts +46 -0
  116. package/src/server-error-page.d.ts +29 -0
  117. package/src/start-scheduled-jobs.d.ts +18 -0
  118. package/src/two-phase-boot.d.ts +93 -0
  119. package/src/types.d.ts +46 -0
  120. package/src/ui-service.d.ts +46 -0
  121. package/src/ws-boot-self-test.d.ts +19 -0
@@ -0,0 +1,376 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * CLI-AGNOSTIC task-sync CORE — the bridge between a lane's native task list
7
+ * (Claude Code TodoWrite, OpenCode todowrite, future CLIs) and the `work_items`
8
+ * table, which is the single source of truth for "what work does this lane
9
+ * have." Detailed contract + the per-CLI adapter split: apps/agent-fleet.md
10
+ * (task-sync section).
11
+ *
12
+ * Design (operator requirement): work_items is the source; each CLI's task list
13
+ * is a WRITE-THROUGH PROJECTION, never a peer. One source + write-through
14
+ * projection cannot diverge by construction — no two-way sync, no conflict
15
+ * resolution. The lane↔task binding is SYMMETRIC: a lane is a lane whether the
16
+ * pane is an orchestrator or a supervised agent (both have tasks).
17
+ *
18
+ * Mapping: an operator request = a kind='task' work_item; the lane's todo items
19
+ * = kind='subtask' rows under it via parent_id. The task carries the lane
20
+ * identity in kind_data.lane = { pane, session, cli_kind, role } so any surface
21
+ * (fleet UI, another lane) can resolve "who owns this work" and push changes
22
+ * back. Binding is by pane identity (the stable $TMUX_PANE), not by fleet run —
23
+ * so it works before/without the fleet_agent_runs.work_item_id join (that column
24
+ * is a UI convenience for the unified lane+task+subtask view, not a dependency).
25
+ *
26
+ * These procedures are the ONLY CLI-agnostic surface an adapter calls:
27
+ * bindLane — bind (create-or-confirm) a lane's task. SessionStart.
28
+ * upsertFromTaskList — write a lane's current task list through to subtasks (AGENT→DB leg, OpenCode whole-list).
29
+ * upsertFromTaskHook — apply a per-item Claude Code PostToolUse:TaskCreate/Update/Delete (AGENT→DB leg, Claude per-item).
30
+ * readTaskList — read a lane's current subtasks as a normalized list (DB→AGENT backstop).
31
+ * applyExternalChange — record an out-of-band subtask change + notify the owning lane (DB→AGENT realtime leg).
32
+ * A CLI with no native task surface (Codex today — upstream_issue openai-codex-2966)
33
+ * has NO adapter: it degrades to work_items-only. The work is still tracked and
34
+ * visible in the projects UI; the lane simply has no live projection. That is a
35
+ * legitimate end state, not a gap to paper over with a fake adapter.
36
+ */
37
+ import { type Context } from "./auth-router.ts";
38
+ import * as v from "valibot";
39
+ /** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
40
+ export interface TaskListItem {
41
+ content: string;
42
+ status: "pending" | "in_progress" | "completed";
43
+ /** CLI-native stable id when the CLI provides one (TodoWrite has none; opencode todo row id). */
44
+ mark?: string;
45
+ }
46
+ /**
47
+ * Input contract for upsertFromTaskList. oRPC does NOT parse the JSON body into
48
+ * `input` for a schema-less procedure (see the bindLane comment in this file) —
49
+ * so without an explicit `.input()` schema, `input.lane_session` is ALWAYS
50
+ * undefined and the procedure throws "requires lane_session" unconditionally.
51
+ * Measured 2026-07-29: every pi/opencode whole-list `task_list` publish 400'd
52
+ * for exactly this reason (the per-item upsertFromTaskHook path worked because
53
+ * it unwraps `raw.json ?? raw`). Declaring the schema is the documented remedy
54
+ * and the pattern every other `authed.input(...)` procedure in service-boot
55
+ * already follows. The pi adapter (scala-hooks.ts syncTaskList) already sends
56
+ * `{ lane_session, task_list, source }` — it was always valid; the server never
57
+ * read it.
58
+ */
59
+ export declare const upsertFromTaskListInput: v.ObjectSchema<{
60
+ readonly lane_session: v.StringSchema<undefined>;
61
+ readonly task_list: v.ArraySchema<v.ObjectSchema<{
62
+ readonly content: v.StringSchema<undefined>;
63
+ readonly status: v.PicklistSchema<["pending", "in_progress", "completed"], undefined>;
64
+ readonly mark: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
65
+ }, undefined>, undefined>;
66
+ readonly source: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
67
+ }, undefined>;
68
+ /** Who a lane IS — symmetric across orchestrator/agent roles and across CLIs. */
69
+ export interface LaneIdentity {
70
+ /** "tmux:%42" — authoritative pane identity ($TMUX_PANE). */
71
+ pane: string;
72
+ session?: string;
73
+ /** pane-inventory inferCliKind: claude-code | opencode | codex | unknown. */
74
+ cli_kind?: string;
75
+ /** agent | orchestrator — both have tasks; the model does not assume a supervised agent. */
76
+ role?: string;
77
+ /**
78
+ * Optional. work_items.organisation_id is NOT NULL; when the caller omits it
79
+ * (auto-bind at SessionStart — an adopted or channel-dispatched lane has no
80
+ * client org), bindLane defaults to the platform org via resolveBindOrg.
81
+ */
82
+ organisation_id?: string;
83
+ }
84
+ interface WorkItem {
85
+ id: string;
86
+ kind: string;
87
+ parent_id: string | null;
88
+ organisation_id: string;
89
+ title: string;
90
+ status: string | null;
91
+ position: string | null;
92
+ completed: boolean | null;
93
+ kind_data: Record<string, unknown>;
94
+ }
95
+ /**
96
+ * A dispatched run already points at its fleet_tasks row. Task-sync may fill
97
+ * the convenience join for an adopted lane, but must never replace that
98
+ * dispatch identity with the lane's work_items projection id.
99
+ */
100
+ export declare function untrackedRunForPane(pane: string): {
101
+ pane: string;
102
+ state: {
103
+ not: string;
104
+ };
105
+ tracked_task_id: null;
106
+ };
107
+ export declare const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string>;
108
+ export declare function normalizeStatus(s: string | null): TaskListItem["status"];
109
+ /**
110
+ * Resolve the org a lane's task is created under. Explicit when the lane knows
111
+ * its org (a dispatched fleet lane carries one); defaults to the platform org
112
+ * otherwise — an adopted or channel-dispatched pane has no client org, and the
113
+ * platform org is the home for fleet/internal lane tasks (where the operator's
114
+ * PM view reads), so auto-bind at SessionStart seeds the task there.
115
+ */
116
+ export declare function resolveBindOrg(explicit?: string): string;
117
+ /**
118
+ * Record a task-sync hook DISCARD to event_log so it is observable + queryable,
119
+ * not silently swallowed by the hook command's `>/dev/null 2>&1 || true`. The
120
+ * endpoint records it server-side regardless of what the hook does with stdout.
121
+ * Fire-and-forget per event-log-is-the-only-durable-event-log. The headline
122
+ * discard is "no-task-bound" (lane has no bound parent task — exactly the gap
123
+ * that SessionStart auto-bind closes; once binding is automatic this becomes a
124
+ * rare safety signal rather than the default outcome).
125
+ */
126
+ export declare function recordTaskSyncDiscard(reason: string, pane: string | null, extra?: Record<string, unknown>): void;
127
+ /**
128
+ * Record a discard AND return the soft {applied:false} body in one step — every
129
+ * no-op path in upsertFromTaskHook goes through here so none can silently drop
130
+ * an event. The returned shape matches the prior `{applied:false, reason,...}`.
131
+ */
132
+ export declare function discardApplied(reason: string, pane: string | null, extra?: Record<string, unknown>): {
133
+ applied: false;
134
+ reason: string;
135
+ } & Record<string, unknown>;
136
+ /**
137
+ * Trim a task subject at the first tool-call marker.
138
+ *
139
+ * A subject arriving as `Cut judge false positives</subject>\n<parameter
140
+ * name="description">Live gate blocks ~half...` is not a title with odd
141
+ * punctuation — it is the raw tool call, and it renders on the dashboard as a
142
+ * wall of XML where the operator expects a task name (observed live 2026-07-25,
143
+ * 3 rows). The malformation happens upstream in the CLI's hook payload, which
144
+ * this service does not control, so the defence belongs at the WRITE boundary:
145
+ * `work_items` is the single source of truth for every surface, and once a
146
+ * corrupted title lands there every reader shows it.
147
+ *
148
+ * Truncating rather than rejecting is deliberate: the text BEFORE the marker is
149
+ * the real subject and is worth keeping. Dropping the row instead would lose a
150
+ * task the lane genuinely created.
151
+ */
152
+ export declare function sanitizeTaskSubject(raw: string): string;
153
+ export declare function classifyTaskHookEvent(evt: {
154
+ hook_event_name?: unknown;
155
+ task_subject?: unknown;
156
+ }): "completed" | "created" | "tool";
157
+ export declare function subtaskKey(item: {
158
+ mark?: string;
159
+ content: string;
160
+ title: string;
161
+ }): string;
162
+ /**
163
+ * Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
164
+ * event address, and what should happen when none does. Extracted for the same
165
+ * reason planReconcile was — the handler should only EXECUTE, so the matching
166
+ * rule is unit-testable.
167
+ *
168
+ * Three resolutions:
169
+ * - `update` — matched by mark, or (mark absent/unstamped) by exact title.
170
+ * - `heal` — nothing matched but the event carries a subject, so the row can
171
+ * be reconstructed. Covers a TaskCreate that silently produced no
172
+ * subtask, which previously orphaned the task forever.
173
+ * - `discard` — nothing matched and there is no subject to rebuild a title from.
174
+ *
175
+ * Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
176
+ * "2" was never written at all, and every later update for it was dropped as
177
+ * `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
178
+ * distinct lane sessions, were that one reason.
179
+ */
180
+ export declare function resolveTaskUpdateTarget(existing: Array<{
181
+ id: string;
182
+ title: string;
183
+ kind_data?: Record<string, unknown> | null;
184
+ }>, evt: {
185
+ taskId: string;
186
+ subject?: string;
187
+ }): {
188
+ op: "update";
189
+ id: string;
190
+ } | {
191
+ op: "heal";
192
+ title: string;
193
+ } | {
194
+ op: "discard";
195
+ };
196
+ /**
197
+ * Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
198
+ * (current) task list, compute the create/update/delete ops that make the
199
+ * subtask set match the list — WITHOUT touching prisma. Extracted so the
200
+ * matching/status/positioning logic is unit-testable (the handler only executes
201
+ * the plan). Matching is by stable mark when the CLI provides one, else by
202
+ * content. Position follows incoming index, so a reorder is an update.
203
+ */
204
+ export interface ReconcilePlan {
205
+ toCreate: Array<{
206
+ content: string;
207
+ status: string;
208
+ completed: boolean;
209
+ position: string;
210
+ kind_data: Record<string, unknown>;
211
+ }>;
212
+ toUpdate: Array<{
213
+ id: string;
214
+ content: string;
215
+ status: string;
216
+ completed: boolean;
217
+ position: string;
218
+ kind_data: Record<string, unknown>;
219
+ }>;
220
+ toDelete: string[];
221
+ }
222
+ export declare function planReconcile(existing: WorkItem[], incoming: TaskListItem[], source?: string): ReconcilePlan;
223
+ export declare const taskSyncRouter: {
224
+ /**
225
+ * Bind (create-or-confirm) the task a lane owns. Called from a SessionStart
226
+ * adapter so the lane has a durable task row before its first todo write.
227
+ * Idempotent: re-binding updates the identity (role/cli_kind can change) but
228
+ * never orphans existing subtasks. After binding, links the lane's
229
+ * fleet_agent_runs row (matched by pane) to the task via tracked_task_id —
230
+ * the join the operator's lane-labels monitor + the unified fleet view read.
231
+ */
232
+ bindLane: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
233
+ headers: Headers;
234
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
235
+ prisma: any;
236
+ user: import("./auth-router.ts").User;
237
+ orgId: string | null | undefined;
238
+ }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
239
+ task_id: string;
240
+ created: boolean;
241
+ }, {
242
+ task_id: string;
243
+ created: boolean;
244
+ }>, Record<never, never>, Record<never, never>>;
245
+ /**
246
+ * AGENT→DB leg. Write a lane's CURRENT task list through to subtasks. The
247
+ * list is authoritative for this lane (it's what the CLI just rendered), so
248
+ * this is a full reconcile: create/update/delete to match. Matching is by
249
+ * stable mark when the CLI provides one, else by content. Does NOT dispatch
250
+ * a change notification — the lane that authored the list already has it.
251
+ */
252
+ upsertFromTaskList: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
253
+ headers: Headers;
254
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
255
+ prisma: any;
256
+ user: import("./auth-router.ts").User;
257
+ orgId: string | null | undefined;
258
+ }>, v.ObjectSchema<{
259
+ readonly lane_session: v.StringSchema<undefined>;
260
+ readonly task_list: v.ArraySchema<v.ObjectSchema<{
261
+ readonly content: v.StringSchema<undefined>;
262
+ readonly status: v.PicklistSchema<["pending", "in_progress", "completed"], undefined>;
263
+ readonly mark: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
264
+ }, undefined>, undefined>;
265
+ readonly source: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
266
+ }, undefined>, import("@orpc/contract").Schema<{
267
+ task_id: string;
268
+ created: number;
269
+ updated: number;
270
+ deleted: number;
271
+ total: number;
272
+ }, {
273
+ task_id: string;
274
+ created: number;
275
+ updated: number;
276
+ deleted: number;
277
+ total: number;
278
+ }>, Record<never, never>, Record<never, never>>;
279
+ /**
280
+ * Claude Code PostToolUse:TaskCreate/TaskUpdate hook entry. Claude Code's
281
+ * todo tool is the per-item Task* family (TaskCreate/TaskUpdate) — TodoWrite
282
+ * was REMOVED in recent Claude Code; the hook matcher keys on
283
+ * TaskCreate/TaskUpdate, never TodoWrite. The hook command posts the RAW hook
284
+ * event (stdin) + an x-lane-pane header (tmux:$TMUX_PANE). Fired as a
285
+ * PostToolUse:TaskCreate/Update hook (NOT a "TaskCreated" event — that is
286
+ * not a real Claude Code hook event + never fired); this procedure
287
+ * applies the per-item change (TaskCreate → new subtask keyed by the harness
288
+ * task id; TaskUpdate → status/title flip on the matching subtask). Server-
289
+ * side extraction keeps the hook command a trivial jq|curl. Accepts the event
290
+ * raw (input.tool_name) OR oRPC-enveloped (input.json.tool_name). Session-
291
+ * less: fn.taskSync is page-gate-exempt; localhost pane shell authenticates
292
+ * as super-admin via resolveUser. Soft-returns on any no-op (never throws).
293
+ */
294
+ upsertFromTaskHook: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
295
+ headers: Headers;
296
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
297
+ prisma: any;
298
+ user: import("./auth-router.ts").User;
299
+ orgId: string | null | undefined;
300
+ }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
301
+ applied: boolean;
302
+ op: string;
303
+ taskId: string;
304
+ } | ({
305
+ applied: false;
306
+ reason: string;
307
+ } & Record<string, unknown>), {
308
+ applied: boolean;
309
+ op: string;
310
+ taskId: string;
311
+ } | ({
312
+ applied: false;
313
+ reason: string;
314
+ } & Record<string, unknown>)>, Record<never, never>, Record<never, never>>;
315
+ /**
316
+ * DB→AGENT backstop read. Returns the lane's current subtasks in the
317
+ * normalized shape every CLI adapter maps to its native list. Called by the
318
+ * SessionStart/UserPromptSubmit adapters so a pane never opens or continues
319
+ * on stale tasks, and by a lane after it receives a "your tasks changed"
320
+ * channel notification.
321
+ */
322
+ readTaskList: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
323
+ headers: Headers;
324
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
325
+ prisma: any;
326
+ user: import("./auth-router.ts").User;
327
+ orgId: string | null | undefined;
328
+ }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
329
+ task_id: null;
330
+ task_title: null;
331
+ subtasks: never[];
332
+ } | {
333
+ task_id: string;
334
+ task_title: string;
335
+ subtasks: TaskListItem[];
336
+ }, {
337
+ task_id: null;
338
+ task_title: null;
339
+ subtasks: never[];
340
+ } | {
341
+ task_id: string;
342
+ task_title: string;
343
+ subtasks: TaskListItem[];
344
+ }>, Record<never, never>, Record<never, never>>;
345
+ /**
346
+ * DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
347
+ * change to a subtask's status (operator toggles it in the projects UI,
348
+ * another lane edits a shared task) and notify the OWNING lane's pane that
349
+ * its projection is stale. The pane's adapter re-reads (readTaskList) and
350
+ * updates its native list. Symmetric counterpart to upsertFromTaskList:
351
+ * there the lane is the originator (no self-echo); here the lane is the
352
+ * recipient.
353
+ *
354
+ * COMPLETION STEP (not MVP): this procedure is the explicit trigger. For
355
+ * FULL coverage — every work_items subtask write, including the projects
356
+ * UI's generated CRUD — a Postgres trigger + LISTEN subscriber on
357
+ * scala-channel fires the same publish, so an operator edit reaches the
358
+ * lane with no explicit call. Tracked in apps/agent-fleet.md task-sync.
359
+ */
360
+ applyExternalChange: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
361
+ headers: Headers;
362
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
363
+ prisma: any;
364
+ user: import("./auth-router.ts").User;
365
+ orgId: string | null | undefined;
366
+ }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
367
+ task_id: string;
368
+ lane_pane: string | null;
369
+ notified: boolean;
370
+ }, {
371
+ task_id: string;
372
+ lane_pane: string | null;
373
+ notified: boolean;
374
+ }>, Record<never, never>, Record<never, never>>;
375
+ };
376
+ export {};
@@ -16,7 +16,9 @@ import {
16
16
  subtaskKey,
17
17
  STATUS_TO_WORK_ITEM,
18
18
  type TaskListItem,
19
+ upsertFromTaskListInput,
19
20
  } from "./task-sync-router.ts";
21
+ import * as v from "valibot";
20
22
 
21
23
  interface WorkItemLike {
22
24
  id: string;
@@ -277,3 +279,33 @@ describe("a rename cannot orphan an unmarked subtask", () => {
277
279
  expect(src).toContain("...priorSync");
278
280
  });
279
281
  });
282
+
283
+ // --- upsertFromTaskList input schema (added 2026-07-29) ---------------------
284
+ // The gap this closes: upsertFromTaskList read `input.lane_session`, but oRPC
285
+ // leaves `input` empty for a schema-less procedure, so EVERY whole-list publish
286
+ // 400'd with "requires lane_session" — confirmed by curl (a body containing
287
+ // lane_session still 400'd). The pi/opencode adapter was always sending a valid
288
+ // payload; the server never parsed it. Adding `.input()` is the fix.
289
+ describe("task-sync upsertFromTaskListInput schema", () => {
290
+ it("accepts a valid whole-list payload (the pi/opencode adapter shape)", () => {
291
+ const out = v.parse(upsertFromTaskListInput, {
292
+ lane_session: "ses-1",
293
+ task_list: [{ content: "Do X", status: "in_progress" }],
294
+ source: "pi",
295
+ });
296
+ expect(out.lane_session).toBe("ses-1");
297
+ expect(out.task_list).toHaveLength(1);
298
+ });
299
+
300
+ it("accepts an optional mark on items", () => {
301
+ const out = v.parse(upsertFromTaskListInput, {
302
+ lane_session: "ses-1",
303
+ task_list: [{ content: "Do X", status: "pending", mark: "m1" }],
304
+ });
305
+ expect(out.task_list[0].mark).toBe("m1");
306
+ });
307
+
308
+ it("rejects a payload missing lane_session (the field the procedure requires)", () => {
309
+ expect(() => v.parse(upsertFromTaskListInput, { task_list: [] })).toThrow();
310
+ });
311
+ });
@@ -40,6 +40,7 @@ import { recordEvent } from "@teamscala/event-log/record-event";
40
40
  import { getPlatformOrgId } from "@teamscala/db/registry/platform";
41
41
  import { authed, type Context } from "./auth-router.ts";
42
42
  import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
43
+ import * as v from "valibot";
43
44
 
44
45
  /** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
45
46
  export interface TaskListItem {
@@ -49,6 +50,31 @@ export interface TaskListItem {
49
50
  mark?: string;
50
51
  }
51
52
 
53
+ /**
54
+ * Input contract for upsertFromTaskList. oRPC does NOT parse the JSON body into
55
+ * `input` for a schema-less procedure (see the bindLane comment in this file) —
56
+ * so without an explicit `.input()` schema, `input.lane_session` is ALWAYS
57
+ * undefined and the procedure throws "requires lane_session" unconditionally.
58
+ * Measured 2026-07-29: every pi/opencode whole-list `task_list` publish 400'd
59
+ * for exactly this reason (the per-item upsertFromTaskHook path worked because
60
+ * it unwraps `raw.json ?? raw`). Declaring the schema is the documented remedy
61
+ * and the pattern every other `authed.input(...)` procedure in service-boot
62
+ * already follows. The pi adapter (scala-hooks.ts syncTaskList) already sends
63
+ * `{ lane_session, task_list, source }` — it was always valid; the server never
64
+ * read it.
65
+ */
66
+ export const upsertFromTaskListInput = v.object({
67
+ lane_session: v.string(),
68
+ task_list: v.array(
69
+ v.object({
70
+ content: v.string(),
71
+ status: v.picklist(["pending", "in_progress", "completed"]),
72
+ mark: v.optional(v.string()),
73
+ }),
74
+ ),
75
+ source: v.optional(v.string()),
76
+ });
77
+
52
78
  /** Who a lane IS — symmetric across orchestrator/agent roles and across CLIs. */
53
79
  export interface LaneIdentity {
54
80
  /** "tmux:%42" — authoritative pane identity ($TMUX_PANE). */
@@ -665,7 +691,7 @@ export const taskSyncRouter = {
665
691
  * stable mark when the CLI provides one, else by content. Does NOT dispatch
666
692
  * a change notification — the lane that authored the list already has it.
667
693
  */
668
- upsertFromTaskList: authed.handler(async ({ input, context }) => {
694
+ upsertFromTaskList: authed.input(upsertFromTaskListInput).handler(async ({ input, context }) => {
669
695
  const body = input as { lane_session: string; task_list: TaskListItem[]; source?: string };
670
696
  const prisma = (context as Context).prisma as Prisma;
671
697
  const log = getAppLogger();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for task creation with initial comment.
7
+ * Extracted from @teamscala/orpc/fn-router — primitives tier must not
8
+ * contain application-specific work_items logic.
9
+ */
10
+ import { type Context } from "./auth-router.ts";
11
+ export declare const tasksRouter: {
12
+ createWithComment: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
13
+ headers: Headers;
14
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
15
+ prisma: any;
16
+ user: import("./auth-router.ts").User;
17
+ orgId: string | null | undefined;
18
+ }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
19
+ success: boolean;
20
+ data: any;
21
+ }, {
22
+ success: boolean;
23
+ data: any;
24
+ }>, Record<never, never>, Record<never, never>>;
25
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for a GENERIC per-user preference store backed by the
7
+ * `user_preferences` table. The user is resolved from the ORPC session context
8
+ * (`authed` middleware → `context.user.id`); the client supplies the org + key
9
+ * + value. This is the DB-durable, user-scoped key/value surface any feature
10
+ * can use — the AI browser-control focus (key `ai_ui_focus`) is the first
11
+ * consumer; it is NOT scala-projects-bespoke.
12
+ *
13
+ * Why this exists (over the generated `user_preferences` CRUD): the generated
14
+ * create/update do NOT resolve `user_id` server-side (only `organisation_id`
15
+ * from ctx.orgId), so a client cannot correctly write a per-user row through
16
+ * them. This proc stamps `user_id` from the authenticated caller, making the
17
+ * write safe + per-user. It also emits the `data_change` notify so the
18
+ * realtime surface (regions.watchDataChange) pushes updates to subscribed
19
+ * clients — the cross-region channel for the AI focus (the AiChat panel writes
20
+ * a focus here; the project-list-view in a different region subscribes +
21
+ * reacts, no shared React-context ancestor needed).
22
+ *
23
+ * Ownership is user_id: a caller reads/writes ONLY their own preferences. The
24
+ * org is part of the compound key (per-org isolation) but is client-supplied
25
+ * (the session carries the user, not the active org).
26
+ *
27
+ * DECISION — host-scoping of `user_preferences`: the table is NOT yet a shared
28
+ * `@teamscala/db` model (it lives in scala-projects's schema today), so this proc
29
+ * is genuinely host-scoped: it runs correctly on services whose Prisma client
30
+ * declares `user_preferences` (scala-projects is the first host) + is inert
31
+ * elsewhere. `Context.prisma` is `any`-typed, so the proc COMPILES workspace-
32
+ * wide (no type error on a model a host may not declare) — the `any` is the
33
+ * deliberate seam that lets one generic proc serve hosts incrementally as they
34
+ * adopt the table. Per-user preferences are platform-universal, so the tracked
35
+ * follow-on is to promote `user_preferences` to a shared `@teamscala/db` model
36
+ * (open-migration: `user_preferences→shared-model`, exit: every kind:ui host
37
+ * can mount this proc with no schema precondition) — at which point the proc is
38
+ * universally hostable + the `any` seam can tighten to the shared client. Until
39
+ * then the proc is opt-in per host (declare the table → get the capability).
40
+ */
41
+ import * as v from "valibot";
42
+ import { type Context } from "./auth-router.ts";
43
+ export declare const userPreferencesRouter: {
44
+ upsert: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
45
+ headers: Headers;
46
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
47
+ prisma: any;
48
+ user: import("./auth-router.ts").User;
49
+ orgId: string | null | undefined;
50
+ }>, v.ObjectSchema<{
51
+ readonly organisationId: v.StringSchema<undefined>;
52
+ /** The preference key (e.g. `ai_ui_focus`). Namespaced by feature. */
53
+ readonly key: v.StringSchema<undefined>;
54
+ /** The JSON value. Arbitrary per feature — typed by the caller. */
55
+ readonly value: v.AnySchema;
56
+ }, undefined>, import("@orpc/contract").Schema<{
57
+ success: boolean;
58
+ }, {
59
+ success: boolean;
60
+ }>, Record<never, never>, Record<never, never>>;
61
+ get: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
62
+ headers: Headers;
63
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
64
+ prisma: any;
65
+ user: import("./auth-router.ts").User;
66
+ orgId: string | null | undefined;
67
+ }>, v.ObjectSchema<{
68
+ readonly organisationId: v.StringSchema<undefined>;
69
+ readonly key: v.StringSchema<undefined>;
70
+ }, undefined>, import("@orpc/contract").Schema<{
71
+ value: any;
72
+ updatedAt: any;
73
+ }, {
74
+ value: any;
75
+ updatedAt: any;
76
+ }>, Record<never, never>, Record<never, never>>;
77
+ };