@teamscala/service-boot 0.23.350 → 0.23.352

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 (39) hide show
  1. package/package.json +479 -567
  2. package/src/agreement-gate.ts +1 -1
  3. package/src/auth-gate.ts +0 -11
  4. package/src/fn-routers/index.ts +1 -12
  5. package/src/fn-routers/public-agreement-router.ts +37 -49
  6. package/src/mount-orpc-router.test.ts +46 -0
  7. package/src/mount-orpc-router.ts +16 -3
  8. package/src/mutable-app-ref.ts +0 -20
  9. package/src/page-server-orpc/puck-pages-router.ts +0 -102
  10. package/src/register-page-server-client.ts +0 -4
  11. package/src/register-ws-routes.ts +10 -3
  12. package/src/two-phase-boot.ts +2 -1
  13. package/src/ui-service.ts +11 -18
  14. package/src/fn-routers/ai-health-router.ts +0 -64
  15. package/src/fn-routers/panel-data-router.ts +0 -93
  16. package/src/fn-routers/plans-router.ts +0 -141
  17. package/src/fn-routers/task-sync-assign.test.ts +0 -483
  18. package/src/fn-routers/task-sync-assign.ts +0 -485
  19. package/src/fn-routers/task-sync-bind.test.ts +0 -110
  20. package/src/fn-routers/task-sync-brief.test.ts +0 -120
  21. package/src/fn-routers/task-sync-brief.ts +0 -151
  22. package/src/fn-routers/task-sync-classify.test.ts +0 -109
  23. package/src/fn-routers/task-sync-close.test.ts +0 -44
  24. package/src/fn-routers/task-sync-close.ts +0 -82
  25. package/src/fn-routers/task-sync-created-status.test.ts +0 -58
  26. package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
  27. package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
  28. package/src/fn-routers/task-sync-lane.test.ts +0 -36
  29. package/src/fn-routers/task-sync-projection.test.ts +0 -172
  30. package/src/fn-routers/task-sync-projection.ts +0 -273
  31. package/src/fn-routers/task-sync-rename.test.ts +0 -53
  32. package/src/fn-routers/task-sync-rename.ts +0 -111
  33. package/src/fn-routers/tasks-router.ts +0 -77
  34. package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
  35. package/src/page-server-orpc/templates-router.ts +0 -116
  36. package/src/serve/serve-ws.test.ts +0 -135
  37. package/src/serve/serve.ts +0 -148
  38. package/src/serve/stamp-transport-identity.test.ts +0 -24
  39. package/src/serve/stamp-transport-identity.ts +0 -109
