@teamscala/service-boot 0.23.313 → 0.23.314

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.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.313",
9
+ "version": "0.23.314",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -80,6 +80,39 @@ export declare const assignTaskInput: v.ObjectSchema<{
80
80
  }, undefined>;
81
81
  /** The resolved (post-schema) shape the handlers operate on. */
82
82
  export type AssignTaskInput = v.InferOutput<typeof assignTaskInput>;
83
+ /**
84
+ * bindLane ORPC input. Declaring the schema is the documented remedy for the
85
+ * schema-less-body gap (`upsertFromTaskListInput` cites the same measured bug):
86
+ * oRPC does NOT parse a JSON body into `input` for a procedure with no
87
+ * `.input()`, so a SessionStart adapter that POSTs the work item id as a DIRECT
88
+ * body field (`{lane:{...}, parent_id}`) arrives as an EMPTY input — `parent_id`
89
+ * is silently dropped, `adoptAssignmentTask` gets null, no adoption happens, and
90
+ * `readAssignedBrief` returns nulls for a session that has nothing bound to it.
91
+ * That is a DATA gap (nothing bound to the session), never a serving one — which
92
+ * is exactly the misdiagnosis this closes: a prior lane read those nulls as
93
+ * "fn.taskSync is 404" and reached for serving changes, when the row was simply
94
+ * never bound because the body never parsed.
95
+ *
96
+ * With the schema oRPC parses the body, so `parent_id` reaches the adopt path.
97
+ * Every field is OPTIONAL and the handler keeps a header fallback for each, so a
98
+ * caller that transports everything on headers (zerostack: x-lane-pane/session/
99
+ * work-item) parses to an empty object and behaves EXACTLY as before — the schema
100
+ * ADDS the body path without removing the header one. As a side effect it also
101
+ * fixes zerostack cli_kind mis-attribution: zerostack's body `{lane:{cli_kind:
102
+ * "zerostack"}}` was never parsed, so its cli_kind fell through every header to
103
+ * the "claude-code" default; parsed now, it reads "zerostack" as intended.
104
+ */
105
+ export declare const bindLaneInput: v.ObjectSchema<{
106
+ readonly lane: v.OptionalSchema<v.ObjectSchema<{
107
+ readonly pane: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
108
+ readonly session: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
109
+ readonly cli_kind: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
110
+ readonly role: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
111
+ readonly organisation_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
112
+ }, undefined>, undefined>;
113
+ readonly title: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
114
+ readonly parent_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
115
+ }, undefined>;
83
116
  /**
84
117
  * Resolve the org a dispatched task is created under. Pure: the platform org is
85
118
  * INJECTED (resolved by the caller at the handler boundary, where a registry
@@ -13,6 +13,7 @@ import { describe, it, expect } from "bun:test";
13
13
  import * as v from "valibot";
14
14
  import {
15
15
  assignTaskInput,
16
+ bindLaneInput,
16
17
  createBriefTaskInput,
17
18
  createTaskWithBriefComment,
18
19
  adoptAssignmentTask,
@@ -287,6 +288,44 @@ describe("resolveAssignOrg", () => {
287
288
  });
288
289
  });
289
290
 
291
+ describe("bindLaneInput (body parent_id reaches the adopt path — schema-less gap closed)", () => {
292
+ // The data-not-serving gap this schema exists to close: a SessionStart
293
+ // adapter POSTs the work item id as a DIRECT body field, but oRPC parses the
294
+ // body into `input` ONLY when a `.input()` schema is declared. Without one,
295
+ // parent_id arrived as an empty input, adopt got null, and readAssignedBrief
296
+ // returned nulls for a session with nothing bound — read by one lane as
297
+ // "fn.taskSync is 404". The schema makes the body parse; the header stays as
298
+ // the redundant transport a header-only caller already sends.
299
+ it("parses a direct body carrying parent_id (the assignment task id)", () => {
300
+ const out = v.safeParse(bindLaneInput, {
301
+ lane: { pane: "tmux:%5", session: "ses-1", cli_kind: "pi", role: "agent" },
302
+ parent_id: "task-99",
303
+ });
304
+ expect(out.success).toBe(true);
305
+ expect(out.success && out.output.parent_id).toBe("task-99");
306
+ });
307
+
308
+ it("parses an EMPTY body to all-undefined (header-only caller behaves as before)", () => {
309
+ // A caller transporting everything on headers sends {} (or omits the
310
+ // body); the schema must not reject it, and every field is undefined so the
311
+ // handler's header fallbacks carry the values.
312
+ const out = v.safeParse(bindLaneInput, {});
313
+ expect(out.success).toBe(true);
314
+ });
315
+
316
+ it("treats every field as optional (no caller is forced to populate the body)", () => {
317
+ expect(v.safeParse(bindLaneInput, { lane: { cli_kind: "zerostack" } }).success).toBe(true);
318
+ expect(v.safeParse(bindLaneInput, { parent_id: "t-1" }).success).toBe(true);
319
+ expect(v.safeParse(bindLaneInput, { title: "Lane" }).success).toBe(true);
320
+ });
321
+
322
+ it("does NOT require lane.session in the body (session may arrive on a header)", () => {
323
+ // bindLane resolves session from raw.lane?.session ?? x-lane-session, so the
324
+ // schema must not make the body the sole transport for it.
325
+ expect(v.safeParse(bindLaneInput, { parent_id: "t-1" }).success).toBe(true);
326
+ });
327
+ });
328
+
290
329
  describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-brief)", () => {
291
330
  const task = { id: "task-99" };
292
331
 
@@ -92,6 +92,42 @@ export const assignTaskInput = v.object({
92
92
  /** The resolved (post-schema) shape the handlers operate on. */
