@teamscala/service-boot 0.23.308 → 0.23.310
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 +1 -1
- package/src/fn-routers/task-sync-assign.d.ts +44 -0
- package/src/fn-routers/task-sync-assign.test.ts +32 -0
- package/src/fn-routers/task-sync-assign.ts +55 -0
- package/src/fn-routers/task-sync-router.d.ts +12 -0
- package/src/fn-routers/task-sync-router.ts +4 -1
- package/src/pg-boss-mount.ts +9 -2
package/package.json
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import * as v from "valibot";
|
|
57
57
|
import type { Prisma, WorkItem } from "./task-sync-router.ts";
|
|
58
|
+
import { type Context } from "./auth-router.ts";
|
|
58
59
|
/**
|
|
59
60
|
* The assignment input. `name` + `description` are REQUIRED TOGETHER at the
|
|
60
61
|
* signature: oRPC parses this schema before the handler runs, so a call missing
|
|
@@ -194,3 +195,46 @@ export interface AssignmentResult {
|
|
|
194
195
|
task: WorkItem;
|
|
195
196
|
emitted: boolean;
|
|
196
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* createBriefTask ORPC input. The MINT entry point a spawner calls to turn a
|
|
200
|
+
* raw brief into the task row a lane self-briefs from. `name` is the lane's work
|
|
201
|
+
* label (the spawner's windowName); `description` is the brief, stored as the
|
|
202
|
+
* task's FIRST COMMENT — never on the task row itself, so "the brief" has
|
|
203
|
+
* exactly one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
|
|
204
|
+
* Mirrors `assignTaskInput` minus the spawn-shaping fields (slug/windowName/
|
|
205
|
+
* idempotencyKey), which belong on the spawn emission, not the mint.
|
|
206
|
+
*/
|
|
207
|
+
export declare const createBriefTaskInput: v.ObjectSchema<{
|
|
208
|
+
readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "name is required — the lane's work label">]>;
|
|
209
|
+
readonly description: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "description is required — it is the brief (first comment)">]>;
|
|
210
|
+
readonly parent_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
211
|
+
readonly organisation_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
|
|
212
|
+
}, undefined>;
|
|
213
|
+
/**
|
|
214
|
+
* createBriefTask ORPC handler. `spawn_agent_tab` calls this when it receives
|
|
215
|
+
* an `initialPrompt` with no `workItemId`: the prompt becomes the task's first
|
|
216
|
+
* comment, the returned `work_item_id` rides the spawn payload as `workItemId`,
|
|
217
|
+
* and the lane PULLS the brief at boot (`readAssignedBrief`) — no keystroke, no
|
|
218
|
+
* channel send, no positional argument. That deletes the keystroke-brief
|
|
219
|
+
* failure class by construction (`buildAssignmentSpawnPayload` already carries
|
|
220
|
+
* `workItemId` and never `initialPrompt`).
|
|
221
|
+
*
|
|
222
|
+
* Wraps the pure `createTaskWithBriefComment` (atomic task + first comment). The
|
|
223
|
+
* task is `kind:"task"`, `status:"active"`, `assignee_type:"agent"`, with NO
|
|
224
|
+
* `kind_data.lane.session`: the CLI mints the session at spawn and SessionStart
|
|
225
|
+
* ADOPTS this task by id (`adoptAssignmentTask`), which binds the brief to the
|
|
226
|
+
* lane. Returns only the `work_item_id` — the single fact the spawner needs to
|
|
227
|
+
* carry the brief without writing it into the spawn payload. Same transport as
|
|
228
|
+
* `readAssignedBrief`: page-gate-exempt (fn.taskSync), localhost super-admin.
|
|
229
|
+
*/
|
|
230
|
+
export declare const createBriefTaskHandler: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
231
|
+
headers: Headers;
|
|
232
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
|
|
233
|
+
prisma: any;
|
|
234
|
+
user: import("./auth-router.ts").User;
|
|
235
|
+
orgId: string | null | undefined;
|
|
236
|
+
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
237
|
+
work_item_id: string;
|
|
238
|
+
}, {
|
|
239
|
+
work_item_id: string;
|
|
240
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
@@ -13,6 +13,7 @@ import { describe, it, expect } from "bun:test";
|
|
|
13
13
|
import * as v from "valibot";
|
|
14
14
|
import {
|
|
15
15
|
assignTaskInput,
|
|
16
|
+
createBriefTaskInput,
|
|
16
17
|
createTaskWithBriefComment,
|
|
17
18
|
adoptAssignmentTask,
|
|
18
19
|
bindTaskToKnownSession,
|
|
@@ -90,6 +91,37 @@ describe("assignTaskInput (incomplete task is unconstructible)", () => {
|
|
|
90
91
|
});
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
describe("createBriefTaskInput (the spawner's mint entry — incomplete task is unconstructible)", () => {
|
|
95
|
+
// The mint a raw initialPrompt hits: spawn_agent_tab calls createBriefTask,
|
|
96
|
+
// which validates against this schema BEFORE the handler runs. An incomplete
|
|
97
|
+
// mint (no brief) is rejected at the signature, never caught after the fact —
|
|
98
|
+
// the same gate assignTaskInput sets, on the entry point spawn_agent_tab owns.
|
|
99
|
+
it("accepts name + description together", () => {
|
|
100
|
+
const out = v.safeParse(createBriefTaskInput, {
|
|
101
|
+
name: "Spawn mints row",
|
|
102
|
+
description: "the brief text",
|
|
103
|
+
});
|
|
104
|
+
expect(out.success).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects a missing description — no brief, no minted task", () => {
|
|
108
|
+
expect(v.safeParse(createBriefTaskInput, { name: "X" }).success).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("rejects a missing name", () => {
|
|
112
|
+
expect(v.safeParse(createBriefTaskInput, { description: "d" }).success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects an empty-string description (not just absent)", () => {
|
|
116
|
+
expect(v.safeParse(createBriefTaskInput, { name: "X", description: "" }).success).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("treats parent_id/organisation_id as optional (exploratory lane)", () => {
|
|
120
|
+
const out = v.safeParse(createBriefTaskInput, { name: "Explore", description: "d" });
|
|
121
|
+
expect(out.success).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
93
125
|
describe("createTaskWithBriefComment (atomic task + first comment = brief)", () => {
|
|
94
126
|
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
127
|
const { prisma, calls } = fakePrisma();
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
58
58
|
import * as v from "valibot";
|
|
59
59
|
import type { Prisma, WorkItem } from "./task-sync-router.ts";
|
|
60
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
60
61
|
|
|
61
62
|
/**
|
|
62
63
|
* The assignment input. `name` + `description` are REQUIRED TOGETHER at the
|
|
@@ -288,3 +289,57 @@ export interface AssignmentResult {
|
|
|
288
289
|
task: WorkItem;
|
|
289
290
|
emitted: boolean;
|
|
290
291
|
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* createBriefTask ORPC input. The MINT entry point a spawner calls to turn a
|
|
295
|
+
* raw brief into the task row a lane self-briefs from. `name` is the lane's work
|
|
296
|
+
* label (the spawner's windowName); `description` is the brief, stored as the
|
|
297
|
+
* task's FIRST COMMENT — never on the task row itself, so "the brief" has
|
|
298
|
+
* exactly one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
|
|
299
|
+
* Mirrors `assignTaskInput` minus the spawn-shaping fields (slug/windowName/
|
|
300
|
+
* idempotencyKey), which belong on the spawn emission, not the mint.
|
|
301
|
+
*/
|
|
302
|
+
export const createBriefTaskInput = v.object({
|
|
303
|
+
name: v.pipe(v.string(), v.minLength(1, "name is required — the lane's work label")),
|
|
304
|
+
description: v.pipe(
|
|
305
|
+
v.string(),
|
|
306
|
+
v.minLength(1, "description is required — it is the brief (first comment)"),
|
|
307
|
+
),
|
|
308
|
+
parent_id: v.optional(v.string()),
|
|
309
|
+
organisation_id: v.optional(v.string()),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* createBriefTask ORPC handler. `spawn_agent_tab` calls this when it receives
|
|
314
|
+
* an `initialPrompt` with no `workItemId`: the prompt becomes the task's first
|
|
315
|
+
* comment, the returned `work_item_id` rides the spawn payload as `workItemId`,
|
|
316
|
+
* and the lane PULLS the brief at boot (`readAssignedBrief`) — no keystroke, no
|
|
317
|
+
* channel send, no positional argument. That deletes the keystroke-brief
|
|
318
|
+
* failure class by construction (`buildAssignmentSpawnPayload` already carries
|
|
319
|
+
* `workItemId` and never `initialPrompt`).
|
|
320
|
+
*
|
|
321
|
+
* Wraps the pure `createTaskWithBriefComment` (atomic task + first comment). The
|
|
322
|
+
* task is `kind:"task"`, `status:"active"`, `assignee_type:"agent"`, with NO
|
|
323
|
+
* `kind_data.lane.session`: the CLI mints the session at spawn and SessionStart
|
|
324
|
+
* ADOPTS this task by id (`adoptAssignmentTask`), which binds the brief to the
|
|
325
|
+
* lane. Returns only the `work_item_id` — the single fact the spawner needs to
|
|
326
|
+
* carry the brief without writing it into the spawn payload. Same transport as
|
|
327
|
+
* `readAssignedBrief`: page-gate-exempt (fn.taskSync), localhost super-admin.
|
|
328
|
+
*/
|
|
329
|
+
export const createBriefTaskHandler = authed.handler(async ({ input, context }) => {
|
|
330
|
+
const ctx = context as Context;
|
|
331
|
+
const prisma = ctx.prisma as Prisma;
|
|
332
|
+
const data = input as v.InferOutput<typeof createBriefTaskInput>;
|
|
333
|
+
const userId = (ctx.user as { id: string }).id;
|
|
334
|
+
const task = await createTaskWithBriefComment(
|
|
335
|
+
prisma,
|
|
336
|
+
{
|
|
337
|
+
name: data.name,
|
|
338
|
+
description: data.description,
|
|
339
|
+
parent_id: data.parent_id,
|
|
340
|
+
organisation_id: resolveAssignOrg(data.organisation_id),
|
|
341
|
+
},
|
|
342
|
+
userId,
|
|
343
|
+
);
|
|
344
|
+
return { work_item_id: task.id };
|
|
345
|
+
});
|
|
@@ -319,6 +319,18 @@ export declare const taskSyncRouter: {
|
|
|
319
319
|
brief: string | null;
|
|
320
320
|
comment_created_at: string | null;
|
|
321
321
|
}>, Record<never, never>, Record<never, never>>;
|
|
322
|
+
/** SPAWNER→DB brief mint: `spawn_agent_tab` turns an initialPrompt into the task row (brief = first comment) the lane self-briefs from; returns the work_item_id that rides the spawn payload. Handler in task-sync-assign.ts. */
|
|
323
|
+
createBriefTask: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
324
|
+
headers: Headers;
|
|
325
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
|
|
326
|
+
prisma: any;
|
|
327
|
+
user: import("./auth-router.ts").User;
|
|
328
|
+
orgId: string | null | undefined;
|
|
329
|
+
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
330
|
+
work_item_id: string;
|
|
331
|
+
}, {
|
|
332
|
+
work_item_id: string;
|
|
333
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
322
334
|
/**
|
|
323
335
|
* DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
|
|
324
336
|
* change to a subtask's status (operator toggles it in the projects UI,
|
|
@@ -42,7 +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
|
+
import { adoptAssignmentTask, createBriefTaskHandler } from "./task-sync-assign.ts";
|
|
46
46
|
import * as v from "valibot";
|
|
47
47
|
import {
|
|
48
48
|
STATUS_TO_WORK_ITEM,
|
|
@@ -949,6 +949,9 @@ export const taskSyncRouter = {
|
|
|
949
949
|
*/
|
|
950
950
|
readAssignedBrief: readAssignedBriefHandler,
|
|
951
951
|
|
|
952
|
+
/** SPAWNER→DB brief mint: `spawn_agent_tab` turns an initialPrompt into the task row (brief = first comment) the lane self-briefs from; returns the work_item_id that rides the spawn payload. Handler in task-sync-assign.ts. */
|
|
953
|
+
createBriefTask: createBriefTaskHandler,
|
|
954
|
+
|
|
952
955
|
/**
|
|
953
956
|
* DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
|
|
954
957
|
* change to a subtask's status (operator toggles it in the projects UI,
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -204,8 +204,15 @@ export async function mountPgBoss(
|
|
|
204
204
|
// underlying SQL is ON CONFLICT DO NOTHING, so calling it on every
|
|
205
205
|
// boot is a safe idempotent assert, never a duplicate-creation error.
|
|
206
206
|
await boss.createQueue(queueName);
|
|
207
|
-
await boss.work(queueName, async (
|
|
208
|
-
|
|
207
|
+
await boss.work(queueName, async (jobs) => {
|
|
208
|
+
let result: unknown;
|
|
209
|
+
for (const job of jobs) {
|
|
210
|
+
result = await resolveHandler(config.handler_slug, config, job.data);
|
|
211
|
+
}
|
|
212
|
+
// pg-boss stores the work handler return as job output when
|
|
213
|
+
// exactly one job was processed (manager.js complete()). For
|
|
214
|
+
// batch/scheduled jobs the return is not stored.
|
|
215
|
+
return jobs.length === 1 ? result : undefined;
|
|
209
216
|
});
|
|
210
217
|
|
|
211
218
|
if (config.cron) {
|