@@ -1,485 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @indivisible-unit
5
- * @edit edit directly
6
- *
7
- * Lane DISPATCH by assignment — the WRITE half of dispatch-by-assignment. The
8
- * operator's contract (verbatim intent): you create a task (the NAME), you write
9
- * the first COMMENT (which IS the prompt/brief), and THEN you assign it to an AI
10
- * agent — and the assignment is the TRIGGER that spawns the lane. Assignment
11
- * last, always, because by the time the agent exists every piece of information
12
- * it needs is already in the row.
13
- *
14
- * This file owns the two pure halves of that flow, split out of task-sync-router
15
- * (which sits at its 1000-line commit gate, so any new task-sync unit lands in a
16
- * sibling — the same reason task-sync-brief/lane/dispatch exist):
17
- * createTaskWithBriefComment — create the task AND its first comment (the
18
- * description IS the first comment IS the brief) in one transaction. An
19
- * incomplete task is UNCONSTRUCTIBLE: the ORPC `.input()` schema
20
- * (`assignTaskInput`) requires name + description TOGETHER, so oRPC rejects
21
- * the call before the handler runs (`prevention-over-detection` — require at
22
- * the signature, never validate after the fact).
23
- * buildAssignmentSpawnPayload — shape the spawn emission that the assignment
24
- * emits. It carries the task id and NO initialPrompt, because the spawned
25
- * lane PULLS its brief from the DB by its own session id
26
- * (`task-sync-brief.ts` readAssignedBrief → latest comment). That deletes the
27
- * keystroke-brief failure class (a ~4400-char initialPrompt fails outright;
28
- * a ~2600-char one arrives intact and is never submitted, then the pane is
29
- * reaped for producing no turn): when the brief is a row, no keystroke can
30
- * lose it.
31
- *
32
- * THE EMISSION is a row write followed by an emission that triggers the spawn
33
- * (`actions-are-rows-then-emissions`): the assignment writes the task + comment,
34
- * then emits; the host-command bus consumer reacts by spawning. State lives in
35
- * the row, never in a return value a caller may never receive. The exact bus
36
- * surface this file emits through is INJECTED (AssignmentEmitter) rather than
37
- * hard-coded, so it composes with whichever surface the host-command-bus cutover
38
- * lands — one mechanism, not two (`no-silent-reverts` on the spawn path).
39
- *
40
- * BINDING (the gap this flow exposes). A lane resolves its task by its CLI-native
41
- * session id (`kind_data.lane.session`, minted by the CLI at boot — never by us).
42
- * Today `ensureLaneTask`/`bindLane` CREATE a fresh parent task at SessionStart.
43
- * But assignment PRE-CREATES the task (with the brief as its first comment, no
44
- * session yet), so SessionStart must ADOPT THAT EXISTING TASK BY ID — writing the
45
- * freshly-minted session onto its `kind_data.lane.session` — instead of creating
46
- * a new one, or the brief-task is orphaned and the lane self-briefs to nothing.
47
- * The task is created `status:"active"` with no session, so `resolveLaneTask`
48
- * (which filters `kind_data.lane.session`) cannot see it until bind writes the
49
- * session — exactly right: the lane resolves it only after it is bound.
50
- *
51
- * CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
52
- * candidate. Bound to the work_items + comments schema and to task-sync's own
53
- * kind_data.lane.session convention; no unrelated app could install this
54
- * unmodified.
55
- */
56
-
57
- import { getPlatformOrgId } from "@teamscala/db/registry/platform";
58
- import * as v from "valibot";
59
- import type { Prisma, WorkItem } from "./task-sync-router.ts";
60
- import { authed, type Context } from "./auth-router.ts";
61
- import { getAppLogger } from "@teamscala/logger/app-loggers";
62
-
63
- /**
64
- * The assignment input. `name` + `description` are REQUIRED TOGETHER at the
65
- * signature: oRPC parses this schema before the handler runs, so a call missing
66
- * either is rejected with a 400 — an incomplete task is unconstructible, not
67
- * caught after the fact. `description` becomes the task's first comment (the
68
- * brief); it is never stored separately on the task, so "the brief" has exactly
69
- * one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
70
- *
71
- * `name` → work_items.title; `description` → the first comment. `parent_id` is
72
- * the project the dispatched task is scoped under (omit for an exploratory
73
- * lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
74
- * platform org (fleet/internal lane tasks live there).
75
- */
76
- /**
77
- * A lane task's NAME — the lane's real work label, never the generic "Lane"
78
- * sentinel. A row titled "Lane" is spawn debris: it tells the operator nothing
79
- * about the work, so an orchestrator cannot tell real unowned work from it (the
80
- * signal the fleet steers by). Rejected at the ORPC input chokepoint so a spawn
81
- * that forgot to name its lane is REFUSED at the mint, never written
82
- * (lanes-named-by-the-spawner — the spawner sets the work name at creation).
83
- */
84
- const laneWorkName = v.pipe(
85
- v.string(),
86
- v.minLength(1, "name is required — the lane's real work label"),
87
- v.check(
88
- (n) => n.trim().toLowerCase() !== "lane",
89
- "name must carry the lane's real work name, not the generic sentinel 'Lane'",
90
- ),
91
- );
92
-
93
- export const assignTaskInput = v.object({
94
- name: laneWorkName,
95
- description: v.pipe(
96
- v.string(),
97
- v.minLength(1, "description is required — it is the brief (first comment)"),
98
- ),
99
- parent_id: v.optional(v.string()),
100
- organisation_id: v.optional(v.string()),
101
- slug: v.optional(v.string()),
102
- windowName: v.optional(v.string()),
103
- idempotencyKey: v.optional(v.string()),
104
- });
105
-
106
- /** The resolved (post-schema) shape the handlers operate on. */
107
- export type AssignTaskInput = v.InferOutput<typeof assignTaskInput>;
108
-
109
- /**
110
- * bindLane ORPC input. Declaring the schema is the documented remedy for the
111
- * schema-less-body gap (`upsertFromTaskListInput` cites the same measured bug):
112
- * oRPC does NOT parse a JSON body into `input` for a procedure with no
113
- * `.input()`, so a SessionStart adapter that POSTs the work item id as a DIRECT
114
- * body field (`{lane:{...}, parent_id}`) arrives as an EMPTY input — `parent_id`
115
- * is silently dropped, `adoptAssignmentTask` gets null, no adoption happens, and
116
- * `readAssignedBrief` returns nulls for a session that has nothing bound to it.
117
- * That is a DATA gap (nothing bound to the session), never a serving one — which
118
- * is exactly the misdiagnosis this closes: a prior lane read those nulls as
119
- * "fn.taskSync is 404" and reached for serving changes, when the row was simply
120
- * never bound because the body never parsed.
121
- *
122
- * ROOT-OPTIONAL (`v.optional(v.object(...))`) so it tolerates a caller that sends
123
- * a DIRECT body or none at all: oRPC extracts `input` from the `{json:{...}}` RPC
124
- * envelope, so a raw-object or empty POST yields `undefined`, which the optional
125
- * root ACCEPTS — the handler then falls back to its headers, exactly as it did
126
- * schema-less (a header-only caller like zerostack is unchanged). An adapter that
127
- * wants the body path (pi's `parent_id`) POSTs the `{json:{...}}` envelope and
128
- * `parent_id` is parsed + reaches `adoptAssignmentTask` — the same envelope
129
- * convention pi ALREADY uses for upsertFromTaskList. A raw-object POST is NOT
130
- * parsed (oRPC hands the handler `undefined`), which is why pi's bindLaneTask
131
- * must move to the envelope to deliver `parent_id`. Every field is optional with
132
- * a header fallback, so the schema ADDS the body path without removing the header.
133
- */
134
- export const bindLaneInput = v.optional(
135
- v.object({
136
- lane: v.optional(
137
- v.object({
138
- pane: v.optional(v.string()),
139
- session: v.optional(v.string()),
140
- cli_kind: v.optional(v.string()),
141
- role: v.optional(v.string()),
142
- organisation_id: v.optional(v.string()),
143
- }),
144
- ),
145
- title: v.optional(v.string()),
146
- parent_id: v.optional(v.string()),
147
- }),
148
- );
149
-
150
- /**
151
- * Resolve the org a dispatched task is created under. Pure: the platform org is
152
- * INJECTED (resolved by the caller at the handler boundary, where a registry
153
- * read is appropriate) rather than pulled from the registry inside this module,
154
- * so the create logic is unit-testable with no DB (`configured-primitives`:
155
- * config arrives from the caller, never pulled from DB/Env inside a primitive).
156
- */
157
- export function resolveAssignOrg(explicit?: string, platformOrg?: string): string {
158
- return explicit ?? platformOrg ?? getPlatformOrgId();
159
- }
160
-
161
- /**
162
- * Create a dispatched task AND its first comment (the description = the brief) in
163
- * one transaction. The task is `kind:"task"`, `status:"active"`, with NO
164
- * `kind_data.lane.session` yet — the session is minted by the CLI at spawn and
165
- * written onto the row by SessionStart's adopt-existing bind. Until then the
166
- * task is invisible to `resolveLaneTask` (which filters on the session), which is
167
- * correct: the lane may only resolve its task once it is bound to it.
168
- *
169
- * Pure over an injected prisma (no module-level I/O) so the atomic create +
170
- * first-comment decision is unit-testable with a fake transaction. `userId`
171
- * stamps `created_by`/`updated_by` on the task and `user_id` on the comment.
172
- */
173
- export async function createTaskWithBriefComment(
174
- prisma: Prisma,
175
- input: { name: string; description: string; parent_id?: string; organisation_id: string },
176
- userId: string,
177
- ): Promise<WorkItem> {
178
- const organisation_id = input.organisation_id;
179
- return (prisma as unknown as {
180
- $transaction: (fn: (tx: unknown) => Promise<WorkItem>) => Promise<WorkItem>;
181
- }).$transaction(async (tx) => {
182
- const t = tx as {
183
- work_items: { create: (a: unknown) => Promise<WorkItem> };
184
- comments: { create: (a: unknown) => Promise<{ id: string }> };
185
- };
186
- const task = await t.work_items.create({
187
- data: {
188
- kind: "task",
189
- parent_id: input.parent_id ?? null,
190
- organisation_id,
191
- title: input.name,
192
- status: "active",
193
- // No kind_data.lane.session here: the CLI mints the session at spawn
194
- // and SessionStart binds it. Carrying an empty lane marker would let
195
- // resolveLaneTask race a half-bound row, so the key is absent until set.
196
- // assignee_type:"agent" marks the task as OWNED BY A LANE from creation,
197
- // so the compliance gate can tell a dispatched+supervised task (the
198
- // orchestrator does NOT owe it) from the caller's own implementation work
199
- // — even before SessionStart writes the session that finalises ownership.
200
- kind_data: { assignee_type: "agent" },
201
- created_by: userId,
202
- updated_by: userId,
203
- },
204
- });
205
- // The description IS the first comment IS the brief — one write, one source.
206
- const firstComment = await t.comments.create({
207
- data: {
208
- work_item_id: task.id,
209
- content: input.description,
210
- user_id: userId,
211
- },
212
- });
213
- return { ...task, first_comment_id: firstComment.id } as WorkItem & { first_comment_id: string };
214
- });
215
- }
216
-
217
- /**
218
- * ADOPT a pre-created assignment task at SessionStart: write the lane's
219
- * freshly-minted session onto an EXISTING task row instead of creating a new
220
- * one. `bindLane` calls this when the spawn carried a `workItemId` that resolves
221
- * to a `kind:"task"` row (the assignment task whose first comment IS the brief).
222
- * Without this, bindLane would CREATE a new task and orphan the brief-task — the
223
- * lane would `readAssignedBrief` to nothing.
224
- *
225
- * Realizes OWNERSHIP too: sets `assignee_id` to the lane's session (its stable
226
- * identity, `lane-identity-is-cli-session-id`), so the compliance gate can
227
- * distinguish a task the CALLER owes from one a dispatched lane owns (the
228
- * orchestrator supervises, not implements — `anthropic-models-orchestrate-not-
229
- * implement`). "Is the caller the assignee" becomes a simple equality on the
230
- * session.
231
- *
232
- * RE-BINDS a task another session already owns. Migration 109
233
- * (`work_items_one_active_task_per_lane`) makes `(kind_data->'lane'->>'pane')`
234
- * unique WHERE active, so when a lane is re-spawned on a pane whose prior task is
235
- * still active under a STALE session (the lane died, the row survived),
236
- * `resolveLaneTask(newSession)` finds nothing and `create` P2002s — measured
237
- * fleet-wide 2026-08-01: every real lane's bindLane 500'd on this constraint.
238
- * Refusing to steal (the original design) routes every re-spawn into that
239
- * dead-end. An assignment task belongs to whichever lane was dispatched to it,
240
- * and a stale session is by definition dead, so re-binding to the fresh session
241
- * is the correct recovery: the pane's unique slot moves to the live lane and
242
- * the brief follows. Returns null only when `maybeTaskId` is absent or not a
243
- * task — caller falls through to create for an exploratory lane (no workItemId).
244
- */
245
- export async function adoptAssignmentTask(
246
- prisma: Prisma,
247
- maybeTaskId: string | null | undefined,
248
- lane: { session: string; pane: string | null; cli_kind?: string; role?: string },
249
- ): Promise<WorkItem | null> {
250
- if (!maybeTaskId) return null;
251
- const row = await prisma.work_items.findFirst({ where: { id: maybeTaskId, kind: "task" } });
252
- if (!row) return null;
253
- // Defense-in-depth: the findFirst filters kind:"task", but verify the kind on
254
- // the read row too rather than trusting only the DB filter — a project parent
255
- // (which a spawn also carries as workItemId) must never be adopted, only
256
- // created-under. Verified, not assumed (`a-component-may-not-report-a-state...`).
257
- if (row.kind !== "task") return null;
258
- const laneData = {
259
- pane: lane.pane,
260
- session: lane.session,
261
- cli_kind: lane.cli_kind ?? "claude-code",
262
- role: lane.role ?? "agent",
263
- };
264
- const mergedKindData = {
265
- ...(row.kind_data ?? {}),
266
- lane: laneData,
267
- assignee_type: "agent",
268
- };
269
- // RELEASE any OTHER active task holding this pane's unique lane slot before
270
- // writing — migration 109's unique index would otherwise P2002 on the update
271
- // when a stale task (prior session, same pane) is still active. Same logic
272
- // as the run-link STEAL-before-CLAIM: a pane's lane slot is singly-owned, so
273
- // every other row holding it is this lane's own stale prior (or a duplicate the
274
- // race created) and is released to null-pane so this update can land. The
275
- // assignment task itself is then re-bound to the fresh session.
276
- try {
277
- if (lane.pane) {
278
- const others = (
279
- prisma as unknown as {
280
- work_items?: {
281
- updateMany: (a: unknown) => Promise<{ count: number }>;
282
- };
283
- }
284
- ).work_items;
285
- await others?.updateMany({
286
- where: {
287
- kind: "task",
288
- status: "active",
289
- id: { not: row.id },
290
- kind_data: { path: ["lane", "pane"], equals: lane.pane },
291
- },
292
- data: { kind_data: { lane: { pane: null } } },
293
- });
294
- }
295
- } catch {
296
- // Best-effort: a release failure does not block the adopt — the update below
297
- // will throw its own P2002 and the handler falls through, converging on the
298
- // next bind. Logged by the caller's run-link region, not here.
299
- }
300
- try {
301
- return await prisma.work_items.update({
302
- where: { id: row.id },
303
- data: { kind_data: mergedKindData, assignee_id: lane.session },
304
- });
305
- } catch {
306
- return null;
307
- }
308
- }
309
-
310
- /**
311
- * The bind decision AFTER attempting to adopt the spawn's referenced task. PURE
312
- * over the adopt result + whether the referenced row exists, so the
313
- * "a spawn that cannot bind fails loudly rather than orphaning" invariant
314
- * (`absent-input-reads-as-success`) is unit-testable without standing up the
315
- * ORPC handler. `bindLane` calls `adoptAssignmentTask` then this, then acts.
316
- *
317
- * `use` — adopt succeeded; bind the lane to that task.
318
- * `create` — no task to adopt: an exploratory lane (no workItemId) or the
319
- * referenced id is a PROJECT the task is created under.
320
- * `refuse` — the spawn named a workItemId the lane should adopt, but NO task
321
- * exists with that id. Silently creating a NEW task here would
322
- * abandon the spawn's referenced brief and bind the lane to an
323
- * empty task it was never meant to own — exactly the orphan
324
- * `absent-input-reads-as-success` forbids (a caller that stated
325
- * adopt-intent reads "success" while the referenced row rots).
326
- * Failing loudly surfaces the malformed spawn instead.
327
- */
328
- export type BindAction =
329
- | { action: "use"; task: WorkItem }
330
- | { action: "create"; scope: "exploratory" | "project-parent" }
331
- | { action: "refuse"; reason: "missing-referenced-task"; workItemId: string };
332
-
333
- export function resolveBindAction(args: {
334
- adopted: WorkItem | null;
335
- workItemId: string | null;
336
- /** The kind of the row at workItemId, or null when no such row exists. */
337
- referencedKind: string | null;
338
- }): BindAction {
339
- if (args.adopted) return { action: "use", task: args.adopted };
340
- if (!args.workItemId) return { action: "create", scope: "exploratory" };
341
- // A workItemId was provided but adopt returned null: either it resolves to a
342
- // non-task (a PROJECT — create a task under it) or to nothing (refuse).
343
- if (args.referencedKind === null) {
344
- return { action: "refuse", reason: "missing-referenced-task", workItemId: args.workItemId };
345
- }
346
- return { action: "create", scope: "project-parent" };
347
- }
348
-
349
- /**
350
- * Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
351
- * CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
352
- * lane knows its session id up front (`resumeId`). So the assignment binds the
353
- * task to `resumeId` directly and writes a NEW comment as the continuation brief,
354
- * before emitting the spawn with `resumeId`. `readAssignedBrief(resumeId)` then
355
- * returns that continuation — so `DEFAULT_CONTINUATION_PROMPT`'s job (which rode
356
- * the now-retired keystroke path) moves to a row write at assign time. Also sets
357
- * `assignee_id` (ownership) for the same reason as the fresh-spawn adopt.
358
- */
359
- export async function bindTaskToKnownSession(
360
- prisma: Prisma,
361
- taskId: string,
362
- lane: { session: string; pane: string | null; cli_kind?: string; role?: string },
363
- ): Promise<WorkItem | null> {
364
- return adoptAssignmentTask(prisma, taskId, lane);
365
- }
366
-
367
- /**
368
- * The spawn payload an assignment emits. Carries the task id (as `workItemId`,
369
- * which the picker exports into the pane so SessionStart can ADOPT the existing
370
- * task) and NEVER an `initialPrompt` — the lane pulls its brief from the DB by
371
- * its own session id. This is the contract whatever host-command surface lands:
372
- * command `spawn_agent_tab`, payload `{ workItemId, slug?, windowName?,
373
- * idempotencyKey? }`.
374
- *
375
- * Pure (no I/O) so the "no keystroke brief, taskId-only" invariant is
376
- * unit-testable and regression-locked: a test asserting `initialPrompt` is
377
- * absent fails the moment someone re-adds keystroke delivery.
378
- */
379
- export function buildAssignmentSpawnPayload(
380
- task: Pick<WorkItem, "id">,
381
- options: { slug?: string; windowName?: string; idempotencyKey?: string },
382
- ): {
383
- command: "spawn_agent_tab";
384
- payload: { workItemId: string; slug?: string; windowName?: string; idempotencyKey?: string };
385
- } {
386
- return {
387
- command: "spawn_agent_tab",
388
- payload: {
389
- workItemId: task.id,
390
- ...(options.slug ? { slug: options.slug } : {}),
391
- ...(options.windowName ? { windowName: options.windowName } : {}),
392
- ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
393
- },
394
- };
395
- }
396
-
397
- /**
398
- * The emission surface an assignment writes the spawn intent to. Injected (not
399
- * imported) so this file composes with whichever bus surface the host-command-bus
400
- * cutover settles on, rather than hard-coding a second mechanism. Implementations
401
- * write a durable row the consumer reacts to (`actions-are-rows-then-emissions`):
402
- * fire-and-forget from the caller's perspective — the caller states intent and
403
- * proceeds; the consumer spawns the lane and the lane self-briefs.
404
- */
405
- export interface AssignmentEmitter {
406
- /** Write the spawn intent as a durable row the host-command consumer reacts to. */
407
- emitSpawn(
408
- payload: ReturnType<typeof buildAssignmentSpawnPayload>,
409
- ): Promise<void>;
410
- }
411
-
412
- /**
413
- * The outcome of assigning a task: the created/confirmed task and confirmation
414
- * that the spawn intent was emitted (not that the lane spawned — that is the
415
- * consumer's reaction, observed separately). The caller proceeds on the row.
416
- */
417
- export interface AssignmentResult {
418
- task: WorkItem;
419
- emitted: boolean;
420
- }
421
-
422
- /**
423
- * createBriefTask ORPC input. The MINT entry point a spawner calls to turn a
424
- * raw brief into the task row a lane self-briefs from. `name` is the lane's work
425
- * label (the spawner's windowName); `description` is the brief, stored as the
426
- * task's FIRST COMMENT — never on the task row itself, so "the brief" has
427
- * exactly one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
428
- * Mirrors `assignTaskInput` minus the spawn-shaping fields (slug/windowName/
429
- * idempotencyKey), which belong on the spawn emission, not the mint.
430
- */
431
- export const createBriefTaskInput = v.object({
432
- name: laneWorkName,
433
- description: v.pipe(
434
- v.string(),
435
- v.minLength(1, "description is required — it is the brief (first comment)"),
436
- ),
437
- parent_id: v.optional(v.string()),
438
- organisation_id: v.optional(v.string()),
439
- });
440
-
441
- /**
442
- * createBriefTask ORPC handler. `spawn_agent_tab` calls this when it receives
443
- * an `initialPrompt` with no `workItemId`: the prompt becomes the task's first
444
- * comment, the returned `work_item_id` rides the spawn payload as `workItemId`,
445
- * and the lane PULLS the brief at boot (`readAssignedBrief`) — no keystroke, no
446
- * channel send, no positional argument. That deletes the keystroke-brief
447
- * failure class by construction (`buildAssignmentSpawnPayload` already carries
448
- * `workItemId` and never `initialPrompt`).
449
- *
450
- * Wraps the pure `createTaskWithBriefComment` (atomic task + first comment). The
451
- * task is `kind:"task"`, `status:"active"`, `assignee_type:"agent"`, with NO
452
- * `kind_data.lane.session`: the CLI mints the session at spawn and SessionStart
453
- * ADOPTS this task by id (`adoptAssignmentTask`), which binds the brief to the
454
- * lane. Returns only the `work_item_id` — the single fact the spawner needs to
455
- * carry the brief without writing it into the spawn payload. Same transport as
456
- * `readAssignedBrief`: page-gate-exempt (fn.taskSync), localhost super-admin.
457
- */
458
- export const createBriefTaskHandler = authed.handler(async ({ input, context }) => {
459
- const ctx = context as Context;
460
- const prisma = ctx.prisma as Prisma;
461
- const data = input as v.InferOutput<typeof createBriefTaskInput>;
462
- try {
463
- const userId = (ctx.user as { id: string }).id;
464
- const task = await createTaskWithBriefComment(
465
- prisma,
466
- {
467
- name: data.name,
468
- description: data.description,
469
- parent_id: data.parent_id,
470
- organisation_id: resolveAssignOrg(data.organisation_id),
471
- },
472
- userId,
473
- );
474
- return { work_item_id: task.id, comment_id: (task as WorkItem & { first_comment_id: string }).first_comment_id };
475
- } catch (err) {
476
- // oRPC's default swallows a handler throw into a generic 500 with no stack;
477
- // emit it so a mint failure is diagnosable, not a black box
478
- // (a-component-may-not-report-a-state-it-has-not-verified).
479
- getAppLogger().error("[task-sync] createBriefTask failed", {
480
- error: err instanceof Error ? err.message : String(err),
481
- stack: err instanceof Error ? err.stack : undefined,
482
- });
483
- throw err;
484
- }
485
- });
@@ -1,110 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Unit tests for the SessionStart auto-bind helpers added to close the
7
- * task-sync -> work_items gap: lanes kept native task lists but nothing reached
8
- * work_items because fn.taskSync.bindLane was never auto-called, so every
9
- * TaskCreate hit the `no-task-bound` discard. resolveBindOrg + discardApplied
10
- * are the two new testable decisions; getPlatformOrgId + recordEvent are mocked.
11
- */
12
- import { describe, expect, mock, test } from "bun:test";
13
-
14
- const recorded: Array<{ kind: string; payload: Record<string, unknown> }> = [];
15
- mock.module("@teamscala/event-log/record-event", () => ({
16
- recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
17
- recorded.push(e);
18
- },
19
- }));
20
- mock.module("@teamscala/db/registry/platform", () => ({
21
- getPlatformOrgId: () => "platform-org-test",
22
- }));
23
-
24
- const { resolveBindOrg, discardApplied } = await import(
25
- "./task-sync-router.ts"
26
- );
27
-
28
- describe("resolveBindOrg (SessionStart auto-bind default org)", () => {
29
- test("defaults to the platform org when the lane provides none", () => {
30
- // An adopted or channel-dispatched lane has no client org; its task seeds
31
- // under the platform org (where the operator's fleet PM view reads).
32
- expect(resolveBindOrg(undefined)).toBe("platform-org-test");
33
- });
34
- test("uses the lane's explicit org when provided (dispatched fleet lane)", () => {
35
- expect(resolveBindOrg("client-org-123")).toBe("client-org-123");
36
- });
37
- });
38
-
39
- describe("discardApplied (observable no-task-bound discards)", () => {
40
- test("records the discard to event_log AND returns the soft {applied:false} body", () => {
41
- recorded.length = 0;
42
- const r = discardApplied("no-task-bound", "tmux:%22", { toolName: "TaskCreate" });
43
- expect(r).toEqual({ applied: false, reason: "no-task-bound", toolName: "TaskCreate" });
44
- expect(recorded).toHaveLength(1);
45
- expect(recorded[0]).toEqual({
46
- kind: "tasksync.hook.discarded",
47
- payload: { reason: "no-task-bound", pane: "tmux:%22", toolName: "TaskCreate" },
48
- });
49
- });
50
- test("no-pane discard carries pane:null", () => {
51
- recorded.length = 0;
52
- discardApplied("no-pane", null);
53
- expect(recorded[0]?.payload).toEqual({ reason: "no-pane", pane: null });
54
- });
55
- test("every reason is distinguishable in event_log by payload.reason", () => {
56
- recorded.length = 0;
57
- discardApplied("no-subject", "tmux:%5");
58
- discardApplied("no-taskId", "tmux:%5", { toolName: "TaskUpdate" });
59
- expect(recorded.map((e) => e.payload.reason)).toEqual(["no-subject", "no-taskId"]);
60
- });
61
- });
62
-
63
- describe("lane task binding is deterministic and race-proof (source contract)", () => {
64
- // Two sources since the router hit its 1000-line budget: lane RESOLUTION
65
- // moved to task-sync-lane.ts, the bindLane HANDLER stayed on the router. A
66
- // source-text contract has to point at wherever the code actually lives, so
67
- // these read separately rather than sharing one handle.
68
- const laneSrc = Bun.file(new URL("./task-sync-lane.ts", import.meta.url).pathname);
69
- const src = Bun.file(new URL("./task-sync-router.ts", import.meta.url).pathname);
70
-
71
- test("resolveLaneTask orders, so duplicates cannot flip the answer", async () => {
72
- const text = await laneSrc.text();
73
- // An unordered findFirst returns whichever row Postgres hands back. With
74
- // two parents for one lane, one turn wrote the lane's todos under parent A
75
- // and the next read parent B and found nothing — the list appeared to
76
- // empty itself and the watchdog accused lanes holding a populated list.
77
- expect(text).toContain('orderBy: { created_at: "asc" }');
78
- expect(text).toContain('status: "active"');
79
- });
80
-
81
- test("bindLane recovers from losing the create race instead of throwing", async () => {
82
- const text = await src.text();
83
- // Migration 109 makes the second concurrent create fail. Losing is not an
84
- // error: the winner's row is exactly what the caller wanted.
85
- expect(text).toContain("lost the create race");
86
- expect(text).toContain("const winner = await resolveLaneTask(prisma, session)");
87
- });
88
-
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"]');
101
- });
102
-
103
- test("readTaskList returns the checklist with evidence-derived progress", async () => {
104
- const text = await src.text();
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
- });
110
- });