@teamscala/service-boot 0.23.303 → 0.23.305

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.303",
9
+ "version": "0.23.305",
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
 
@@ -16,11 +16,17 @@
16
16
  * The rule, and why it is not "env always wins":
17
17
  *
18
18
  * Railway injects PORT and healthchecks that exact port, reporting the service
19
- * unavailable if it listens elsewhere — so where there is NO routing row (a
20
- * Railway-only backend), the env is the only truth and must win.
19
+ * unavailable if it listens elsewhere. So on Railway the injected PORT is
20
+ * authoritative period — a `service_install`/`service_routing` row carries the
21
+ * service's LOCAL dev port (e.g. portal 5200), and on Railway binding that
22
+ * instead of PORT means Railway probes a port nothing listens on and the deploy
23
+ * fails healthcheck. The registry-wins rule below exists to stop LOCAL port
24
+ * collisions (one host, many services); inside a Railway container there are no
25
+ * sibling services to collide with, so PORT must win. `isRailway` is the caller's
26
+ * assertion that it is booting inside Railway (from `RAILWAY_ENVIRONMENT`).
21
27
  *
22
- * But a `service_routing` row means this service has a DECLARED port on this
23
- * host. There, a PORT that disagrees is a polluted launcher environment, never
28
+ * Off Railway, a `service_routing`/`service_install` row means this service has
29
+ * a DECLARED port on this host. There, a PORT that disagrees is a polluted launcher environment, never
24
30
  * an intention, and obeying it binds someone else's port. That is not a
25
31
  * hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
26
32
  * while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
