@teamscala/service-boot 0.23.303 → 0.23.304
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 +17 -1
- package/src/fn-routers/task-sync-brief.d.ts +69 -0
- package/src/fn-routers/task-sync-brief.test.ts +77 -0
- package/src/fn-routers/task-sync-brief.ts +97 -0
- package/src/fn-routers/task-sync-router.d.ts +34 -0
- package/src/fn-routers/task-sync-router.ts +27 -0
- package/src/handlers/singleton-tools-currency-watch.d.ts +1 -0
- package/src/handlers/singleton-tools-currency-watch.test.ts +46 -0
- package/src/handlers/singleton-tools-currency-watch.ts +279 -0
- package/src/pg-boss-mount.d.ts +1 -0
- package/src/pg-boss-mount.ts +1 -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.304",
|
|
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-brief": {
|
|
390
|
+
"types": "./src/fn-routers/task-sync-brief.d.ts",
|
|
391
|
+
"default": "./src/fn-routers/task-sync-brief.ts"
|
|
392
|
+
},
|
|
393
|
+
"./fn-routers/task-sync-brief.ts": {
|
|
394
|
+
"types": "./src/fn-routers/task-sync-brief.d.ts",
|
|
395
|
+
"default": "./src/fn-routers/task-sync-brief.ts"
|
|
396
|
+
},
|
|
389
397
|
"./fn-routers/task-sync-dispatch": {
|
|
390
398
|
"types": "./src/fn-routers/task-sync-dispatch.d.ts",
|
|
391
399
|
"default": "./src/fn-routers/task-sync-dispatch.ts"
|
|
@@ -506,6 +514,14 @@
|
|
|
506
514
|
"types": "./src/handlers/scala-tools-currency-watch.d.ts",
|
|
507
515
|
"default": "./src/handlers/scala-tools-currency-watch.ts"
|
|
508
516
|
},
|
|
517
|
+
"./handlers/singleton-tools-currency-watch": {
|
|
518
|
+
"types": "./src/handlers/singleton-tools-currency-watch.d.ts",
|
|
519
|
+
"default": "./src/handlers/singleton-tools-currency-watch.ts"
|
|
520
|
+
},
|
|
521
|
+
"./handlers/singleton-tools-currency-watch.ts": {
|
|
522
|
+
"types": "./src/handlers/singleton-tools-currency-watch.d.ts",
|
|
523
|
+
"default": "./src/handlers/singleton-tools-currency-watch.ts"
|
|
524
|
+
},
|
|
509
525
|
"./handlers/sweep-event-log": {
|
|
510
526
|
"types": "./src/handlers/sweep-event-log.d.ts",
|
|
511
527
|
"default": "./src/handlers/sweep-event-log.ts"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
|
|
7
|
+
* lane's CLI extension reads its own session id on load, resolves the task
|
|
8
|
+
* assigned to that session, and reads the brief from that task's LATEST comment.
|
|
9
|
+
* Nothing is pasted, typed, passed as an argument or sent over a channel
|
|
10
|
+
* (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
|
|
11
|
+
* brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
|
|
12
|
+
* the router mounts the handler.
|
|
13
|
+
*
|
|
14
|
+
* Split out of task-sync-router.ts for the same reason task-sync-lane.ts +
|
|
15
|
+
* task-sync-projection.ts exist: the router sits at its 1000-line commit gate,
|
|
16
|
+
* so every cohesive unit that can live in a sibling does. The self-brief leg is
|
|
17
|
+
* one unit — the handler + the response-shaping decision belong together.
|
|
18
|
+
*
|
|
19
|
+
* CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
|
|
20
|
+
* candidate. Bound to the work_items + comments schema and to task-sync's own
|
|
21
|
+
* kind_data.lane.session convention; no unrelated app could install this
|
|
22
|
+
* unmodified.
|
|
23
|
+
*/
|
|
24
|
+
import type { Prisma, CommentRow } from "./task-sync-router.ts";
|
|
25
|
+
import { type Context } from "./auth-router.ts";
|
|
26
|
+
/**
|
|
27
|
+
* Shape the readAssignedBrief response from a resolved task + its latest
|
|
28
|
+
* comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
|
|
29
|
+
* verdict from no data" (`no-verdict-from-no-data`) — is unit-testable. A null
|
|
30
|
+
* task or null comment returns all-null rather than an empty/fake brief, so the
|
|
31
|
+
* extension can distinguish "nothing to inject" from "inject this".
|
|
32
|
+
*/
|
|
33
|
+
export declare function shapeBriefResponse(task: {
|
|
34
|
+
id: string;
|
|
35
|
+
} | null, comment: Pick<CommentRow, "content" | "created_at"> | null): {
|
|
36
|
+
work_item_id: string | null;
|
|
37
|
+
brief: string | null;
|
|
38
|
+
comment_created_at: string | null;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Read the LATEST comment on a work_items task. The comment IS the brief for a
|
|
42
|
+
* run: every spawn writes a NEW comment, and the description is never the brief,
|
|
43
|
+
* so "what was this lane told" always has exactly one answer
|
|
44
|
+
* (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
|
|
45
|
+
* task has no comment yet — the extension treats that as "no brief to inject".
|
|
46
|
+
*/
|
|
47
|
+
export declare function readLatestTaskComment(prisma: Prisma, workItemId: string): Promise<CommentRow | null>;
|
|
48
|
+
/**
|
|
49
|
+
* readAssignedBrief ORPC handler. A lane's CLI extension calls this with its
|
|
50
|
+
* own session id to pull its brief. Same transport as bindLane/readTaskList: the
|
|
51
|
+
* session id arrives on x-lane-session (and body.lane_session as fallback). The
|
|
52
|
+
* procedure is page-gate-exempt (fn.taskSync), so a localhost pane shell
|
|
53
|
+
* authenticates as super-admin via resolveUser with no session cookie.
|
|
54
|
+
*/
|
|
55
|
+
export declare const readAssignedBriefHandler: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
56
|
+
headers: Headers;
|
|
57
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
|
|
58
|
+
prisma: any;
|
|
59
|
+
user: import("./auth-router.ts").User;
|
|
60
|
+
orgId: string | null | undefined;
|
|
61
|
+
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
62
|
+
work_item_id: string | null;
|
|
63
|
+
brief: string | null;
|
|
64
|
+
comment_created_at: string | null;
|
|
65
|
+
}, {
|
|
66
|
+
work_item_id: string | null;
|
|
67
|
+
brief: string | null;
|
|
68
|
+
comment_created_at: string | null;
|
|
69
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the self-brief leg: shapeBriefResponse (the pure
|
|
7
|
+
* no-task/no-comment ⇒ nulls decision) + source-contract assertions that
|
|
8
|
+
* readLatestTaskComment reads the LATEST comment and the handler resolves by
|
|
9
|
+
* session. Covers `what-must-have-a-test` for the new procedure.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from "bun:test";
|
|
12
|
+
import { shapeBriefResponse, readLatestTaskComment } from "./task-sync-brief.ts";
|
|
13
|
+
|
|
14
|
+
describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
15
|
+
it("returns all-null when there is no task", () => {
|
|
16
|
+
// A lane with no bound task has no brief — null, never an empty fake.
|
|
17
|
+
expect(shapeBriefResponse(null, { content: "x", created_at: new Date() })).toEqual({
|
|
18
|
+
work_item_id: null,
|
|
19
|
+
brief: null,
|
|
20
|
+
comment_created_at: null,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns all-null when the task has no comment yet", () => {
|
|
25
|
+
// The task exists but no spawn comment landed; the extension must inject
|
|
26
|
+
// nothing rather than a fabricated brief.
|
|
27
|
+
expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
|
|
28
|
+
work_item_id: null,
|
|
29
|
+
brief: null,
|
|
30
|
+
comment_created_at: null,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns the comment content + ISO created_at for a resolved task+comment", () => {
|
|
35
|
+
const ts = new Date("2026-08-01T03:24:00.000Z");
|
|
36
|
+
expect(shapeBriefResponse({ id: "task-7" }, { content: "Do the thing", created_at: ts })).toEqual({
|
|
37
|
+
work_item_id: "task-7",
|
|
38
|
+
brief: "Do the thing",
|
|
39
|
+
comment_created_at: "2026-08-01T03:24:00.000Z",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("serializes the comment created_at deterministically (Date → ISO)", () => {
|
|
44
|
+
const ts = new Date(0);
|
|
45
|
+
const out = shapeBriefResponse({ id: "t" }, { content: "c", created_at: ts });
|
|
46
|
+
expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
|
|
51
|
+
const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
|
|
52
|
+
|
|
53
|
+
it("queries comments newest-first by created_at on work_item_id", async () => {
|
|
54
|
+
// The comment IS the brief for a run; ordering by created_at desc makes the
|
|
55
|
+
// LATEST comment (this run's) the answer, never a stale prior-run comment.
|
|
56
|
+
const text = await briefSrc.text();
|
|
57
|
+
expect(text).toContain('orderBy: { created_at: "desc" }');
|
|
58
|
+
expect(text).toContain("work_item_id: workItemId");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("resolves the task by session before reading its comment", async () => {
|
|
62
|
+
// The lane's own session id is the join key — a pane id is never the key
|
|
63
|
+
// (it outlives the session and would inherit a prior session's brief).
|
|
64
|
+
const text = await briefSrc.text();
|
|
65
|
+
expect(text).toContain("resolveLaneTask(prisma, session)");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("readLatestTaskComment is a single latest-comment read", () => {
|
|
70
|
+
it("returns null when the prisma client has no comments model (feature-detect)", async () => {
|
|
71
|
+
// `comments` is a universal shared model, but the access is feature-detected
|
|
72
|
+
// like the fleet_agent_runs reads so a client without it degrades to "no
|
|
73
|
+
// brief" rather than throwing.
|
|
74
|
+
const out = await readLatestTaskComment({} as never, "task-1");
|
|
75
|
+
expect(out).toBe(null);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
|
|
7
|
+
* lane's CLI extension reads its own session id on load, resolves the task
|
|
8
|
+
* assigned to that session, and reads the brief from that task's LATEST comment.
|
|
9
|
+
* Nothing is pasted, typed, passed as an argument or sent over a channel
|
|
10
|
+
* (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
|
|
11
|
+
* brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
|
|
12
|
+
* the router mounts the handler.
|
|
13
|
+
*
|
|
14
|
+
* Split out of task-sync-router.ts for the same reason task-sync-lane.ts +
|
|
15
|
+
* task-sync-projection.ts exist: the router sits at its 1000-line commit gate,
|
|
16
|
+
* so every cohesive unit that can live in a sibling does. The self-brief leg is
|
|
17
|
+
* one unit — the handler + the response-shaping decision belong together.
|
|
18
|
+
*
|
|
19
|
+
* CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
|
|
20
|
+
* candidate. Bound to the work_items + comments schema and to task-sync's own
|
|
21
|
+
* kind_data.lane.session convention; no unrelated app could install this
|
|
22
|
+
* unmodified.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Prisma, CommentRow } from "./task-sync-router.ts";
|
|
26
|
+
import type { WorkItem } from "./task-sync-router.ts";
|
|
27
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
28
|
+
import { resolveLaneTask } from "./task-sync-lane.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Shape the readAssignedBrief response from a resolved task + its latest
|
|
32
|
+
* comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
|
|
33
|
+
* verdict from no data" (`no-verdict-from-no-data`) — is unit-testable. A null
|
|
34
|
+
* task or null comment returns all-null rather than an empty/fake brief, so the
|
|
35
|
+
* extension can distinguish "nothing to inject" from "inject this".
|
|
36
|
+
*/
|
|
37
|
+
export function shapeBriefResponse(
|
|
38
|
+
task: { id: string } | null,
|
|
39
|
+
comment: Pick<CommentRow, "content" | "created_at"> | null,
|
|
40
|
+
): { work_item_id: string | null; brief: string | null; comment_created_at: string | null } {
|
|
41
|
+
if (!task || !comment) {
|
|
42
|
+
return { work_item_id: null, brief: null, comment_created_at: null };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
work_item_id: task.id,
|
|
46
|
+
brief: comment.content,
|
|
47
|
+
comment_created_at:
|
|
48
|
+
comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the LATEST comment on a work_items task. The comment IS the brief for a
|
|
54
|
+
* run: every spawn writes a NEW comment, and the description is never the brief,
|
|
55
|
+
* so "what was this lane told" always has exactly one answer
|
|
56
|
+
* (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
|
|
57
|
+
* task has no comment yet — the extension treats that as "no brief to inject".
|
|
58
|
+
*/
|
|
59
|
+
export async function readLatestTaskComment(
|
|
60
|
+
prisma: Prisma,
|
|
61
|
+
workItemId: string,
|
|
62
|
+
): Promise<CommentRow | null> {
|
|
63
|
+
const comments = (
|
|
64
|
+
prisma as unknown as {
|
|
65
|
+
comments?: {
|
|
66
|
+
findFirst: (a: unknown) => Promise<CommentRow | null>;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
).comments;
|
|
70
|
+
if (!comments?.findFirst) return null;
|
|
71
|
+
return comments.findFirst({
|
|
72
|
+
where: { work_item_id: workItemId },
|
|
73
|
+
orderBy: { created_at: "desc" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* readAssignedBrief ORPC handler. A lane's CLI extension calls this with its
|
|
79
|
+
* own session id to pull its brief. Same transport as bindLane/readTaskList: the
|
|
80
|
+
* session id arrives on x-lane-session (and body.lane_session as fallback). The
|
|
81
|
+
* procedure is page-gate-exempt (fn.taskSync), so a localhost pane shell
|
|
82
|
+
* authenticates as super-admin via resolveUser with no session cookie.
|
|
83
|
+
*/
|
|
84
|
+
export const readAssignedBriefHandler = authed.handler(async ({ input, context }) => {
|
|
85
|
+
const ctx = context as Context;
|
|
86
|
+
const prisma = ctx.prisma as Prisma;
|
|
87
|
+
const body = (input ?? {}) as { lane_session?: string };
|
|
88
|
+
const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
|
|
89
|
+
if (!session) {
|
|
90
|
+
const { ORPCError } = await import("@orpc/server");
|
|
91
|
+
throw new ORPCError("BAD_REQUEST", { message: "readAssignedBrief requires lane_session" });
|
|
92
|
+
}
|
|
93
|
+
const task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
|
|
94
|
+
if (!task) return shapeBriefResponse(null, null);
|
|
95
|
+
const comment = await readLatestTaskComment(prisma, task.id);
|
|
96
|
+
return shapeBriefResponse(task, comment);
|
|
97
|
+
});
|
|
@@ -102,7 +102,15 @@ export type Prisma = {
|
|
|
102
102
|
count: number;
|
|
103
103
|
}>;
|
|
104
104
|
};
|
|
105
|
+
comments?: {
|
|
106
|
+
findFirst: (a: unknown) => Promise<CommentRow | null>;
|
|
107
|
+
};
|
|
105
108
|
};
|
|
109
|
+
/** A `comments` row — only the fields readAssignedBrief touches. */
|
|
110
|
+
export interface CommentRow {
|
|
111
|
+
content: string;
|
|
112
|
+
created_at: Date;
|
|
113
|
+
}
|
|
106
114
|
/**
|
|
107
115
|
* A dispatched run already points at its fleet_tasks row. Task-sync may fill
|
|
108
116
|
* the convenience join for an adopted lane, but must never replace that
|
|
@@ -285,6 +293,32 @@ export declare const taskSyncRouter: {
|
|
|
285
293
|
task_title: string;
|
|
286
294
|
subtasks: TaskListItem[];
|
|
287
295
|
}>, Record<never, never>, Record<never, never>>;
|
|
296
|
+
/**
|
|
297
|
+
* DB→AGENT self-brief leg. A spawned lane PULLS its own brief — nothing is
|
|
298
|
+
* pasted, typed, passed as an argument or sent over a channel
|
|
299
|
+
* (`action-emission-architecture.md` "The lane PULLS its brief"). The lane's
|
|
300
|
+
* CLI extension reads its own session id on load, calls this procedure, and
|
|
301
|
+
* reads the brief from the task's LATEST comment. The comment — not the
|
|
302
|
+
* description, not a stale comment — is the brief for THIS run: every spawn
|
|
303
|
+
* writes a NEW comment, so "what was this lane told" always has exactly one
|
|
304
|
+
* answer (`assignment-as-dispatch`). Handler lives in task-sync-brief.ts
|
|
305
|
+
* (router is at its line budget); mounted here so fn.taskSync surfaces it.
|
|
306
|
+
*/
|
|
307
|
+
readAssignedBrief: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
308
|
+
headers: Headers;
|
|
309
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
|
|
310
|
+
prisma: any;
|
|
311
|
+
user: import("./auth-router.ts").User;
|
|
312
|
+
orgId: string | null | undefined;
|
|
313
|
+
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
314
|
+
work_item_id: string | null;
|
|
315
|
+
brief: string | null;
|
|
316
|
+
comment_created_at: string | null;
|
|
317
|
+
}, {
|
|
318
|
+
work_item_id: string | null;
|
|
319
|
+
brief: string | null;
|
|
320
|
+
comment_created_at: string | null;
|
|
321
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
288
322
|
/**
|
|
289
323
|
* DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
|
|
290
324
|
* change to a subtask's status (operator toggles it in the projects UI,
|
|
@@ -41,6 +41,7 @@ import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
|
41
41
|
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
|
+
import { readAssignedBriefHandler } from "./task-sync-brief.ts";
|
|
44
45
|
import * as v from "valibot";
|
|
45
46
|
import {
|
|
46
47
|
STATUS_TO_WORK_ITEM,
|
|
@@ -122,8 +123,21 @@ export type Prisma = {
|
|
|
122
123
|
update: (a: unknown) => Promise<WorkItem>;
|
|
123
124
|
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
|
124
125
|
};
|
|
126
|
+
// `comments` is a universal shared model (owned by @teamgbg/db), so every
|
|
127
|
+
// service with the db client carries it. Declared here rather than
|
|
128
|
+
// feature-detected because, unlike fleet_agent_runs (fleet-service-only), it
|
|
129
|
+
// is part of the shared client every consumer installs.
|
|
130
|
+
comments?: {
|
|
131
|
+
findFirst: (a: unknown) => Promise<CommentRow | null>;
|
|
132
|
+
};
|
|
125
133
|
};
|
|
126
134
|
|
|
135
|
+
/** A `comments` row — only the fields readAssignedBrief touches. */
|
|
136
|
+
export interface CommentRow {
|
|
137
|
+
content: string;
|
|
138
|
+
created_at: Date;
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
/**
|
|
128
142
|
* A dispatched run already points at its fleet_tasks row. Task-sync may fill
|
|
129
143
|
* the convenience join for an adopted lane, but must never replace that
|
|
@@ -900,6 +914,19 @@ export const taskSyncRouter = {
|
|
|
900
914
|
return { task_id: task.id, task_title: task.title, subtasks };
|
|
901
915
|
}),
|
|
902
916
|
|
|
917
|
+
/**
|
|
918
|
+
* DB→AGENT self-brief leg. A spawned lane PULLS its own brief — nothing is
|
|
919
|
+
* pasted, typed, passed as an argument or sent over a channel
|
|
920
|
+
* (`action-emission-architecture.md` "The lane PULLS its brief"). The lane's
|
|
921
|
+
* CLI extension reads its own session id on load, calls this procedure, and
|
|
922
|
+
* reads the brief from the task's LATEST comment. The comment — not the
|
|
923
|
+
* description, not a stale comment — is the brief for THIS run: every spawn
|
|
924
|
+
* writes a NEW comment, so "what was this lane told" always has exactly one
|
|
925
|
+
* answer (`assignment-as-dispatch`). Handler lives in task-sync-brief.ts
|
|
926
|
+
* (router is at its line budget); mounted here so fn.taskSync surfaces it.
|
|
927
|
+
*/
|
|
928
|
+
readAssignedBrief: readAssignedBriefHandler,
|
|
929
|
+
|
|
903
930
|
/**
|
|
904
931
|
* DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
|
|
905
932
|
* change to a subtask's status (operator toggles it in the projects UI,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SOURCE = readFileSync(
|
|
6
|
+
join(import.meta.dir, "singleton-tools-currency-watch.ts"),
|
|
7
|
+
"utf-8",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const CODE = SOURCE.split("\n")
|
|
11
|
+
.filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*"))
|
|
12
|
+
.join("\n");
|
|
13
|
+
|
|
14
|
+
describe("singleton-tools-currency-watch", () => {
|
|
15
|
+
test("reads version_source from config/scala-singleton-tools", () => {
|
|
16
|
+
expect(CODE).toContain("scala-singleton-tools");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("reinstalls stale tools", () => {
|
|
20
|
+
expect(CODE).toContain("reinstall");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("skips bare tools (bun) with a deferred event", () => {
|
|
24
|
+
expect(CODE).toContain("deferred-to-bun-stable-watch");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("records tick events for observability", () => {
|
|
28
|
+
expect(CODE).toContain("singleton-tools.currency.tick");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("records reinstall events", () => {
|
|
32
|
+
expect(CODE).toContain("singleton-tools.currency.reinstalled");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("records install-failed events", () => {
|
|
36
|
+
expect(CODE).toContain("singleton-tools.currency.install-failed");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("uses semver comparison (not string comparison)", () => {
|
|
40
|
+
expect(CODE).toContain("semverLt");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("handles unreadable installed version gracefully", () => {
|
|
44
|
+
expect(CODE).toContain("unreadable");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The `singleton-tools-currency-watch` pg-boss handler — keeps every tool
|
|
7
|
+
* declared in `config/scala-singleton-tools` at its `version_source` latest.
|
|
8
|
+
*
|
|
9
|
+
* Each tool entry now carries a `version_source` block:
|
|
10
|
+
* { type: "npm_package", name: "<pkg>", dist_tag: "latest" }
|
|
11
|
+
* The handler reads the row, checks each tool's installed version against the
|
|
12
|
+
* registry dist-tag, and reinstalls when stale. Reinstall strategy per kind:
|
|
13
|
+
* - "js" (oxlint): `bun add <pkg>@<dist_tag>` in the tool's `install_dir`
|
|
14
|
+
* - "elf" (tsgo/tsc): `bun add -g <pkg>@<dist_tag>` (global install)
|
|
15
|
+
* - "bare" (bun): SKIPPED — bun is a standalone ELF binary; its upgrade is
|
|
16
|
+
* owned by `bun-stable-watch` (which watches for 1.4.0 stable). This handler
|
|
17
|
+
* logs a skip event so the gap is observable, never silent.
|
|
18
|
+
*
|
|
19
|
+
* Boot-safety (protective-layers-fail-explicitly): every step is wrapped — a
|
|
20
|
+
* dist-tag fetch failure, version-parse failure, or install failure NEVER
|
|
21
|
+
* crashes the job or loga; each records a failure event + returns. Repeated
|
|
22
|
+
* reinstalls are prevented by strict semver-lt comparison.
|
|
23
|
+
*
|
|
24
|
+
* Registered under slug `@teamscala/service-boot:singleton-tools-currency-watch`.
|
|
25
|
+
*/
|
|
26
|
+
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
27
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
28
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
29
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
30
|
+
import { spawn } from "@teamscala/os/spawn";
|
|
31
|
+
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
32
|
+
|
|
33
|
+
interface VersionSource {
|
|
34
|
+
type: "npm_package";
|
|
35
|
+
name: string;
|
|
36
|
+
dist_tag: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SingletonTool {
|
|
40
|
+
name: string;
|
|
41
|
+
kind: "elf" | "bare" | "js";
|
|
42
|
+
real_path: string;
|
|
43
|
+
lock_name?: string;
|
|
44
|
+
install_dir?: string;
|
|
45
|
+
version_source?: VersionSource;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SingletonToolsConfig {
|
|
49
|
+
tools: SingletonTool[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SemverParts {
|
|
53
|
+
major: number;
|
|
54
|
+
minor: number;
|
|
55
|
+
patch: number;
|
|
56
|
+
/** Pre-release suffix, e.g. "dev.20260620.1" or "" for stable. */
|
|
57
|
+
pre: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseSemver(v: string): SemverParts | null {
|
|
61
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
|
|
62
|
+
if (!m) return null;
|
|
63
|
+
return { major: Number(m[1]!), minor: Number(m[2]!), patch: Number(m[3]!), pre: m[4] ?? "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Compare two pre-release identifiers: "dev.20260707.2" > "dev.20260620.1". */
|
|
67
|
+
function preLt(a: string, b: string): boolean {
|
|
68
|
+
// Split on dots, compare each segment numerically if possible, else lexicographically.
|
|
69
|
+
const sa = a.split(".");
|
|
70
|
+
const sb = b.split(".");
|
|
71
|
+
for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
|
|
72
|
+
const na = Number(sa[i]);
|
|
73
|
+
const nb = Number(sb[i]);
|
|
74
|
+
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
|
|
75
|
+
if (na !== nb) return na < nb;
|
|
76
|
+
} else if (sa[i] !== sb[i]) {
|
|
77
|
+
return sa[i]! < sb[i]!;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return sa.length < sb.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function semverLt(a: string, b: string): boolean {
|
|
84
|
+
const pa = parseSemver(a);
|
|
85
|
+
const pb = parseSemver(b);
|
|
86
|
+
if (!pa || !pb) return false;
|
|
87
|
+
if (pa.major !== pb.major) return pa.major < pb.major;
|
|
88
|
+
if (pa.minor !== pb.minor) return pa.minor < pb.minor;
|
|
89
|
+
if (pa.patch !== pb.patch) return pa.patch < pb.patch;
|
|
90
|
+
// Same major.minor.patch — compare pre-release.
|
|
91
|
+
// No pre-release (stable) > any pre-release.
|
|
92
|
+
if (!pa.pre && !pb.pre) return false;
|
|
93
|
+
if (!pa.pre) return false; // a is stable, b is pre — a is newer
|
|
94
|
+
if (!pb.pre) return true; // a is pre, b is stable — a is older
|
|
95
|
+
return preLt(pa.pre, pb.pre);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the installed version from a tool's real_path (symlink-resolved binary). */
|
|
99
|
+
async function readInstalledVersion(tool: SingletonTool): Promise<string | null> {
|
|
100
|
+
try {
|
|
101
|
+
if (tool.kind === "js" && tool.install_dir) {
|
|
102
|
+
// JS tools: read the installed package's package.json
|
|
103
|
+
const pkgJsonPath = `${tool.install_dir}/node_modules/${tool.version_source!.name}/package.json`;
|
|
104
|
+
const pkg = await Bun.file(pkgJsonPath).json() as { version: string };
|
|
105
|
+
return pkg.version;
|
|
106
|
+
}
|
|
107
|
+
// ELF/bare: run --version on the binary
|
|
108
|
+
const result = await spawn({
|
|
109
|
+
name: `singleton-tools-currency:version-${tool.name}`,
|
|
110
|
+
command: [tool.real_path, "--version"],
|
|
111
|
+
timeoutMs: 15_000,
|
|
112
|
+
});
|
|
113
|
+
const out = (result.stdout ?? "").trim();
|
|
114
|
+
// tsgo prints "Version 7.0.0-dev.20260620.1" — extract the version
|
|
115
|
+
const vMatch = /(\d+\.\d+\.\d+(?:-dev\.\d+)?)/.exec(out);
|
|
116
|
+
return vMatch?.[1] ?? out;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Fetch the latest version from npm for a given package + dist-tag. */
|
|
123
|
+
async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
|
|
124
|
+
try {
|
|
125
|
+
// @teamscala/* packages use the internal dist-tag tool; public npm packages
|
|
126
|
+
// fetch directly from the npm registry (same pattern as bun-stable-watch).
|
|
127
|
+
if (pkg.startsWith("@teamscala/")) {
|
|
128
|
+
const result = await spawn({
|
|
129
|
+
name: "singleton-tools-currency:dist-tag",
|
|
130
|
+
command: ["scala-tools", "packages", "dist-tag", "get", pkg, distTag],
|
|
131
|
+
timeoutMs: 30_000,
|
|
132
|
+
});
|
|
133
|
+
const latest =
|
|
134
|
+
(result.stdout ?? "")
|
|
135
|
+
.split("\n")
|
|
136
|
+
.map((l) => l.trim())
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.pop() ?? "";
|
|
139
|
+
if (!latest || !parseSemver(latest)) return null;
|
|
140
|
+
return latest;
|
|
141
|
+
}
|
|
142
|
+
// Public npm package: fetch the dist-tag manifest directly.
|
|
143
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/${encodeURIComponent(distTag)}`;
|
|
144
|
+
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
145
|
+
if (!res.ok) return null;
|
|
146
|
+
const manifest = (await res.json()) as { version: string };
|
|
147
|
+
return manifest.version ?? null;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Reinstall a stale tool to the latest version. */
|
|
154
|
+
async function reinstallTool(tool: SingletonTool, latest: string): Promise<boolean> {
|
|
155
|
+
const log = getAppLogger();
|
|
156
|
+
const vs = tool.version_source!;
|
|
157
|
+
|
|
158
|
+
if (tool.kind === "js" && tool.install_dir) {
|
|
159
|
+
// JS tool: bun add in the install_dir
|
|
160
|
+
const result = await spawn({
|
|
161
|
+
name: `singleton-tools-currency:install-${tool.name}`,
|
|
162
|
+
command: ["bun", "add", `${vs.name}@${vs.dist_tag}`],
|
|
163
|
+
cwd: tool.install_dir,
|
|
164
|
+
timeoutMs: 300_000,
|
|
165
|
+
});
|
|
166
|
+
if ((result.exitCode ?? -1) !== 0) {
|
|
167
|
+
log.error(
|
|
168
|
+
`[singleton-tools-currency] ${tool.name} install failed (exit ${result.exitCode}): ${(result.stderr ?? "").slice(0, 200)}`,
|
|
169
|
+
);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (tool.kind === "elf") {
|
|
176
|
+
// ELF tool: bun add -g
|
|
177
|
+
const result = await spawn({
|
|
178
|
+
name: `singleton-tools-currency:install-${tool.name}`,
|
|
179
|
+
command: ["bun", "add", "-g", `${vs.name}@${vs.dist_tag}`],
|
|
180
|
+
timeoutMs: 300_000,
|
|
181
|
+
});
|
|
182
|
+
if ((result.exitCode ?? -1) !== 0) {
|
|
183
|
+
log.error(
|
|
184
|
+
`[singleton-tools-currency] ${tool.name} global install failed (exit ${result.exitCode}): ${(result.stderr ?? "").slice(0, 200)}`,
|
|
185
|
+
);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// bare (bun) — skip, owned by bun-stable-watch
|
|
192
|
+
log.info(`[singleton-tools-currency] ${tool.name} (bare) — skip, owned by bun-stable-watch`);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function singletonToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
|
|
197
|
+
const log = getAppLogger();
|
|
198
|
+
|
|
199
|
+
let config: SingletonToolsConfig;
|
|
200
|
+
try {
|
|
201
|
+
config = loadRegistryConfig("config", "scala-singleton-tools") as never as SingletonToolsConfig;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
log.warn(
|
|
204
|
+
`[singleton-tools-currency] cannot load config/scala-singleton-tools — skip: ${err instanceof Error ? err.message : String(err)}`,
|
|
205
|
+
);
|
|
206
|
+
recordEvent({
|
|
207
|
+
kind: "singleton-tools.currency.tick",
|
|
208
|
+
payload: { note: "config/scala-singleton-tools unreadable" },
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const tool of config.tools) {
|
|
214
|
+
const vs = tool.version_source;
|
|
215
|
+
if (!vs || vs.type !== "npm_package") {
|
|
216
|
+
// No version_source declared — skip (the tool is not yet inside the mechanism)
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (tool.kind === "bare") {
|
|
221
|
+
// bun: owned by bun-stable-watch; log a tick so the gap is observable
|
|
222
|
+
recordEvent({
|
|
223
|
+
kind: "singleton-tools.currency.tick",
|
|
224
|
+
payload: { tool: tool.name, status: "deferred-to-bun-stable-watch" },
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const installed = await readInstalledVersion(tool);
|
|
230
|
+
if (!installed) {
|
|
231
|
+
log.warn(`[singleton-tools-currency] ${tool.name}: cannot read installed version — skip`);
|
|
232
|
+
recordEvent({
|
|
233
|
+
kind: "singleton-tools.currency.tick",
|
|
234
|
+
payload: { tool: tool.name, installed: null, note: "unreadable" },
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const latest = await fetchLatestVersion(vs.name, vs.dist_tag);
|
|
240
|
+
if (!latest) {
|
|
241
|
+
log.warn(`[singleton-tools-currency] ${tool.name}: cannot fetch latest — skip`);
|
|
242
|
+
recordEvent({
|
|
243
|
+
kind: "singleton-tools.currency.tick",
|
|
244
|
+
payload: { tool: tool.name, installed, latest: null, note: "dist-tag unreadable" },
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!semverLt(installed, latest)) {
|
|
250
|
+
recordEvent({
|
|
251
|
+
kind: "singleton-tools.currency.tick",
|
|
252
|
+
payload: { tool: tool.name, installed, latest, status: "current" },
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// STALE — reinstall
|
|
258
|
+
log.warn(
|
|
259
|
+
`[singleton-tools-currency] ${tool.name} ${installed} < latest ${latest} — reinstalling`,
|
|
260
|
+
);
|
|
261
|
+
const ok = await reinstallTool(tool, latest);
|
|
262
|
+
if (ok) {
|
|
263
|
+
recordEvent({
|
|
264
|
+
kind: "singleton-tools.currency.reinstalled",
|
|
265
|
+
payload: { tool: tool.name, from: installed, to: latest },
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
recordEvent({
|
|
269
|
+
kind: "singleton-tools.currency.install-failed",
|
|
270
|
+
payload: { tool: tool.name, from: installed, to: latest },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pgBossHandlerRegistry.register(
|
|
277
|
+
"@teamscala/service-boot:singleton-tools-currency-watch",
|
|
278
|
+
singletonToolsCurrencyWatch,
|
|
279
|
+
);
|
package/src/pg-boss-mount.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ import "./handlers/bun-stable-watch.ts";
|
|
|
18
18
|
import "./handlers/whatsapp-liveness.ts";
|
|
19
19
|
import "./handlers/upstream-issue-sync.ts";
|
|
20
20
|
import "./handlers/scala-tools-currency-watch.ts";
|
|
21
|
+
import "./handlers/singleton-tools-currency-watch.ts";
|
|
21
22
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
22
23
|
import "./handlers/pane-outbox-drain.ts";
|
|
23
24
|
export interface PgBossMountContext {
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -31,6 +31,7 @@ import "./handlers/bun-stable-watch.ts";
|
|
|
31
31
|
import "./handlers/whatsapp-liveness.ts";
|
|
32
32
|
import "./handlers/upstream-issue-sync.ts";
|
|
33
33
|
import "./handlers/scala-tools-currency-watch.ts";
|
|
34
|
+
import "./handlers/singleton-tools-currency-watch.ts";
|
|
34
35
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
35
36
|
import "./handlers/pane-outbox-drain.ts";
|
|
36
37
|
|