93
93
  export type AssignTaskInput = v.InferOutput<typeof assignTaskInput>;
94
94
 
95
+ /**
96
+ * bindLane ORPC input. Declaring the schema is the documented remedy for the
97
+ * schema-less-body gap (`upsertFromTaskListInput` cites the same measured bug):
98
+ * oRPC does NOT parse a JSON body into `input` for a procedure with no
99
+ * `.input()`, so a SessionStart adapter that POSTs the work item id as a DIRECT
100
+ * body field (`{lane:{...}, parent_id}`) arrives as an EMPTY input — `parent_id`
101
+ * is silently dropped, `adoptAssignmentTask` gets null, no adoption happens, and
102
+ * `readAssignedBrief` returns nulls for a session that has nothing bound to it.
103
+ * That is a DATA gap (nothing bound to the session), never a serving one — which
104
+ * is exactly the misdiagnosis this closes: a prior lane read those nulls as
105
+ * "fn.taskSync is 404" and reached for serving changes, when the row was simply
106
+ * never bound because the body never parsed.
107
+ *
108
+ * With the schema oRPC parses the body, so `parent_id` reaches the adopt path.
109
+ * Every field is OPTIONAL and the handler keeps a header fallback for each, so a
110
+ * caller that transports everything on headers (zerostack: x-lane-pane/session/
111
+ * work-item) parses to an empty object and behaves EXACTLY as before — the schema
112
+ * ADDS the body path without removing the header one. As a side effect it also
113
+ * fixes zerostack cli_kind mis-attribution: zerostack's body `{lane:{cli_kind:
114
+ * "zerostack"}}` was never parsed, so its cli_kind fell through every header to
115
+ * the "claude-code" default; parsed now, it reads "zerostack" as intended.
116
+ */
117
+ export const bindLaneInput = v.object({
118
+ lane: v.optional(
119
+ v.object({
120
+ pane: v.optional(v.string()),
121
+ session: v.optional(v.string()),
122
+ cli_kind: v.optional(v.string()),
123
+ role: v.optional(v.string()),
124
+ organisation_id: v.optional(v.string()),
125
+ }),
126
+ ),
127
+ title: v.optional(v.string()),
128
+ parent_id: v.optional(v.string()),
129
+ });
130
+
95
131
  /**
96
132
  * Resolve the org a dispatched task is created under. Pure: the platform org is
97
133
  * INJECTED (resolved by the caller at the handler boundary, where a registry
@@ -187,7 +187,17 @@ export declare const taskSyncRouter: {
187
187
  prisma: any;
188
188
  user: import("./auth-router.ts").User;
189
189
  orgId: string | null | undefined;
190
- }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
190
+ }>, v.ObjectSchema<{
191
+ readonly lane: v.OptionalSchema<v.ObjectSchema<{
192
+ readonly pane: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
193
+ readonly session: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
194
+ readonly cli_kind: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
195
+ readonly role: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
196
+ readonly organisation_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
197
+ }, undefined>, undefined>;
198
+ readonly title: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
199
+ readonly parent_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
200
+ }, undefined>, import("@orpc/contract").Schema<{
191
201
  task_id: string;
192
202
  created: boolean;
193
203
  }, {
@@ -43,7 +43,7 @@ import { authed, type Context } from "./auth-router.ts";
43
43
  import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
44
44
  import { resolveLaneTask, resolveLaneWorkName } from "./task-sync-lane.ts";
45
45
  import { readAssignedBriefHandler } from "./task-sync-brief.ts";
46
- import { adoptAssignmentTask, createBriefTaskHandler } from "./task-sync-assign.ts";
46
+ import { adoptAssignmentTask, bindLaneInput, createBriefTaskHandler } from "./task-sync-assign.ts";
47
47
  import * as v from "valibot";
48
48
  import {
49
49
  STATUS_TO_WORK_ITEM,
@@ -422,7 +422,7 @@ export const taskSyncRouter = {
422
422
  * fleet_agent_runs row (matched by pane) to the task via tracked_task_id —
423
423
  * the join the operator's lane-labels monitor + the unified fleet view read.
424
424
  */
