@teamscala/service-boot 0.23.349 → 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.
- package/package.json +479 -567
- package/src/agreement-gate.ts +1 -1
- package/src/auth-gate.ts +0 -11
- package/src/fn-routers/index.ts +1 -12
- package/src/fn-routers/public-agreement-router.ts +37 -49
- package/src/mount-orpc-router.test.ts +46 -0
- package/src/mount-orpc-router.ts +16 -3
- package/src/mutable-app-ref.ts +0 -20
- package/src/page-server-orpc/puck-pages-router.ts +0 -102
- package/src/register-page-server-client.ts +0 -4
- package/src/register-ws-routes.ts +10 -3
- package/src/two-phase-boot.ts +2 -1
- package/src/ui-service.ts +11 -18
- package/src/fn-routers/ai-health-router.ts +0 -64
- package/src/fn-routers/panel-data-router.ts +0 -93
- package/src/fn-routers/plans-router.ts +0 -141
- package/src/fn-routers/task-sync-assign.test.ts +0 -483
- package/src/fn-routers/task-sync-assign.ts +0 -485
- package/src/fn-routers/task-sync-bind.test.ts +0 -110
- package/src/fn-routers/task-sync-brief.test.ts +0 -120
- package/src/fn-routers/task-sync-brief.ts +0 -151
- package/src/fn-routers/task-sync-classify.test.ts +0 -109
- package/src/fn-routers/task-sync-close.test.ts +0 -44
- package/src/fn-routers/task-sync-close.ts +0 -82
- package/src/fn-routers/task-sync-created-status.test.ts +0 -58
- package/src/fn-routers/task-sync-dispatch.test.ts +0 -62
- package/src/fn-routers/task-sync-hook-lifecycle.test.ts +0 -204
- package/src/fn-routers/task-sync-lane.test.ts +0 -36
- package/src/fn-routers/task-sync-projection.test.ts +0 -172
- package/src/fn-routers/task-sync-projection.ts +0 -273
- package/src/fn-routers/task-sync-rename.test.ts +0 -53
- package/src/fn-routers/task-sync-rename.ts +0 -111
- package/src/fn-routers/tasks-router.ts +0 -77
- package/src/page-server-orpc/portal-diagnostics-router.ts +0 -170
- package/src/page-server-orpc/templates-router.ts +0 -116
- package/src/serve/serve-ws.test.ts +0 -135
- package/src/serve/serve.ts +0 -148
- package/src/serve/stamp-transport-identity.test.ts +0 -24
- 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
|
-
});
|