@teamscala/service-boot 0.23.305 → 0.23.306
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 +9 -1
- package/src/fn-routers/task-sync-assign.d.ts +196 -0
- package/src/fn-routers/task-sync-assign.test.ts +290 -0
- package/src/fn-routers/task-sync-assign.ts +290 -0
- package/src/fn-routers/task-sync-projection.d.ts +2 -1
- package/src/fn-routers/task-sync-projection.test.ts +23 -0
- package/src/fn-routers/task-sync-projection.ts +29 -8
- package/src/fn-routers/task-sync-router.ts +50 -28
- package/src/pg-boss-handler-registry.d.ts +2 -0
- package/src/pg-boss-mount.d.ts +2 -0
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.306",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"files": [
|
|
12
12
|
"src"
|
|
@@ -386,6 +386,14 @@
|
|
|
386
386
|
"types": "./src/fn-routers/sign-in-flow-router.d.ts",
|
|
387
387
|
"default": "./src/fn-routers/sign-in-flow-router.ts"
|
|
388
388
|
},
|
|
389
|
+
"./fn-routers/task-sync-assign": {
|
|
390
|
+
"types": "./src/fn-routers/task-sync-assign.d.ts",
|
|
391
|
+
"default": "./src/fn-routers/task-sync-assign.ts"
|
|
392
|
+
},
|
|
393
|
+
"./fn-routers/task-sync-assign.ts": {
|
|
394
|
+
"types": "./src/fn-routers/task-sync-assign.d.ts",
|
|
395
|
+
"default": "./src/fn-routers/task-sync-assign.ts"
|
|
396
|
+
},
|
|
389
397
|
"./fn-routers/task-sync-brief": {
|
|
390
398
|
"types": "./src/fn-routers/task-sync-brief.d.ts",
|
|
391
399
|
"default": "./src/fn-routers/task-sync-brief.ts"
|
|
@@ -0,0 +1,196 @@
|
|
|
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
|
+
import * as v from "valibot";
|
|
57
|
+
import type { Prisma, WorkItem } from "./task-sync-router.ts";
|
|
58
|
+
/**
|
|
59
|
+
* The assignment input. `name` + `description` are REQUIRED TOGETHER at the
|
|
60
|
+
* signature: oRPC parses this schema before the handler runs, so a call missing
|
|
61
|
+
* either is rejected with a 400 — an incomplete task is unconstructible, not
|
|
62
|
+
* caught after the fact. `description` becomes the task's first comment (the
|
|
63
|
+
* brief); it is never stored separately on the task, so "the brief" has exactly
|
|
64
|
+
* one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
|
|
65
|
+
*
|
|
66
|
+
* `name` → work_items.title; `description` → the first comment. `parent_id` is
|
|
67
|
+
* the project the dispatched task is scoped under (omit for an exploratory
|
|
68
|
+
* lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
|
|
69
|
+
* platform org (fleet/internal lane tasks live there).
|
|
70
|
+
*/
|
|
71
|
+
export declare const assignTaskInput: v.ObjectSchema<{
|
|
72
|
+
readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "name is required — an incomplete task is unconstructible">]>;
|
|
73
|
+
readonly description: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "description is required — it is the brief (first comment)">]>;
|
|
74
|
+
readonly parent_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
75
|
+
readonly organisation_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
76
|
+
readonly slug: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
77
|
+
readonly windowName: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
78
|
+
readonly idempotencyKey: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
79
|
+
}, undefined>;
|
|
80
|
+
/** The resolved (post-schema) shape the handlers operate on. */
|
|
81
|
+
export type AssignTaskInput = v.InferOutput<typeof assignTaskInput>;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the org a dispatched task is created under. Pure: the platform org is
|
|
84
|
+
* INJECTED (resolved by the caller at the handler boundary, where a registry
|
|
85
|
+
* read is appropriate) rather than pulled from the registry inside this module,
|
|
86
|
+
* so the create logic is unit-testable with no DB (`configured-primitives`:
|
|
87
|
+
* config arrives from the caller, never pulled from DB/Env inside a primitive).
|
|
88
|
+
*/
|
|
89
|
+
export declare function resolveAssignOrg(explicit?: string, platformOrg?: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* Create a dispatched task AND its first comment (the description = the brief) in
|
|
92
|
+
* one transaction. The task is `kind:"task"`, `status:"active"`, with NO
|
|
93
|
+
* `kind_data.lane.session` yet — the session is minted by the CLI at spawn and
|
|
94
|
+
* written onto the row by SessionStart's adopt-existing bind. Until then the
|
|
95
|
+
* task is invisible to `resolveLaneTask` (which filters on the session), which is
|
|
96
|
+
* correct: the lane may only resolve its task once it is bound to it.
|
|
97
|
+
*
|
|
98
|
+
* Pure over an injected prisma (no module-level I/O) so the atomic create +
|
|
99
|
+
* first-comment decision is unit-testable with a fake transaction. `userId`
|
|
100
|
+
* stamps `created_by`/`updated_by` on the task and `user_id` on the comment.
|
|
101
|
+
*/
|
|
102
|
+
export declare function createTaskWithBriefComment(prisma: Prisma, input: {
|
|
103
|
+
name: string;
|
|
104
|
+
description: string;
|
|
105
|
+
parent_id?: string;
|
|
106
|
+
organisation_id: string;
|
|
107
|
+
}, userId: string): Promise<WorkItem>;
|
|
108
|
+
/**
|
|
109
|
+
* ADOPT a pre-created assignment task at SessionStart: write the lane's
|
|
110
|
+
* freshly-minted session onto an EXISTING task row instead of creating a new
|
|
111
|
+
* one. `bindLane` calls this when the spawn carried a `workItemId` that resolves
|
|
112
|
+
* to a `kind:"task"` row (the assignment task whose first comment IS the brief).
|
|
113
|
+
* Without this, bindLane would CREATE a new task and orphan the brief-task — the
|
|
114
|
+
* lane would `readAssignedBrief` to nothing.
|
|
115
|
+
*
|
|
116
|
+
* Realizes OWNERSHIP too: sets `assignee_id` to the lane's session (its stable
|
|
117
|
+
* identity, `lane-identity-is-cli-session-id`), so the compliance gate can
|
|
118
|
+
* distinguish a task the CALLER owes from one a dispatched lane owns (the
|
|
119
|
+
* orchestrator supervises, not implements — `anthropic-models-orchestrate-not-
|
|
120
|
+
* implement`). "Is the caller the assignee" becomes a simple equality on the
|
|
121
|
+
* session.
|
|
122
|
+
*
|
|
123
|
+
* Refuses to STEAL a task another live session already owns — that is the
|
|
124
|
+
* existing-session re-bind branch's job (matched by `kind_data.lane.session`).
|
|
125
|
+
* Here we adopt only tasks with no session (or already this session). Returns
|
|
126
|
+
* null when `maybeTaskId` is absent, not a task, or already owned by another
|
|
127
|
+
* session — caller falls through to the normal create.
|
|
128
|
+
*/
|
|
129
|
+
export declare function adoptAssignmentTask(prisma: Prisma, maybeTaskId: string | null | undefined, lane: {
|
|
130
|
+
session: string;
|
|
131
|
+
pane: string | null;
|
|
132
|
+
cli_kind?: string;
|
|
133
|
+
role?: string;
|
|
134
|
+
}): Promise<WorkItem | null>;
|
|
135
|
+
/**
|
|
136
|
+
* Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
|
|
137
|
+
* CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
|
|
138
|
+
* lane knows its session id up front (`resumeId`). So the assignment binds the
|
|
139
|
+
* task to `resumeId` directly and writes a NEW comment as the continuation brief,
|
|
140
|
+
* before emitting the spawn with `resumeId`. `readAssignedBrief(resumeId)` then
|
|
141
|
+
* returns that continuation — so `DEFAULT_CONTINUATION_PROMPT`'s job (which rode
|
|
142
|
+
* the now-retired keystroke path) moves to a row write at assign time. Also sets
|
|
143
|
+
* `assignee_id` (ownership) for the same reason as the fresh-spawn adopt.
|
|
144
|
+
*/
|
|
145
|
+
export declare function bindTaskToKnownSession(prisma: Prisma, taskId: string, lane: {
|
|
146
|
+
session: string;
|
|
147
|
+
pane: string | null;
|
|
148
|
+
cli_kind?: string;
|
|
149
|
+
role?: string;
|
|
150
|
+
}): Promise<WorkItem | null>;
|
|
151
|
+
/**
|
|
152
|
+
* The spawn payload an assignment emits. Carries the task id (as `workItemId`,
|
|
153
|
+
* which the picker exports into the pane so SessionStart can ADOPT the existing
|
|
154
|
+
* task) and NEVER an `initialPrompt` — the lane pulls its brief from the DB by
|
|
155
|
+
* its own session id. This is the contract whatever host-command surface lands:
|
|
156
|
+
* command `spawn_agent_tab`, payload `{ workItemId, slug?, windowName?,
|
|
157
|
+
* idempotencyKey? }`.
|
|
158
|
+
*
|
|
159
|
+
* Pure (no I/O) so the "no keystroke brief, taskId-only" invariant is
|
|
160
|
+
* unit-testable and regression-locked: a test asserting `initialPrompt` is
|
|
161
|
+
* absent fails the moment someone re-adds keystroke delivery.
|
|
162
|
+
*/
|
|
163
|
+
export declare function buildAssignmentSpawnPayload(task: Pick<WorkItem, "id">, options: {
|
|
164
|
+
slug?: string;
|
|
165
|
+
windowName?: string;
|
|
166
|
+
idempotencyKey?: string;
|
|
167
|
+
}): {
|
|
168
|
+
command: "spawn_agent_tab";
|
|
169
|
+
payload: {
|
|
170
|
+
workItemId: string;
|
|
171
|
+
slug?: string;
|
|
172
|
+
windowName?: string;
|
|
173
|
+
idempotencyKey?: string;
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* The emission surface an assignment writes the spawn intent to. Injected (not
|
|
178
|
+
* imported) so this file composes with whichever bus surface the host-command-bus
|
|
179
|
+
* cutover settles on, rather than hard-coding a second mechanism. Implementations
|
|
180
|
+
* write a durable row the consumer reacts to (`actions-are-rows-then-emissions`):
|
|
181
|
+
* fire-and-forget from the caller's perspective — the caller states intent and
|
|
182
|
+
* proceeds; the consumer spawns the lane and the lane self-briefs.
|
|
183
|
+
*/
|
|
184
|
+
export interface AssignmentEmitter {
|
|
185
|
+
/** Write the spawn intent as a durable row the host-command consumer reacts to. */
|
|
186
|
+
emitSpawn(payload: ReturnType<typeof buildAssignmentSpawnPayload>): Promise<void>;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The outcome of assigning a task: the created/confirmed task and confirmation
|
|
190
|
+
* that the spawn intent was emitted (not that the lane spawned — that is the
|
|
191
|
+
* consumer's reaction, observed separately). The caller proceeds on the row.
|
|
192
|
+
*/
|
|
193
|
+
export interface AssignmentResult {
|
|
194
|
+
task: WorkItem;
|
|
195
|
+
emitted: boolean;
|
|
196
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the dispatch-by-assignment WRITE core (task-sync-assign.ts):
|
|
7
|
+
* the "incomplete task is unconstructible" signature invariant, the atomic
|
|
8
|
+
* create-task + first-comment (= brief) write, and the no-initialPrompt spawn
|
|
9
|
+
* payload (regression-locks the row-based self-brief model — fails the moment
|
|
10
|
+
* someone re-adds keystroke delivery). Covers `what-must-have-a-test`.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "bun:test";
|
|
13
|
+
import * as v from "valibot";
|
|
14
|
+
import {
|
|
15
|
+
assignTaskInput,
|
|
16
|
+
createTaskWithBriefComment,
|
|
17
|
+
adoptAssignmentTask,
|
|
18
|
+
bindTaskToKnownSession,
|
|
19
|
+
buildAssignmentSpawnPayload,
|
|
20
|
+
resolveAssignOrg,
|
|
21
|
+
} from "./task-sync-assign.ts";
|
|
22
|
+
|
|
23
|
+
/** A minimal fake prisma exposing only $transaction + the two creates the fn uses. */
|
|
24
|
+
function fakePrisma(overrides: { platformOrg?: string } = {}) {
|
|
25
|
+
const calls: {
|
|
26
|
+
workItem: Record<string, unknown> | null;
|
|
27
|
+
comment: Record<string, unknown> | null;
|
|
28
|
+
}[] = [];
|
|
29
|
+
let createdTask: Record<string, unknown> = {
|
|
30
|
+
id: "task-new-1",
|
|
31
|
+
kind: "task",
|
|
32
|
+
title: "unused",
|
|
33
|
+
status: "active",
|
|
34
|
+
kind_data: {},
|
|
35
|
+
};
|
|
36
|
+
const prisma = {
|
|
37
|
+
$transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
|
38
|
+
const tx = {
|
|
39
|
+
work_items: {
|
|
40
|
+
create: async (a: { data: Record<string, unknown> }) => {
|
|
41
|
+
createdTask = { ...createdTask, ...a.data, id: "task-new-1" };
|
|
42
|
+
return createdTask;
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
comments: {
|
|
46
|
+
create: async (a: { data: Record<string, unknown> }) => {
|
|
47
|
+
calls.push({ workItem: null, comment: a.data });
|
|
48
|
+
return a.data;
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const result = await fn(tx);
|
|
53
|
+
// Record the task create alongside the first comment for assertion.
|
|
54
|
+
calls[calls.length - 1] = { ...calls[calls.length - 1], workItem: { ...createdTask } };
|
|
55
|
+
return result;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return { prisma, calls, platformOrg: overrides.platformOrg ?? "org-platform" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("assignTaskInput (incomplete task is unconstructible)", () => {
|
|
62
|
+
it("accepts name + description together", () => {
|
|
63
|
+
const out = v.safeParse(assignTaskInput, { name: "FleetTruth", description: "Do X" });
|
|
64
|
+
expect(out.success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects a missing description — no brief, no task", () => {
|
|
68
|
+
// The description IS the brief (first comment); a task without it is exactly
|
|
69
|
+
// the incomplete state the operator said must be unconstructible.
|
|
70
|
+
const out = v.safeParse(assignTaskInput, { name: "FleetTruth" });
|
|
71
|
+
expect(out.success).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects a missing name", () => {
|
|
75
|
+
const out = v.safeParse(assignTaskInput, { description: "Do X" });
|
|
76
|
+
expect(out.success).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("rejects an empty-string description (not just absent)", () => {
|
|
80
|
+
const out = v.safeParse(assignTaskInput, { name: "FleetTruth", description: "" });
|
|
81
|
+
expect(out.success).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("treats parent_id/slug/windowName as optional", () => {
|
|
85
|
+
const out = v.safeParse(assignTaskInput, {
|
|
86
|
+
name: "Exploratory",
|
|
87
|
+
description: "Look around",
|
|
88
|
+
});
|
|
89
|
+
expect(out.success).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("createTaskWithBriefComment (atomic task + first comment = brief)", () => {
|
|
94
|
+
it("creates the task active with kind_data carrying NO session, and writes the description as the first comment in the SAME transaction", async () => {
|
|
95
|
+
const { prisma, calls } = fakePrisma();
|
|
96
|
+
const task = await createTaskWithBriefComment(
|
|
97
|
+
prisma as never,
|
|
98
|
+
{ name: "Dispatch test", description: "the brief text", organisation_id: "org-1" },
|
|
99
|
+
"user-1",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// The task is the active parent the lane will later bind its session to.
|
|
103
|
+
expect(task.kind).toBe("task");
|
|
104
|
+
expect(task.status).toBe("active");
|
|
105
|
+
expect(task.title).toBe("Dispatch test");
|
|
106
|
+
// CRITICAL: no session key yet — the CLI mints it at spawn and SessionStart
|
|
107
|
+
// writes it. Carrying one now would let resolveLaneTask race a half-bound row.
|
|
108
|
+
// assignee_type:"agent" marks it lane-owned from creation (ownership marker).
|
|
109
|
+
expect(task.kind_data).toEqual({ assignee_type: "agent" });
|
|
110
|
+
expect((task.kind_data as Record<string, unknown>).lane).toBeUndefined();
|
|
111
|
+
|
|
112
|
+
// Exactly one comment, content = the description (one source, not two).
|
|
113
|
+
expect(calls).toHaveLength(1);
|
|
114
|
+
expect(calls[0].comment).toMatchObject({
|
|
115
|
+
work_item_id: task.id,
|
|
116
|
+
content: "the brief text",
|
|
117
|
+
user_id: "user-1",
|
|
118
|
+
});
|
|
119
|
+
// The comment was written inside the same transaction as the task create
|
|
120
|
+
// (atomic — a task row without its brief comment can never exist).
|
|
121
|
+
expect(calls[0].workItem).toMatchObject({ id: task.id, kind: "task", status: "active" });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("parents the task under parent_id when supplied, else null", async () => {
|
|
125
|
+
const withParent = fakePrisma();
|
|
126
|
+
await createTaskWithBriefComment(
|
|
127
|
+
withParent.prisma as never,
|
|
128
|
+
{ name: "T", description: "d", parent_id: "proj-9", organisation_id: "org-1" },
|
|
129
|
+
"u",
|
|
130
|
+
);
|
|
131
|
+
expect(withParent.calls[0].workItem?.parent_id).toBe("proj-9");
|
|
132
|
+
|
|
133
|
+
const noParent = fakePrisma();
|
|
134
|
+
await createTaskWithBriefComment(
|
|
135
|
+
noParent.prisma as never,
|
|
136
|
+
{ name: "T", description: "d", organisation_id: "org-1" },
|
|
137
|
+
"u",
|
|
138
|
+
);
|
|
139
|
+
expect(noParent.calls[0].workItem?.parent_id).toBe(null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("stamps created_by/updated_by + comment user_id from the caller", async () => {
|
|
143
|
+
const { prisma, calls } = fakePrisma();
|
|
144
|
+
await createTaskWithBriefComment(
|
|
145
|
+
prisma as never,
|
|
146
|
+
{ name: "T", description: "d", organisation_id: "org-1" },
|
|
147
|
+
"user-42",
|
|
148
|
+
);
|
|
149
|
+
expect(calls[0].workItem).toMatchObject({ created_by: "user-42", updated_by: "user-42" });
|
|
150
|
+
expect(calls[0].comment).toMatchObject({ user_id: "user-42" });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("adoptAssignmentTask (SessionStart adopts the pre-created task + ownership)", () => {
|
|
155
|
+
function fakeAdoptPrisma(existing: Record<string, unknown> | null) {
|
|
156
|
+
const updates: Array<{ id: string } & Record<string, unknown>> = [];
|
|
157
|
+
const prisma = {
|
|
158
|
+
work_items: {
|
|
159
|
+
findFirst: async () => existing,
|
|
160
|
+
update: async (a: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
161
|
+
const row = { ...(existing ?? {}), ...a.data, id: a.where.id };
|
|
162
|
+
updates.push(row);
|
|
163
|
+
return row;
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
return { prisma, updates };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
it("writes the lane session onto the pre-created task AND sets assignee_id (ownership)", async () => {
|
|
171
|
+
const task = { id: "task-1", kind: "task", kind_data: { assignee_type: "agent" }, assignee_id: null };
|
|
172
|
+
const { prisma, updates } = fakeAdoptPrisma(task);
|
|
173
|
+
const out = await adoptAssignmentTask(prisma as never, "task-1", { session: "ses-9", pane: "tmux:%5" });
|
|
174
|
+
expect(out).not.toBeNull();
|
|
175
|
+
expect(updates).toHaveLength(1);
|
|
176
|
+
// OWNERSHIP: assignee_id = the lane session (so the gate can key caller==assignee).
|
|
177
|
+
expect(updates[0].assignee_id).toBe("ses-9");
|
|
178
|
+
// BINDING: kind_data.lane.session set (resolveLaneTask can now find it).
|
|
179
|
+
const kd = updates[0].kind_data as { lane: { session: string; pane: string }; assignee_type: string };
|
|
180
|
+
expect(kd.lane.session).toBe("ses-9");
|
|
181
|
+
expect(kd.lane.pane).toBe("tmux:%5");
|
|
182
|
+
expect(kd.assignee_type).toBe("agent");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("preserves existing kind_data when merging the lane marker onto it", async () => {
|
|
186
|
+
const task = { id: "t", kind: "task", kind_data: { assignee_type: "agent", other: "keep" } };
|
|
187
|
+
const { prisma, updates } = fakeAdoptPrisma(task);
|
|
188
|
+
await adoptAssignmentTask(prisma as never, "t", { session: "s", pane: null });
|
|
189
|
+
const kd = updates[0].kind_data as { other: string; lane: { session: string } };
|
|
190
|
+
expect(kd.other).toBe("keep"); // not clobbered
|
|
191
|
+
expect(kd.lane.session).toBe("s");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("REFUSES to steal a task another live session already owns", async () => {
|
|
195
|
+
const task = { id: "t", kind: "task", kind_data: { lane: { session: "someone-else" } } };
|
|
196
|
+
const { prisma, updates } = fakeAdoptPrisma(task);
|
|
197
|
+
const out = await adoptAssignmentTask(prisma as never, "t", { session: "s", pane: null });
|
|
198
|
+
expect(out).toBeNull();
|
|
199
|
+
expect(updates).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("re-adopts a task already bound to THIS session (idempotent re-bind)", async () => {
|
|
203
|
+
const task = { id: "t", kind: "task", kind_data: { lane: { session: "ses-9" } } };
|
|
204
|
+
const { prisma, updates } = fakeAdoptPrisma(task);
|
|
205
|
+
const out = await adoptAssignmentTask(prisma as never, "t", { session: "ses-9", pane: "tmux:%5" });
|
|
206
|
+
expect(out).not.toBeNull();
|
|
207
|
+
expect(updates).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns null when the id is absent (nothing to adopt — caller creates)", async () => {
|
|
211
|
+
const { prisma, updates } = fakeAdoptPrisma(null);
|
|
212
|
+
expect(await adoptAssignmentTask(prisma as never, null, { session: "s", pane: null })).toBeNull();
|
|
213
|
+
expect(await adoptAssignmentTask(prisma as never, undefined, { session: "s", pane: null })).toBeNull();
|
|
214
|
+
expect(updates).toHaveLength(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns null when the id is a PROJECT, not a task (disambiguates by kind)", async () => {
|
|
218
|
+
// A project parent must NOT be adopted — bindLane creates a task UNDER it.
|
|
219
|
+
const { prisma, updates } = fakeAdoptPrisma({ id: "proj-1", kind: "project", kind_data: {} });
|
|
220
|
+
expect(await adoptAssignmentTask(prisma as never, "proj-1", { session: "s", pane: null })).toBeNull();
|
|
221
|
+
expect(updates).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("bindTaskToKnownSession (resume path binds to a known session up front)", () => {
|
|
226
|
+
it("binds the task to the known resumeId session (ownership = resumeId)", async () => {
|
|
227
|
+
const task = { id: "t", kind: "task", kind_data: { assignee_type: "agent" }, assignee_id: null };
|
|
228
|
+
const prisma = {
|
|
229
|
+
work_items: {
|
|
230
|
+
findFirst: async () => task,
|
|
231
|
+
update: async (a: { where: { id: string }; data: Record<string, unknown> }) =>
|
|
232
|
+
({ ...task, ...a.data, id: a.where.id }) as Record<string, unknown>,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const out = (await bindTaskToKnownSession(prisma as never, "t", {
|
|
236
|
+
session: "resumeId-1",
|
|
237
|
+
pane: "tmux:%9",
|
|
238
|
+
})) as Record<string, unknown> | null;
|
|
239
|
+
expect(out).not.toBeNull();
|
|
240
|
+
expect(out?.assignee_id).toBe("resumeId-1");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("resolveAssignOrg", () => {
|
|
245
|
+
it("returns the explicit org when supplied", () => {
|
|
246
|
+
expect(resolveAssignOrg("org-explicit")).toBe("org-explicit");
|
|
247
|
+
});
|
|
248
|
+
it("falls back to the INJECTED platform org when explicit is omitted", () => {
|
|
249
|
+
// Pure: platform org arrives from the caller (resolved at the handler
|
|
250
|
+
// boundary), never pulled from the registry inside this module.
|
|
251
|
+
expect(resolveAssignOrg(undefined, "org-platform")).toBe("org-platform");
|
|
252
|
+
});
|
|
253
|
+
it("prefers explicit over the injected platform org", () => {
|
|
254
|
+
expect(resolveAssignOrg("org-explicit", "org-platform")).toBe("org-explicit");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-brief)", () => {
|
|
259
|
+
const task = { id: "task-99" };
|
|
260
|
+
|
|
261
|
+
it("emits command spawn_agent_tab carrying the taskId as workItemId", () => {
|
|
262
|
+
const out = buildAssignmentSpawnPayload(task, { slug: "cc-glm52", windowName: "FleetTruth" });
|
|
263
|
+
expect(out.command).toBe("spawn_agent_tab");
|
|
264
|
+
expect(out.payload.workItemId).toBe("task-99");
|
|
265
|
+
expect(out.payload.slug).toBe("cc-glm52");
|
|
266
|
+
expect(out.payload.windowName).toBe("FleetTruth");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("NEVER carries initialPrompt — the lane pulls its brief from the DB by session id", () => {
|
|
270
|
+
// Regression-lock for the failure class this deletes: a 4400-char
|
|
271
|
+
// initialPrompt fails; a 2600-char one arrives but is never submitted.
|
|
272
|
+
// The moment someone re-adds keystroke delivery this assertion fails.
|
|
273
|
+
const out = buildAssignmentSpawnPayload(task, { slug: "cc-glm52" });
|
|
274
|
+
expect("initialPrompt" in out.payload).toBe(false);
|
|
275
|
+
expect(out.payload).not.toHaveProperty("initialPrompt");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("omits optional spawn fields when not supplied (no undefined keys)", () => {
|
|
279
|
+
const out = buildAssignmentSpawnPayload(task, {});
|
|
280
|
+
expect(out.payload).toEqual({ workItemId: "task-99" });
|
|
281
|
+
expect("slug" in out.payload).toBe(false);
|
|
282
|
+
expect("windowName" in out.payload).toBe(false);
|
|
283
|
+
expect("idempotencyKey" in out.payload).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("forwards idempotencyKey when supplied (retries reuse it, new spawns don't)", () => {
|
|
287
|
+
const out = buildAssignmentSpawnPayload(task, { idempotencyKey: "k-1" });
|
|
288
|
+
expect(out.payload.idempotencyKey).toBe("k-1");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The assignment input. `name` + `description` are REQUIRED TOGETHER at the
|
|
63
|
+
* signature: oRPC parses this schema before the handler runs, so a call missing
|
|
64
|
+
* either is rejected with a 400 — an incomplete task is unconstructible, not
|
|
65
|
+
* caught after the fact. `description` becomes the task's first comment (the
|
|
66
|
+
* brief); it is never stored separately on the task, so "the brief" has exactly
|
|
67
|
+
* one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
|
|
68
|
+
*
|
|
69
|
+
* `name` → work_items.title; `description` → the first comment. `parent_id` is
|
|
70
|
+
* the project the dispatched task is scoped under (omit for an exploratory
|
|
71
|
+
* lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
|
|
72
|
+
* platform org (fleet/internal lane tasks live there).
|
|
73
|
+
*/
|
|
74
|
+
export const assignTaskInput = v.object({
|
|
75
|
+
name: v.pipe(
|
|
76
|
+
v.string(),
|
|
77
|
+
v.minLength(1, "name is required — an incomplete task is unconstructible"),
|
|
78
|
+
),
|
|
79
|
+
description: v.pipe(
|
|
80
|
+
v.string(),
|
|
81
|
+
v.minLength(1, "description is required — it is the brief (first comment)"),
|
|
82
|
+
),
|
|
83
|
+
parent_id: v.optional(v.string()),
|
|
84
|
+
organisation_id: v.optional(v.string()),
|
|
85
|
+
slug: v.optional(v.string()),
|
|
86
|
+
windowName: v.optional(v.string()),
|
|
87
|
+
idempotencyKey: v.optional(v.string()),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** The resolved (post-schema) shape the handlers operate on. */
|
|
91
|
+
export type AssignTaskInput = v.InferOutput<typeof assignTaskInput>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the org a dispatched task is created under. Pure: the platform org is
|
|
95
|
+
* INJECTED (resolved by the caller at the handler boundary, where a registry
|
|
96
|
+
* read is appropriate) rather than pulled from the registry inside this module,
|
|
97
|
+
* so the create logic is unit-testable with no DB (`configured-primitives`:
|
|
98
|
+
* config arrives from the caller, never pulled from DB/Env inside a primitive).
|
|
99
|
+
*/
|
|
100
|
+
export function resolveAssignOrg(explicit?: string, platformOrg?: string): string {
|
|
101
|
+
return explicit ?? platformOrg ?? getPlatformOrgId();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create a dispatched task AND its first comment (the description = the brief) in
|
|
106
|
+
* one transaction. The task is `kind:"task"`, `status:"active"`, with NO
|
|
107
|
+
* `kind_data.lane.session` yet — the session is minted by the CLI at spawn and
|
|
108
|
+
* written onto the row by SessionStart's adopt-existing bind. Until then the
|
|
109
|
+
* task is invisible to `resolveLaneTask` (which filters on the session), which is
|
|
110
|
+
* correct: the lane may only resolve its task once it is bound to it.
|
|
111
|
+
*
|
|
112
|
+
* Pure over an injected prisma (no module-level I/O) so the atomic create +
|
|
113
|
+
* first-comment decision is unit-testable with a fake transaction. `userId`
|
|
114
|
+
* stamps `created_by`/`updated_by` on the task and `user_id` on the comment.
|
|
115
|
+
*/
|
|
116
|
+
export async function createTaskWithBriefComment(
|
|
117
|
+
prisma: Prisma,
|
|
118
|
+
input: { name: string; description: string; parent_id?: string; organisation_id: string },
|
|
119
|
+
userId: string,
|
|
120
|
+
): Promise<WorkItem> {
|
|
121
|
+
const organisation_id = input.organisation_id;
|
|
122
|
+
return (prisma as unknown as {
|
|
123
|
+
$transaction: (fn: (tx: unknown) => Promise<WorkItem>) => Promise<WorkItem>;
|
|
124
|
+
}).$transaction(async (tx) => {
|
|
125
|
+
const t = tx as {
|
|
126
|
+
work_items: { create: (a: unknown) => Promise<WorkItem> };
|
|
127
|
+
comments: { create: (a: unknown) => Promise<unknown> };
|
|
128
|
+
};
|
|
129
|
+
const task = await t.work_items.create({
|
|
130
|
+
data: {
|
|
131
|
+
kind: "task",
|
|
132
|
+
parent_id: input.parent_id ?? null,
|
|
133
|
+
organisation_id,
|
|
134
|
+
title: input.name,
|
|
135
|
+
status: "active",
|
|
136
|
+
// No kind_data.lane.session here: the CLI mints the session at spawn
|
|
137
|
+
// and SessionStart binds it. Carrying an empty lane marker would let
|
|
138
|
+
// resolveLaneTask race a half-bound row, so the key is absent until set.
|
|
139
|
+
// assignee_type:"agent" marks the task as OWNED BY A LANE from creation,
|
|
140
|
+
// so the compliance gate can tell a dispatched+supervised task (the
|
|
141
|
+
// orchestrator does NOT owe it) from the caller's own implementation work
|
|
142
|
+
// — even before SessionStart writes the session that finalises ownership.
|
|
143
|
+
kind_data: { assignee_type: "agent" },
|
|
144
|
+
created_by: userId,
|
|
145
|
+
updated_by: userId,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
// The description IS the first comment IS the brief — one write, one source.
|
|
149
|
+
await t.comments.create({
|
|
150
|
+
data: {
|
|
151
|
+
work_item_id: task.id,
|
|
152
|
+
content: input.description,
|
|
153
|
+
user_id: userId,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return task;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ADOPT a pre-created assignment task at SessionStart: write the lane's
|
|
162
|
+
* freshly-minted session onto an EXISTING task row instead of creating a new
|
|
163
|
+
* one. `bindLane` calls this when the spawn carried a `workItemId` that resolves
|
|
164
|
+
* to a `kind:"task"` row (the assignment task whose first comment IS the brief).
|
|
165
|
+
* Without this, bindLane would CREATE a new task and orphan the brief-task — the
|
|
166
|
+
* lane would `readAssignedBrief` to nothing.
|
|
167
|
+
*
|
|
168
|
+
* Realizes OWNERSHIP too: sets `assignee_id` to the lane's session (its stable
|
|
169
|
+
* identity, `lane-identity-is-cli-session-id`), so the compliance gate can
|
|
170
|
+
* distinguish a task the CALLER owes from one a dispatched lane owns (the
|
|
171
|
+
* orchestrator supervises, not implements — `anthropic-models-orchestrate-not-
|
|
172
|
+
* implement`). "Is the caller the assignee" becomes a simple equality on the
|
|
173
|
+
* session.
|
|
174
|
+
*
|
|
175
|
+
* Refuses to STEAL a task another live session already owns — that is the
|
|
176
|
+
* existing-session re-bind branch's job (matched by `kind_data.lane.session`).
|
|
177
|
+
* Here we adopt only tasks with no session (or already this session). Returns
|
|
178
|
+
* null when `maybeTaskId` is absent, not a task, or already owned by another
|
|
179
|
+
* session — caller falls through to the normal create.
|
|
180
|
+
*/
|
|
181
|
+
export async function adoptAssignmentTask(
|
|
182
|
+
prisma: Prisma,
|
|
183
|
+
maybeTaskId: string | null | undefined,
|
|
184
|
+
lane: { session: string; pane: string | null; cli_kind?: string; role?: string },
|
|
185
|
+
): Promise<WorkItem | null> {
|
|
186
|
+
if (!maybeTaskId) return null;
|
|
187
|
+
const row = await prisma.work_items.findFirst({ where: { id: maybeTaskId, kind: "task" } });
|
|
188
|
+
if (!row) return null;
|
|
189
|
+
// Defense-in-depth: the findFirst filters kind:"task", but verify the kind on
|
|
190
|
+
// the read row too rather than trusting only the DB filter — a project parent
|
|
191
|
+
// (which a spawn also carries as workItemId) must never be adopted, only
|
|
192
|
+
// created-under. Verified, not assumed (`a-component-may-not-report-a-state...`).
|
|
193
|
+
if (row.kind !== "task") return null;
|
|
194
|
+
const existingSession = (
|
|
195
|
+
(row.kind_data as { lane?: { session?: string } } | null) ?? {}
|
|
196
|
+
).lane?.session;
|
|
197
|
+
if (existingSession && existingSession !== lane.session) return null;
|
|
198
|
+
const laneData = {
|
|
199
|
+
pane: lane.pane,
|
|
200
|
+
session: lane.session,
|
|
201
|
+
cli_kind: lane.cli_kind ?? "claude-code",
|
|
202
|
+
role: lane.role ?? "agent",
|
|
203
|
+
};
|
|
204
|
+
const mergedKindData = {
|
|
205
|
+
...(row.kind_data ?? {}),
|
|
206
|
+
lane: laneData,
|
|
207
|
+
assignee_type: "agent",
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
return await prisma.work_items.update({
|
|
211
|
+
where: { id: row.id },
|
|
212
|
+
data: { kind_data: mergedKindData, assignee_id: lane.session },
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
|
|
221
|
+
* CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
|
|
222
|
+
* lane knows its session id up front (`resumeId`). So the assignment binds the
|
|
223
|
+
* task to `resumeId` directly and writes a NEW comment as the continuation brief,
|
|
224
|
+
* before emitting the spawn with `resumeId`. `readAssignedBrief(resumeId)` then
|
|
225
|
+
* returns that continuation — so `DEFAULT_CONTINUATION_PROMPT`'s job (which rode
|
|
226
|
+
* the now-retired keystroke path) moves to a row write at assign time. Also sets
|
|
227
|
+
* `assignee_id` (ownership) for the same reason as the fresh-spawn adopt.
|
|
228
|
+
*/
|
|
229
|
+
export async function bindTaskToKnownSession(
|
|
230
|
+
prisma: Prisma,
|
|
231
|
+
taskId: string,
|
|
232
|
+
lane: { session: string; pane: string | null; cli_kind?: string; role?: string },
|
|
233
|
+
): Promise<WorkItem | null> {
|
|
234
|
+
return adoptAssignmentTask(prisma, taskId, lane);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The spawn payload an assignment emits. Carries the task id (as `workItemId`,
|
|
239
|
+
* which the picker exports into the pane so SessionStart can ADOPT the existing
|
|
240
|
+
* task) and NEVER an `initialPrompt` — the lane pulls its brief from the DB by
|
|
241
|
+
* its own session id. This is the contract whatever host-command surface lands:
|
|
242
|
+
* command `spawn_agent_tab`, payload `{ workItemId, slug?, windowName?,
|
|
243
|
+
* idempotencyKey? }`.
|
|
244
|
+
*
|
|
245
|
+
* Pure (no I/O) so the "no keystroke brief, taskId-only" invariant is
|
|
246
|
+
* unit-testable and regression-locked: a test asserting `initialPrompt` is
|
|
247
|
+
* absent fails the moment someone re-adds keystroke delivery.
|
|
248
|
+
*/
|
|
249
|
+
export function buildAssignmentSpawnPayload(
|
|
250
|
+
task: Pick<WorkItem, "id">,
|
|
251
|
+
options: { slug?: string; windowName?: string; idempotencyKey?: string },
|
|
252
|
+
): {
|
|
253
|
+
command: "spawn_agent_tab";
|
|
254
|
+
payload: { workItemId: string; slug?: string; windowName?: string; idempotencyKey?: string };
|
|
255
|
+
} {
|
|
256
|
+
return {
|
|
257
|
+
command: "spawn_agent_tab",
|
|
258
|
+
payload: {
|
|
259
|
+
workItemId: task.id,
|
|
260
|
+
...(options.slug ? { slug: options.slug } : {}),
|
|
261
|
+
...(options.windowName ? { windowName: options.windowName } : {}),
|
|
262
|
+
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The emission surface an assignment writes the spawn intent to. Injected (not
|
|
269
|
+
* imported) so this file composes with whichever bus surface the host-command-bus
|
|
270
|
+
* cutover settles on, rather than hard-coding a second mechanism. Implementations
|
|
271
|
+
* write a durable row the consumer reacts to (`actions-are-rows-then-emissions`):
|
|
272
|
+
* fire-and-forget from the caller's perspective — the caller states intent and
|
|
273
|
+
* proceeds; the consumer spawns the lane and the lane self-briefs.
|
|
274
|
+
*/
|
|
275
|
+
export interface AssignmentEmitter {
|
|
276
|
+
/** Write the spawn intent as a durable row the host-command consumer reacts to. */
|
|
277
|
+
emitSpawn(
|
|
278
|
+
payload: ReturnType<typeof buildAssignmentSpawnPayload>,
|
|
279
|
+
): Promise<void>;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The outcome of assigning a task: the created/confirmed task and confirmation
|
|
284
|
+
* that the spawn intent was emitted (not that the lane spawned — that is the
|
|
285
|
+
* consumer's reaction, observed separately). The caller proceeds on the row.
|
|
286
|
+
*/
|
|
287
|
+
export interface AssignmentResult {
|
|
288
|
+
task: WorkItem;
|
|
289
|
+
emitted: boolean;
|
|
290
|
+
}
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
* work_items-specific mapping) is what earns a package.
|
|
31
31
|
*/
|
|
32
32
|
import type { TaskListItem, WorkItem } from "./task-sync-router.ts";
|
|
33
|
-
|
|
33
|
+
/** CLI task-list status → work_items.status. The canonical forward direction. */
|
|
34
|
+
export declare const STATUS_TO_WORK_ITEM: Record<"completed" | "in_progress" | "pending", string>;
|
|
34
35
|
export declare function normalizeStatus(s: string | null): TaskListItem["status"];
|
|
35
36
|
/**
|
|
36
37
|
* Decide the status to WRITE for an existing row, given what the CLI list says.
|
|
@@ -29,6 +29,29 @@ describe("STATUS_TO_WORK_ITEM", () => {
|
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
describe("status translation is a single non-drifting source", () => {
|
|
33
|
+
// The forward map (CLI → work_items) is canonical; the reverse is DERIVED from
|
|
34
|
+
// it (+ a closed synonym set). This locks the structural fix: the two
|
|
35
|
+
// directions can never disagree, so adding a CLI status is one entry, not two
|
|
36
|
+
// maps to keep in sync — deletes the drift class (`prevention-over-detection`).
|
|
37
|
+
|
|
38
|
+
it("round-trips every CLI status through work_items and back unchanged", () => {
|
|
39
|
+
for (const cli of ["pending", "in_progress", "completed"] as TaskListItem["status"][]) {
|
|
40
|
+
expect(normalizeStatus(STATUS_TO_WORK_ITEM[cli])).toBe(cli);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("the reverse map is the derived inverse of the forward map", () => {
|
|
45
|
+
// Every forward mapping is present reversed (the reverse may ADD synonyms
|
|
46
|
+
// like active/completed, but must never CONTRADICT the forward map).
|
|
47
|
+
for (const [cli, workItem] of Object.entries(STATUS_TO_WORK_ITEM) as Array<
|
|
48
|
+
[TaskListItem["status"], string]
|
|
49
|
+
>) {
|
|
50
|
+
expect(normalizeStatus(workItem)).toBe(cli);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
32
55
|
describe("normalizeStatus", () => {
|
|
33
56
|
it("maps work_items statuses back to the normalized CLI form", () => {
|
|
34
57
|
expect(normalizeStatus("todo")).toBe("pending");
|
|
@@ -32,23 +32,44 @@
|
|
|
32
32
|
|
|
33
33
|
import type { TaskListItem, WorkItem } from "./task-sync-router.ts";
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// The status translation between a CLI's task list (pending/in_progress/completed)
|
|
36
|
+
// and work_items.status (todo/in-progress/done/active/blocked/…). ONE source of
|
|
37
|
+
// truth: the CLI→work_items map is canonical (each CLI state maps to exactly one
|
|
38
|
+
// work_items status); the reverse direction is DERIVED from it, plus a closed set
|
|
39
|
+
// of work_items-internal synonyms (active, completed) that have no distinct CLI
|
|
40
|
+
// state. The two directions therefore cannot drift — adding a CLI state is one
|
|
41
|
+
// entry in CLI_STATUS_TO_WORK_ITEM and the inverse updates with it — which deletes
|
|
42
|
+
// the "two hand-maintained inverse maps" drift class (`prevention-over-detection`).
|
|
36
43
|
// The DB enum uses a hyphen for the active state ("in-progress"); see
|
|
37
|
-
//
|
|
38
|
-
|
|
44
|
+
// COLUMN_ENUMES["work_items.status"] in db-validation.
|
|
45
|
+
const CLI_STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
|
|
39
46
|
pending: "todo",
|
|
40
47
|
in_progress: "in-progress",
|
|
41
48
|
completed: "done",
|
|
42
49
|
};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
|
|
51
|
+
// work_items-internal statuses that are SYNONYMS for a CLI state (no distinct CLI
|
|
52
|
+
// representation). A closed, additive set — the only hand-maintained second source.
|
|
53
|
+
const WORK_ITEM_STATUS_SYNONYMS: Record<string, TaskListItem["status"]> = {
|
|
47
54
|
active: "in_progress",
|
|
48
|
-
done: "completed",
|
|
49
55
|
completed: "completed",
|
|
50
56
|
};
|
|
51
57
|
|
|
58
|
+
// The reverse map, DERIVED: the canonical inverse of CLI_STATUS_TO_WORK_ITEM, plus
|
|
59
|
+
// the synonyms. Generated at module load so it can never disagree with the forward
|
|
60
|
+
// map. Unknown/internal statuses collapse to pending via normalizeStatus.
|
|
61
|
+
const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
|
|
62
|
+
...Object.fromEntries(
|
|
63
|
+
(Object.entries(CLI_STATUS_TO_WORK_ITEM) as Array<[TaskListItem["status"], string]>).map(
|
|
64
|
+
([cli, workItem]) => [workItem, cli],
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
...WORK_ITEM_STATUS_SYNONYMS,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** CLI task-list status → work_items.status. The canonical forward direction. */
|
|
71
|
+
export const STATUS_TO_WORK_ITEM = CLI_STATUS_TO_WORK_ITEM;
|
|
72
|
+
|
|
52
73
|
export function normalizeStatus(s: string | null): TaskListItem["status"] {
|
|
53
74
|
return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
|
|
54
75
|
}
|
|
@@ -42,6 +42,7 @@ import { authed, type Context } from "./auth-router.ts";
|
|
|
42
42
|
import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
|
|
43
43
|
import { resolveLaneTask, resolveLaneWorkName } from "./task-sync-lane.ts";
|
|
44
44
|
import { readAssignedBriefHandler } from "./task-sync-brief.ts";
|
|
45
|
+
import { adoptAssignmentTask } from "./task-sync-assign.ts";
|
|
45
46
|
import * as v from "valibot";
|
|
46
47
|
import {
|
|
47
48
|
STATUS_TO_WORK_ITEM,
|
|
@@ -486,37 +487,58 @@ export const taskSyncRouter = {
|
|
|
486
487
|
created = false;
|
|
487
488
|
log.info("[task-sync] bindLane: re-bound existing task", { taskId, session });
|
|
488
489
|
} else {
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
},
|
|
507
|
-
});
|
|
508
|
-
log.info("[task-sync] bindLane: created task", { taskId: row.id, session });
|
|
509
|
-
} catch (err) {
|
|
510
|
-
const winner = await resolveLaneTask(prisma, session);
|
|
511
|
-
if (!winner) throw err;
|
|
512
|
-
row = winner;
|
|
513
|
-
log.info("[task-sync] bindLane: lost the create race, using existing task", {
|
|
514
|
-
taskId: row.id,
|
|
490
|
+
// DISPATCH-BY-ASSIGNMENT adopt. When the spawn carried a workItemId that
|
|
491
|
+
// points to an existing kind:"task" (the assignment task whose FIRST
|
|
492
|
+
// COMMENT is the brief), ADOPT it — write this lane's freshly-minted
|
|
493
|
+
// session onto it + set ownership (assignee_id) — instead of creating a
|
|
494
|
+
// new task that orphans the brief and leaves the lane self-briefing to
|
|
495
|
+
// nothing. Falls through to create for an exploratory lane (no
|
|
496
|
+
// workItemId) or a project parent (kind:"project"), so existing behaviour
|
|
497
|
+
// is unchanged on those paths.
|
|
498
|
+
const adopted = await adoptAssignmentTask(prisma, raw.parent_id, {
|
|
499
|
+
...lane,
|
|
500
|
+
session,
|
|
501
|
+
});
|
|
502
|
+
if (adopted) {
|
|
503
|
+
taskId = adopted.id;
|
|
504
|
+
created = false;
|
|
505
|
+
log.info("[task-sync] bindLane: adopted assigned task", {
|
|
506
|
+
taskId: adopted.id,
|
|
515
507
|
session,
|
|
516
508
|
});
|
|
509
|
+
} else {
|
|
510
|
+
// Read-then-create is a race: SessionStart and the lane's first
|
|
511
|
+
// TaskCreate arrive seconds apart, both read nothing, and both create.
|
|
512
|
+
// Measured 2026-07-25: 9 lanes with two active parents each, every twin
|
|
513
|
+
// 2-3 seconds behind its sibling. Migration 109's partial unique index
|
|
514
|
+
// makes the second write fail instead of succeed, and losing the race
|
|
515
|
+
// is not an error — the winner's row is exactly what this call wanted,
|
|
516
|
+
// so re-read it and carry on.
|
|
517
|
+
let row: WorkItem;
|
|
518
|
+
try {
|
|
519
|
+
row = await prisma.work_items.create({
|
|
520
|
+
data: {
|
|
521
|
+
kind: "task",
|
|
522
|
+
parent_id: raw.parent_id ?? null,
|
|
523
|
+
organisation_id,
|
|
524
|
+
title: laneTitle,
|
|
525
|
+
status: "active",
|
|
526
|
+
kind_data: { lane },
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
log.info("[task-sync] bindLane: created task", { taskId: row.id, session });
|
|
530
|
+
} catch (err) {
|
|
531
|
+
const winner = await resolveLaneTask(prisma, session);
|
|
532
|
+
if (!winner) throw err;
|
|
533
|
+
row = winner;
|
|
534
|
+
log.info("[task-sync] bindLane: lost the create race, using existing task", {
|
|
535
|
+
taskId: row.id,
|
|
536
|
+
session,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
taskId = row.id;
|
|
540
|
+
created = true;
|
|
517
541
|
}
|
|
518
|
-
taskId = row.id;
|
|
519
|
-
created = true;
|
|
520
542
|
}
|
|
521
543
|
// Link the fleet run carrying this pane to the bound task — sets
|
|
522
544
|
// fleet_agent_runs.tracked_task_id. Best-effort by design: fleet_agent_runs
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
17
17
|
export type PgBossJobHandler = (ctx: {
|
|
18
18
|
config: ScheduledJobConfig;
|
|
19
|
+
/** Per-job data payload from a one-shot `boss.send()` — absent for scheduled jobs. */
|
|
20
|
+
jobData?: unknown;
|
|
19
21
|
}) => Promise<void> | void;
|
|
20
22
|
export declare const pgBossHandlerRegistry: import("@teamscala/generic-registry/create-registry").Registry<PgBossJobHandler>;
|
|
21
23
|
/**
|
package/src/pg-boss-mount.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ import "./handlers/scala-tools-currency-watch.ts";
|
|
|
21
21
|
import "./handlers/singleton-tools-currency-watch.ts";
|
|
22
22
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
23
23
|
import "./handlers/pane-outbox-drain.ts";
|
|
24
|
+
/** Returns the mounted PgBoss instance, or null if pg-boss is not running on this service. */
|
|
25
|
+
export declare function getPgBoss(): PgBoss | null;
|
|
24
26
|
export interface PgBossMountContext {
|
|
25
27
|
databaseUrl: string;
|
|
26
28
|
serviceSlug: string;
|