425
- bindLane: authed.handler(async ({ input, context }) => {
425
+ bindLane: authed.input(bindLaneInput).handler(async ({ input, context }) => {
426
426
  const ctx = context as Context;
427
427
  const prisma = ctx.prisma as Prisma;
428
428
  const log = getAppLogger();
@@ -443,10 +443,17 @@ export const taskSyncRouter = {
443
443
  const pane = raw.lane?.pane ?? headerPane;
444
444
  const headerSession = ctx.headers?.get("x-lane-session") ?? null;
445
445
  const session = raw.lane?.session ?? headerSession;
446
- // parent_id via header: bindLane has no .input() schema so ORPC does not parse the body (headers are the reliable transport, same as pane/session) — body parent_id was always undefined, which is why adoption silently never happened.
446
+ // parent_id arrives EITHER as a body field (parsed now that bindLane has an
447
+ // `.input()` schema — bindLaneInput) OR on the x-lane-work-item header. Both
448
+ // are the assignment task id this lane ADOPTS (`adoptAssignmentTask`); the
449
+ // header stays as the redundant transport a header-only caller (zerostack)
450
+ // already sends. Before the schema, the body path silently never parsed, so a
451
+ // body-only caller (pi) dropped parent_id, never adopted, and self-briefed to
452
+ // nulls — the data-not-serving gap `bindLaneInput` exists to close.
447
453
  const headerWorkItem = ctx.headers?.get("x-lane-work-item") ?? null;
448
- // cli_kind via header for the same reason (body not parsed) — without it
449
- // every adopting lane is mis-attributed as claude-code.
454
+ // cli_kind via header is the fallback for callers that transport it there; a
455
+ // caller that puts cli_kind in the body lane object (parsed by bindLaneInput)
456
+ // is read from raw.lane.cli_kind first.
450
457
  const headerWorkKind = ctx.headers?.get("x-lane-cli-kind") ?? null;
451
458
  if (!session) {
452
459
  const { ORPCError } = await import("@orpc/server");