@@ -60,7 +66,10 @@ export interface PortResolution {
60
66
  * @param envPort parsed PORT env, or undefined when unset/unparseable
61
67
  * @param declaredPort `service_routing.local_port`, or undefined when the
62
68
  * service has no routing row (the Railway-only case)
69
+ * @param isRailway true when booting inside Railway (caller derives from
70
+ * `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
71
+ * regardless of the row, because Railway healthchecks it
63
72
  */
64
- export declare function resolveServicePort(envPort: number | undefined, declaredPort: number | undefined): PortResolution | {
73
+ export declare function resolveServicePort(envPort: number | undefined, declaredPort: number | undefined, isRailway?: boolean): PortResolution | {
65
74
  error: "no-port-source";
66
75
  };
@@ -95,4 +95,31 @@ describe("resolveServicePort", () => {
95
95
  });
96
96
  });
97
97
  });
98
+
99
+ describe("Railway: injected PORT is authoritative over the row's dev port", () => {
100
+ // The 2026-07-31 portal incident: portal declares its dev port 5200 in
101
+ // service_install, Railway injects PORT=8080 and healthchecks 8080. The
102
+ // registry-wins rule (correct on the shared dev host) made portal bind 5200
103
+ // on Railway, so Railway's healthcheck probed a dead port and the deploy
104
+ // failed — despite the service booting healthy. On Railway PORT must win.
105
+ test("a conflicting declared port does NOT override Railway's injected PORT", () => {
106
+ expect(resolveServicePort(8080, 5200, true)).toEqual({ port: 8080, source: "env" });
107
+ });
108
+
109
+ test("an agreeing declared port still resolves to env on Railway", () => {
110
+ expect(resolveServicePort(8080, 8080, true)).toEqual({ port: 8080, source: "env" });
111
+ });
112
+
113
+ test("Railway with no injected PORT falls back to the declared port", () => {
114
+ expect(resolveServicePort(undefined, 5200, true)).toEqual({ port: 5200, source: "service_routing" });
115
+ });
116
+
117
+ test("isRailway defaults to false: off Railway the registry still wins a conflict", () => {
118
+ expect(resolveServicePort(8080, 5200)).toEqual({
119
+ port: 5200,
120
+ source: "service_routing",
121
+ conflict: { envPort: 8080, declaredPort: 5200 },
122
+ });
123
+ });
124
+ });
98
125
  });
@@ -16,11 +16,17 @@
16
16
  * The rule, and why it is not "env always wins":
17
17
  *
18
18
  * Railway injects PORT and healthchecks that exact port, reporting the service
19
- * unavailable if it listens elsewhere — so where there is NO routing row (a
20
- * Railway-only backend), the env is the only truth and must win.
19
+ * unavailable if it listens elsewhere. So on Railway the injected PORT is
20
+ * authoritative period — a `service_install`/`service_routing` row carries the
21
+ * service's LOCAL dev port (e.g. portal 5200), and on Railway binding that
22
+ * instead of PORT means Railway probes a port nothing listens on and the deploy
23
+ * fails healthcheck. The registry-wins rule below exists to stop LOCAL port
24
+ * collisions (one host, many services); inside a Railway container there are no
25
+ * sibling services to collide with, so PORT must win. `isRailway` is the caller's
26
+ * assertion that it is booting inside Railway (from `RAILWAY_ENVIRONMENT`).
21
27
  *
22
- * But a `service_routing` row means this service has a DECLARED port on this
23
- * host. There, a PORT that disagrees is a polluted launcher environment, never
28
+ * Off Railway, a `service_routing`/`service_install` row means this service has
29
+ * a DECLARED port on this host. There, a PORT that disagrees is a polluted launcher environment, never
24
30
  * an intention, and obeying it binds someone else's port. That is not a
25
31
  * hypothetical: mcp-ai-chat-system booted onto 3021 — session-picker's port —
26
32
  * while its own MCP_SERVER_URL still pointed at 8080. Both processes bound 3021,
@@ -63,14 +69,26 @@ function isBindablePort(value: number | undefined): value is number {
63
69
  * @param envPort parsed PORT env, or undefined when unset/unparseable
64
70
  * @param declaredPort `service_routing.local_port`, or undefined when the
65
71
  * service has no routing row (the Railway-only case)
72
+ * @param isRailway true when booting inside Railway (caller derives from
73
+ * `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
74
+ * regardless of the row, because Railway healthchecks it
66
75
  */
67
76
  export function resolveServicePort(
68
77
  envPort: number | undefined,
69
78
  declaredPort: number | undefined,
79
+ isRailway = false,
70
80
  ): PortResolution | { error: "no-port-source" } {
71
81
  const envUsable = isBindablePort(envPort);
72
82
  const declaredUsable = isBindablePort(declaredPort);
73
83
 
84
+ // On Railway the injected PORT is authoritative: Railway healthchecks that
85
+ // exact port, and the declared port is the service's LOCAL dev port — binding
86
+ // it makes the healthcheck probe a port nothing listens on. There are no
87
+ // sibling services to collide with inside a container, so PORT wins.
88
+ if (isRailway && envUsable) {
89
+ return { port: envPort, source: "env" };
90
+ }
91
+
74
92
  // No routing row: Railway's injected PORT is the only truth available.
75
93
  if (!declaredUsable) {
76
94
  return envUsable ? { port: envPort, source: "env" } : { error: "no-port-source" };
package/src/ui-service.ts CHANGED
@@ -74,9 +74,13 @@ export async function bootUiService(
74
74
  //
75
75
  // This branch previously took `process.env.PORT` unconditionally, which is
76
76
  // how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
77
- // leaked env and silently split that daemon's traffic (2026-07-31). Railway
78
- // is unaffected by construction: it injects PORT and a Railway-only service
79
- // has no declared port, so the env still wins there.
77
+ // leaked env and silently split that daemon's traffic (2026-07-31). On the
78
+ // dev host the registry wins a conflict (below). On Railway the injected PORT
79
+ // wins instead — Railway healthchecks exactly that port, and the row's port
80
+ // is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
81
+ // makes the healthcheck probe a dead port and the deploy fails (`isRailway`
82
+ // path in the resolver).
83
+ const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
80
84
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
81
85
  // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
82
86
  // services `port` is the ACTIVATOR's ingress (what callers resolve), not the
@@ -85,7 +89,7 @@ export async function bootUiService(
85
89
  // bind the activator's port → SO_REUSEPORT silent load-balancing (the
86
90
  // service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
87
91
  const declaredAppPort = config.app_port ?? config.port;
88
- const resolution = resolveServicePort(envPort, declaredAppPort);
92
+ const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
89
93
  if ("error" in resolution) {
90
94
  throw new BootFatalError(
91
95
  "ui-service.port-resolution",