@teamscala/service-boot 0.23.302 → 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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.302",
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
+ );
@@ -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 {
@@ -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
 
@@ -12,7 +12,7 @@
12
12
  * built in the background and atomically swapped in. This eliminates the
13
13
  * ~25s channel outage window during scala-agents restart.
14
14
  */
15
- import { Hono } from "hono";
15
+ import { Hono, type Context } from "hono";
16
16
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
17
17
  import { mountServerPackages } from "./mount-server-packages.ts";
18
18
  import type { PageServerDeps } from "./ui-service.ts";
@@ -28,6 +28,49 @@ export declare function getPageHandlerState(): {
28
28
  ready: boolean;
29
29
  hasHandler: boolean;
30
30
  };
31
+ /**
32
+ * `/health` for a UI service — 200 ONLY once the full app can actually render
33
+ * a page, 503 before that.
34
+ *
35
+ * This endpoint is not decoration: it is the wake gate for every scale-to-zero
36
+ * app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
37
+ * connection through a cold wake and pipes it the instant `isHealthy(port)` —
38
+ * a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
39
+ * on-demand` means by "`/health`-gates the wake (the client never sees a boot
40
+ * 503)".
41
+ *
42
+ * It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
43
+ * serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
44
+ * surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
45
+ * handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
46
+ * `/health` reported ready while every page 503'd, the activator believed the
47
+ * wake had landed, and it piped the customer's first request straight into that
48
+ * window — the customer got the 503.
49
+ *
50
+ * Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
51
+ * scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
52
+ * — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
53
+ * such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
54
+ * placeholder, which does no render work). scala-public logged ZERO, which is
55
+ * the whole reason it looked healthy while the other two did not: the gap is a
56
+ * RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
57
+ * not a config difference between the apps.
58
+ *
59
+ * Gating this on `fullAppReady` makes the signal honest for every consumer at
60
+ * once (the activator's wake gate, `poller-post-restart-health-gate`, any
61
+ * health-gated router) rather than teaching each one its own probe, and it is
62
+ * what `readiness-gated-boot` already requires: "/health returns 503 until
63
+ * ready, 200 after — so it is the real readiness signal", a service being
64
+ * "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
65
+ * is not ready, per `liveness-by-capability-not-process-existence` — liveness is
66
+ * a probe of the capability being REQUESTED, never of process existence or of
67
+ * some other surface that happens to answer.
68
+ *
69
+ * The early readiness-gate flip is deliberately left alone: it gates the ORPC
70
+ * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
71
+ * one consumer contract is "can you serve me a page", is moved to phase 2.
72
+ */
73
+ export declare function healthHandler(c: Context): Response;
31
74
  export interface MinimalAppResult {
32
75
  app: Hono;
33
76
  mountRegistry: Awaited<ReturnType<typeof mountServerPackages>>;
@@ -17,7 +17,7 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
17
17
  import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
18
18
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
19
  import { createTimer } from "@teamscala/timing/create-timer";
20
- import { Hono } from "hono";
20
+ import { Hono, type Context } from "hono";
21
21
  import { compress } from "hono/compress";
22
22
  import { cors } from "hono/cors";
23
23
  import { timing } from "hono/timing";
@@ -77,6 +77,54 @@ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
77
77
  return { ready: fullAppReady, hasHandler: realPageHandler !== null };
78
78
  }
79
79
 
80
+ /**
81
+ * `/health` for a UI service — 200 ONLY once the full app can actually render
82
+ * a page, 503 before that.
83
+ *
84
+ * This endpoint is not decoration: it is the wake gate for every scale-to-zero
85
+ * app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
86
+ * connection through a cold wake and pipes it the instant `isHealthy(port)` —
87
+ * a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
88
+ * on-demand` means by "`/health`-gates the wake (the client never sees a boot
89
+ * 503)".
90
+ *
91
+ * It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
92
+ * serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
93
+ * surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
94
+ * handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
95
+ * `/health` reported ready while every page 503'd, the activator believed the
96
+ * wake had landed, and it piped the customer's first request straight into that
97
+ * window — the customer got the 503.
98
+ *
99
+ * Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
100
+ * scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
101
+ * — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
102
+ * such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
103
+ * placeholder, which does no render work). scala-public logged ZERO, which is
104
+ * the whole reason it looked healthy while the other two did not: the gap is a
105
+ * RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
106
+ * not a config difference between the apps.
107
+ *
108
+ * Gating this on `fullAppReady` makes the signal honest for every consumer at
109
+ * once (the activator's wake gate, `poller-post-restart-health-gate`, any
110
+ * health-gated router) rather than teaching each one its own probe, and it is
111
+ * what `readiness-gated-boot` already requires: "/health returns 503 until
112
+ * ready, 200 after — so it is the real readiness signal", a service being
113
+ * "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
114
+ * is not ready, per `liveness-by-capability-not-process-existence` — liveness is
115
+ * a probe of the capability being REQUESTED, never of process existence or of
116
+ * some other surface that happens to answer.
117
+ *
118
+ * The early readiness-gate flip is deliberately left alone: it gates the ORPC
119
+ * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
120
+ * one consumer contract is "can you serve me a page", is moved to phase 2.
121
+ */
122
+ export function healthHandler(c: Context): Response {
123
+ return fullAppReady
124
+ ? c.json({ status: "ok" })
125
+ : c.json({ status: "starting" }, 503, { "Retry-After": "1" });
126
+ }
127
+
80
128
  export interface MinimalAppResult {
81
129
  app: Hono;
82
130
  mountRegistry: Awaited<ReturnType<typeof mountServerPackages>>;
@@ -161,7 +209,7 @@ export async function buildMinimalApp(
161
209
 
162
210
  // Core middleware
163
211
  app.use("*", readinessGate);
164
- app.get("/health", (c) => c.json({ status: "ok" }));
212
+ app.get("/health", healthHandler);
165
213
  app.use("*", injectSecurityHeaders);
166
214
  const cookieDomain = await getCookieDomain();
167
215
  app.use(
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The wake gate for every scale-to-zero app: `/health` must not report ready
7
+ * while the service still 503s pages.
8
+ *
9
+ * Exercised through a real Hono request against the REAL exported handler, not
10
+ * asserted against source text — `proxy-is-not-behaviour`: a source grep proves
11
+ * a line exists, never that the endpoint answers 503. This test fails if anyone
12
+ * restores the flat `{status:"ok"}`, which is the regression it locks.
13
+ *
14
+ * THE MEASURED OUTAGE (2026-07-31, quiz-dev + sign-dev returning 503 to
15
+ * customers). `scala-activator` gates a cold wake on `isHealthy(port)` — a
16
+ * plain `GET /health` — then pipes the held client connection the moment it
17
+ * returns ok. `/health` answered ok as soon as the MINIMAL app served, but the
18
+ * phase-1 page handler returns 503 until the phase-2 swap, so the activator
19
+ * piped the customer's first request into that window:
20
+ *
21
+ * 05:58:25.446 activator: awake scala-quiz (4227ms)
22
+ * 05:58:25.452 scala-quiz: GET / 503 3ms <- the customer's request
23
+ * 05:58:26.456 scala-quiz: Swapped from minimal app to full app
24
+ *
25
+ * Nine such 503s on scala-quiz, two on scala-sign, zero on scala-public — the
26
+ * gap is a race whose width is the phase-1→phase-2 build time (1420ms cold,
27
+ * 137ms warm), which is why one app looked healthy and the others did not.
28
+ */
29
+
30
+ import { describe, expect, test } from "bun:test";
31
+ import { Hono } from "hono";
32
+ import { healthHandler, setPageHandler, getPageHandlerState } from "./two-phase-boot.ts";
33
+
34
+ /** Mount the real handler exactly as buildMinimalApp does. */
35
+ const app = new Hono().get("/health", healthHandler);
36
+
37
+ /*
38
+ * Ordered, and deliberately so: `fullAppReady` is module state that only ever
39
+ * transitions false -> true (via setPageHandler at the phase-2 swap), so the
40
+ * pre-swap assertion has to run before the swap is simulated. There is no reset
41
+ * hook and adding one would exist only for tests.
42
+ */
43
+ describe("/health is gated on page-serving readiness", () => {
44
+ test("before the phase-2 swap it reports 503, not ready", async () => {
45
+ expect(getPageHandlerState().ready).toBe(false);
46
+
47
+ const res = await app.request("/health");
48
+
49
+ // The activator treats any non-ok as "not awake yet" and keeps holding
50
+ // the client connection instead of piping it into a 503.
51
+ expect(res.status).toBe(503);
52
+ expect(res.ok).toBe(false);
53
+ expect(await res.json()).toEqual({ status: "starting" });
54
+ // Retry-After is the contract readiness-gated-boot specifies alongside
55
+ // the 503, so a caller backs off rather than hot-looping the wake.
56
+ expect(res.headers.get("Retry-After")).toBe("1");
57
+ });
58
+
59
+ test("after the phase-2 swap it reports 200 ok", async () => {
60
+ // Simulate the phase-2 swap: this is the real call ui-service.ts makes
61
+ // via buildFullApp, and it is what flips fullAppReady.
62
+ setPageHandler(() => async () => new Response("page"));
63
+ expect(getPageHandlerState().ready).toBe(true);
64
+
65
+ const res = await app.request("/health");
66
+
67
+ expect(res.status).toBe(200);
68
+ expect(res.ok).toBe(true);
69
+ expect(await res.json()).toEqual({ status: "ok" });
70
+ });
71
+ });