@teamscala/service-boot 0.23.327 → 0.23.328

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.
Files changed (162) hide show
  1. package/package.json +524 -532
  2. package/src/agreement-gate.ts +12 -3
  3. package/src/auth-gate.test.ts +70 -0
  4. package/src/auth-gate.ts +68 -6
  5. package/src/build-client-assets.test.ts +1 -1
  6. package/src/build-client-assets.ts +1 -1
  7. package/src/discover-blocks.ts +1 -1
  8. package/src/fn-routers/index.ts +2 -0
  9. package/src/fn-routers/task-sync-assign.test.ts +2 -1
  10. package/src/fn-routers/task-sync-assign.ts +4 -4
  11. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  12. package/src/fn-routers/task-sync-brief.test.ts +20 -17
  13. package/src/fn-routers/task-sync-brief.ts +15 -14
  14. package/src/fn-routers/task-sync-close.test.ts +44 -0
  15. package/src/fn-routers/task-sync-close.ts +82 -0
  16. package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
  17. package/src/fn-routers/task-sync-lane.test.ts +36 -0
  18. package/src/fn-routers/task-sync-projection.test.ts +26 -0
  19. package/src/fn-routers/task-sync-projection.ts +51 -0
  20. package/src/fn-routers/task-sync-router.test.ts +14 -2
  21. package/src/handlers/singleton-tools-currency-watch.ts +114 -11
  22. package/src/handlers/sweep-host-cache.test.ts +133 -0
  23. package/src/handlers/sweep-host-cache.ts +143 -26
  24. package/src/install-logging.ts +3 -0
  25. package/src/mount-orpc-router.ts +1 -1
  26. package/src/register-page-routes.ts +0 -3
  27. package/src/resolve-brand-favicon.ts +1 -1
  28. package/src/resource-not-found.ts +10 -0
  29. package/src/serve/stamp-transport-identity.test.ts +24 -0
  30. package/src/serve/stamp-transport-identity.ts +14 -2
  31. package/src/two-phase-boot.ts +85 -17
  32. package/src/two-phase-health-gate-client-only.test.ts +77 -0
  33. package/src/two-phase-health-gate.test.ts +13 -2
  34. package/src/agreement-gate.d.ts +0 -19
  35. package/src/asset-cache-key.d.ts +0 -11
  36. package/src/asset-optimize.d.ts +0 -17
  37. package/src/asset-proxy.d.ts +0 -11
  38. package/src/asset-social-card.d.ts +0 -17
  39. package/src/attach-agreement-magic.d.ts +0 -20
  40. package/src/attach-portal-members.d.ts +0 -23
  41. package/src/attach-proposal-accept.d.ts +0 -26
  42. package/src/attach-proposal-magic.d.ts +0 -18
  43. package/src/attach-transfers-upload.d.ts +0 -27
  44. package/src/auth-gate.d.ts +0 -28
  45. package/src/bind-page-handler.d.ts +0 -18
  46. package/src/build-client-assets/input-hashing.d.ts +0 -25
  47. package/src/build-client-assets/source-candidates.d.ts +0 -27
  48. package/src/build-client-assets.d.ts +0 -50
  49. package/src/compose-page-title.d.ts +0 -41
  50. package/src/content-gate/access.d.ts +0 -15
  51. package/src/content-gate/config-types.d.ts +0 -99
  52. package/src/content-gate/gate.d.ts +0 -13
  53. package/src/content-gate/parse-cookie.d.ts +0 -9
  54. package/src/content-gate/token.d.ts +0 -48
  55. package/src/content-page-floor.d.ts +0 -39
  56. package/src/discover-blocks.d.ts +0 -28
  57. package/src/expand-roster.d.ts +0 -34
  58. package/src/fn-routers/agreement-completion-email.d.ts +0 -14
  59. package/src/fn-routers/agreement-send-router.d.ts +0 -82
  60. package/src/fn-routers/ai-health-router.d.ts +0 -35
  61. package/src/fn-routers/auth-router.d.ts +0 -47
  62. package/src/fn-routers/cache-admin-router.d.ts +0 -57
  63. package/src/fn-routers/chat-session-router.d.ts +0 -219
  64. package/src/fn-routers/email-defaults.d.ts +0 -1
  65. package/src/fn-routers/email-link-ttl.d.ts +0 -1
  66. package/src/fn-routers/index.d.ts +0 -18
  67. package/src/fn-routers/panel-data-router.d.ts +0 -19
  68. package/src/fn-routers/persist-chat-messages.d.ts +0 -36
  69. package/src/fn-routers/plans-router.d.ts +0 -25
  70. package/src/fn-routers/proposal-send-router.d.ts +0 -58
  71. package/src/fn-routers/public-agreement-router.d.ts +0 -289
  72. package/src/fn-routers/public-assessment-router.d.ts +0 -68
  73. package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
  74. package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
  75. package/src/fn-routers/task-sync-assign.d.ts +0 -305
  76. package/src/fn-routers/task-sync-brief.d.ts +0 -96
  77. package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
  78. package/src/fn-routers/task-sync-dispatch.ts +0 -124
  79. package/src/fn-routers/task-sync-lane.d.ts +0 -49
  80. package/src/fn-routers/task-sync-lane.ts +0 -125
  81. package/src/fn-routers/task-sync-projection.d.ts +0 -121
  82. package/src/fn-routers/task-sync-rename.d.ts +0 -91
  83. package/src/fn-routers/task-sync-router.d.ts +0 -424
  84. package/src/fn-routers/task-sync-router.ts +0 -1106
  85. package/src/fn-routers/tasks-router.d.ts +0 -25
  86. package/src/fn-routers/user-preferences-router.d.ts +0 -77
  87. package/src/fn-routers/work-items-router.d.ts +0 -102
  88. package/src/gate-ttls.d.ts +0 -6
  89. package/src/gpt-gate.d.ts +0 -25
  90. package/src/handlers/bun-stable-watch.d.ts +0 -1
  91. package/src/handlers/pane-outbox-drain.d.ts +0 -1
  92. package/src/handlers/projects-digest.d.ts +0 -26
  93. package/src/handlers/prune-stale-data.d.ts +0 -30
  94. package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
  95. package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
  96. package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
  97. package/src/handlers/sweep-event-log.d.ts +0 -1
  98. package/src/handlers/sweep-host-cache.d.ts +0 -1
  99. package/src/handlers/upstream-issue-sync.d.ts +0 -1
  100. package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
  101. package/src/handlers/whatsapp-liveness.d.ts +0 -4
  102. package/src/hono-error-handler.d.ts +0 -25
  103. package/src/install-harness.d.ts +0 -45
  104. package/src/install-logging.d.ts +0 -34
  105. package/src/load-service-install-row.d.ts +0 -24
  106. package/src/load-service-prisma-client.d.ts +0 -18
  107. package/src/mount-orpc-router.d.ts +0 -15
  108. package/src/mount-server-packages.d.ts +0 -29
  109. package/src/mount.d.ts +0 -9
  110. package/src/mutable-app-ref.d.ts +0 -44
  111. package/src/page-route-shell-types.d.ts +0 -12
  112. package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
  113. package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
  114. package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
  115. package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
  116. package/src/page-server-orpc/server-client.d.ts +0 -16
  117. package/src/page-server-orpc/templates-router.d.ts +0 -34
  118. package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
  119. package/src/pg-boss-handler-registry.d.ts +0 -27
  120. package/src/pg-boss-mount.d.ts +0 -42
  121. package/src/proposal-authorized-emails.d.ts +0 -28
  122. package/src/proposal-chat-inject.d.ts +0 -17
  123. package/src/proposal-gate.d.ts +0 -18
  124. package/src/read-html-template.d.ts +0 -38
  125. package/src/read-viewport-from-request.d.ts +0 -18
  126. package/src/readiness-gate.d.ts +0 -16
  127. package/src/register-api-routes.d.ts +0 -18
  128. package/src/register-crash-handlers.d.ts +0 -15
  129. package/src/register-page-routes.d.ts +0 -27
  130. package/src/register-page-server-client.d.ts +0 -11
  131. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
  132. package/src/register-registry-page-route/graph-cache.d.ts +0 -30
  133. package/src/register-registry-page-route/meta-title.d.ts +0 -2
  134. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
  135. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
  136. package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
  137. package/src/register-registry-page-route.d.ts +0 -27
  138. package/src/register-static-assets.d.ts +0 -13
  139. package/src/register-ws-routes.d.ts +0 -28
  140. package/src/resolve-app-base-url.d.ts +0 -13
  141. package/src/resolve-brand-favicon.d.ts +0 -28
  142. package/src/resolve-from-loader-config.d.ts +0 -32
  143. package/src/resolve-service-context.d.ts +0 -17
  144. package/src/resolve-service-port.d.ts +0 -75
  145. package/src/resolve-theme-mode.d.ts +0 -42
  146. package/src/resolvers/fetch-org-brand.d.ts +0 -2
  147. package/src/resolvers/portal-route-paths.d.ts +0 -39
  148. package/src/resolvers/website-by-host-map.d.ts +0 -12
  149. package/src/resource-chat-inject.d.ts +0 -46
  150. package/src/resource-not-found.d.ts +0 -60
  151. package/src/run-boot-hook-list.d.ts +0 -19
  152. package/src/run-boot-hooks.d.ts +0 -31
  153. package/src/scala-tools-currency-watchdog.d.ts +0 -10
  154. package/src/security-headers.d.ts +0 -12
  155. package/src/serve/serve.d.ts +0 -46
  156. package/src/serve/stamp-transport-identity.d.ts +0 -32
  157. package/src/server-error-page.d.ts +0 -29
  158. package/src/start-scheduled-jobs.d.ts +0 -18
  159. package/src/two-phase-boot.d.ts +0 -136
  160. package/src/types.d.ts +0 -46
  161. package/src/ui-service.d.ts +0 -46
  162. package/src/ws-boot-self-test.d.ts +0 -19
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane-close write-through for task-sync. The session-owning fleet runtime
7
+ * calls this ORPC procedure before releasing an ACP lane, so work_items remains
8
+ * the durable lifecycle source without a second raw-SQL writer in the host.
9
+ */
10
+
11
+ import { ORPCError } from "@orpc/server";
12
+ import * as v from "valibot";
13
+ import { authed, type Context } from "./auth-router.ts";
14
+ import type { Prisma, WorkItem } from "./task-sync-router.ts";
15
+
16
+ export const closeLaneInput = v.object({
17
+ lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
18
+ disposition: v.picklist([
19
+ "completed",
20
+ "needs_retry",
21
+ "failed",
22
+ "orchestrator_closed",
23
+ "orphaned",
24
+ ]),
25
+ });
26
+
27
+ export interface ClosedTaskState {
28
+ task_id: string;
29
+ task_status: "done" | "archived";
30
+ completed: boolean;
31
+ kind_data: Record<string, unknown>;
32
+ }
33
+
34
+ export function closedTaskState(
35
+ task: Pick<WorkItem, "id" | "kind_data">,
36
+ disposition: v.InferOutput<typeof closeLaneInput>["disposition"],
37
+ ): ClosedTaskState {
38
+ const completed = disposition === "completed";
39
+ const lane =
40
+ task.kind_data.lane && typeof task.kind_data.lane === "object"
41
+ ? (task.kind_data.lane as Record<string, unknown>)
42
+ : {};
43
+ return {
44
+ task_id: task.id,
45
+ task_status: completed ? "done" : "archived",
46
+ completed,
47
+ kind_data: { ...task.kind_data, lane: { ...lane, live: false } },
48
+ };
49
+ }
50
+
51
+ export const closeLaneHandler = authed
52
+ .input(closeLaneInput)
53
+ .handler(async ({ input, context }) => {
54
+ const prisma = (context as Context).prisma as Prisma;
55
+ const task = await prisma.work_items.findFirst({
56
+ where: {
57
+ kind: "task",
58
+ kind_data: { path: ["lane", "session"], equals: input.lane_session },
59
+ },
60
+ orderBy: { created_at: "desc" },
61
+ });
62
+ if (!task) {
63
+ throw new ORPCError("NOT_FOUND", {
64
+ message: `No task is bound to ACP lane ${input.lane_session}`,
65
+ });
66
+ }
67
+ const state = closedTaskState(task, input.disposition);
68
+ await prisma.work_items.update({
69
+ where: { id: task.id },
70
+ data: {
71
+ kind_data: state.kind_data,
72
+ status: state.task_status,
73
+ completed: state.completed,
74
+ },
75
+ select: { id: true },
76
+ });
77
+ return {
78
+ task_id: state.task_id,
79
+ task_status: state.task_status,
80
+ reconciled: true,
81
+ };
82
+ });
@@ -3,42 +3,60 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Unit tests for the pure event-payload builder in task-sync-dispatch. The HTTP
7
- * POST + registry resolution are side-effectful (covered end-to-end by the
8
- * DB->AGENT direction demo — a published orchestrator.message with slotName
9
- * cross-pane delivers to the target pane's <channel> tag); buildTaskChangedEvent
10
- * is pure and tested here. Satisfies what-must-have-a-test / primitive-api-has-test.
6
+ * Unit tests for the pure durable comment-payload builder in task-sync-dispatch.
7
+ * The runtime path writes this payload through the canonical comments model.
11
8
  */
12
9
  import { describe, it, expect } from "bun:test";
13
- import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
10
+ import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
14
11
 
15
- describe("task-sync buildTaskChangedEvent", () => {
16
- it("is an orchestrator.message addressed to the owning lane pane", () => {
17
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
18
- expect(e.type).toBe("orchestrator.message");
19
- expect(e.orchestratorSessionId).toBe("tmux:%5");
20
- });
21
-
22
- it("uses slotName cross-pane so watchEvents bypasses the fleet-run-existence check", () => {
23
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
24
- expect(e.slotName).toBe("cross-pane");
25
- });
12
+ describe("task-sync buildTaskChangeComment", () => {
13
+ it("addresses the canonical task and runtime with no pane transport", () => {
14
+ const comment = buildTaskChangeComment("task-7", "session-5", "subtask-1:completed");
15
+ expect(comment).toMatchObject({
16
+ work_item_id: "task-7",
17
+ source_cli_session_id: "session-5",
18
+ sender_role: "system",
19
+ sender_name: "TaskSync",
20
+ });
21
+ expect(comment.system_key).toBe("task-sync:task-7:session-5:subtask-1:completed");
22
+ expect(comment.content).toContain("readTaskList");
23
+ expect(comment).not.toHaveProperty("pane");
24
+ });
26
25
 
27
- it("carries the task id in the message so the recipient can re-read just it", () => {
28
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
29
- expect(e.message).toContain("task-7");
30
- expect(e.message).toContain("readTaskList");
31
- });
32
-
33
- it("produces a single-line message (the <channel> tag renders it inline)", () => {
34
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
35
- expect(e.message.includes("\n")).toBe(false);
36
- });
26
+ it("writes one durable comment addressed by the active cli_session", async () => {
27
+ const calls: unknown[] = [];
28
+ const result = await notifyLaneOfTaskChange(
29
+ {
30
+ cli_sessions: { findFirst: async () => ({ id: "session-5" }) },
31
+ comments: {
32
+ upsert: async (args) => {
33
+ calls.push(args);
34
+ return { id: "comment-1" };
35
+ },
36
+ },
37
+ },
38
+ "task-7",
39
+ "subtask-1:completed",
40
+ );
41
+ expect(result).toMatchObject({ delivered: true, sessionId: "session-5", commentId: "comment-1" });
42
+ expect(calls[0]).toMatchObject({ create: { work_item_id: "task-7", source_cli_session_id: "session-5" } });
43
+ });
37
44
 
38
- it("stamps the system sender + a deterministic runId + the provided epoch-ms ts", () => {
39
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
40
- expect(e.fromOrchestratorId).toBe("task-sync");
41
- expect(e.runId).toBe("task-sync-task-7-1700000000000");
42
- expect(e.ts).toBe(1700000000000);
45
+ it("does not fabricate a pre-launch session or comment", async () => {
46
+ let writes = 0;
47
+ const result = await notifyLaneOfTaskChange(
48
+ {
49
+ cli_sessions: { findFirst: async () => null },
50
+ comments: {
51
+ upsert: async () => {
52
+ writes++;
53
+ return { id: "unexpected" };
54
+ },
55
+ },
56
+ },
57
+ "task-7",
58
+ );
59
+ expect(result).toEqual({ delivered: false, sessionId: null, commentId: null });
60
+ expect(writes).toBe(0);
61
+ });
43
62
  });
44
- });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavioral tests for canonical CLI-session lane resolution.
7
+ */
8
+ import { describe, expect, it } from "bun:test";
9
+ import { resolveLaneWorkName } from "./task-sync-lane.ts";
10
+
11
+ describe("resolveLaneWorkName", () => {
12
+ it("reads the visible label from the active cli_sessions route", async () => {
13
+ let query: unknown;
14
+ const name = await resolveLaneWorkName(
15
+ {
16
+ cli_sessions: {
17
+ findFirst: async (args: unknown) => {
18
+ query = args;
19
+ return { id: "session-1", label: "ZeroStack" };
20
+ },
21
+ },
22
+ } as never,
23
+ "tmux:%7",
24
+ );
25
+ expect(name).toBe("ZeroStack");
26
+ expect(query).toMatchObject({ where: { host_route: { path: ["pane_id"], equals: "tmux:%7" } } });
27
+ });
28
+
29
+ it("returns no unverified title when no active session owns the pane", async () => {
30
+ const name = await resolveLaneWorkName(
31
+ { cli_sessions: { findFirst: async () => null } } as never,
32
+ "tmux:%8",
33
+ );
34
+ expect(name).toBeNull();
35
+ });
36
+ });
@@ -12,6 +12,7 @@
12
12
  import { describe, expect, it } from "bun:test";
13
13
  import {
14
14
  STATUS_TO_WORK_ITEM,
15
+ deriveEvidenceProgress,
15
16
  normalizeStatus,
16
17
  planReconcile,
17
18
  resolveProjectedStatus,
@@ -29,6 +30,31 @@ describe("STATUS_TO_WORK_ITEM", () => {
29
30
  });
30
31
  });
31
32
 
33
+ describe("evidence-derived task progress", () => {
34
+ it("counts only required subtasks with independent Orchestrator acceptance", () => {
35
+ const out = deriveEvidenceProgress(
36
+ [
37
+ { id: "a", status: "done" },
38
+ { id: "b", status: "done" },
39
+ { id: "c", status: "todo", kind_data: { pm_progress: { required: false } } },
40
+ ],
41
+ [
42
+ { work_item_id: "a", sender_role: "orchestrator", system_key: "acceptance:a" },
43
+ { work_item_id: "b", sender_role: "worker", system_key: "acceptance:b" },
44
+ ],
45
+ );
46
+ expect(out).toEqual({ total: 2, accepted: 1, percentage: 50 });
47
+ });
48
+
49
+ it("does not treat prose, context, or done alone as evidence", () => {
50
+ expect(
51
+ deriveEvidenceProgress([{ id: "a", status: "done" }], [
52
+ { work_item_id: "a", sender_role: "orchestrator", system_key: "progress:100" },
53
+ ]),
54
+ ).toEqual({ total: 1, accepted: 0, percentage: 0 });
55
+ });
56
+ });
57
+
32
58
  describe("status translation is a single non-drifting source", () => {
33
59
  // The forward map (CLI → work_items) is canonical; the reverse is DERIVED from
34
60
  // it (+ a closed synonym set). This locks the structural fix: the two
@@ -74,6 +74,57 @@ export function normalizeStatus(s: string | null): TaskListItem["status"] {
74
74
  return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
75
75
  }
76
76
 
77
+ export interface ProgressSubtask {
78
+ id: string;
79
+ status: string | null;
80
+ kind_data?: Record<string, unknown> | null;
81
+ }
82
+
83
+ export interface ProgressAcceptanceEvidence {
84
+ work_item_id: string | null;
85
+ sender_role: string | null;
86
+ system_key: string | null;
87
+ }
88
+
89
+ export interface EvidenceProgress {
90
+ total: number;
91
+ accepted: number;
92
+ percentage: number;
93
+ }
94
+
95
+ /**
96
+ * Derive progress from the canonical checklist and independent acceptance
97
+ * comments. A done status without an Orchestrator acceptance comment is not
98
+ * evidence of completion; prose percentages and context gauges never enter
99
+ * this calculation. Subtasks are required by default, with an explicit
100
+ * `kind_data.pm_progress.required: false` opt-out for non-gating notes.
101
+ */
102
+ export function deriveEvidenceProgress(
103
+ subtasks: ProgressSubtask[],
104
+ evidence: ProgressAcceptanceEvidence[],
105
+ ): EvidenceProgress {
106
+ const required = subtasks.filter(
107
+ (s) => (s.kind_data?.pm_progress as { required?: boolean } | undefined)?.required !== false,
108
+ );
109
+ const accepted = new Set(
110
+ evidence
111
+ .filter(
112
+ (e) =>
113
+ e.sender_role === "orchestrator" &&
114
+ typeof e.system_key === "string" &&
115
+ e.system_key.startsWith("acceptance:") &&
116
+ e.work_item_id,
117
+ )
118
+ .map((e) => e.work_item_id as string),
119
+ );
120
+ const acceptedCount = required.filter((s) => s.status === "done" && accepted.has(s.id)).length;
121
+ return {
122
+ total: required.length,
123
+ accepted: acceptedCount,
124
+ percentage: required.length === 0 ? 0 : Math.round((acceptedCount / required.length) * 100),
125
+ };
126
+ }
127
+
77
128
  /**
78
129
  * Decide the status to WRITE for an existing row, given what the CLI list says.
79
130
  *
@@ -337,8 +337,20 @@ describe("a blocked row survives a CLI list that cannot express it", () => {
337
337
  it("planReconcile leaves a blocked subtask blocked when the list says pending", () => {
338
338
  // The end-to-end shape: this is what re-blocked the lane three times.
339
339
  const plan = planReconcile(
340
- [{ mark: "1", content: "Deploy the fix", status: "pending" }],
341
- [{ id: "row-1", title: "Deploy the fix", status: "blocked", completed: false, position: "0", kind_data: { task_sync: { mark: "1" } } }] as never,
340
+ [
341
+ {
342
+ id: "row-1",
343
+ kind: "subtask",
344
+ parent_id: "task-1",
345
+ organisation_id: "org-1",
346
+ title: "Deploy the fix",
347
+ status: "blocked",
348
+ position: "0",
349
+ completed: false,
350
+ kind_data: { task_sync: { mark: "1" } },
351
+ },
352
+ ],
353
+ [{ content: "Deploy the fix", status: "pending" }],
342
354
  "claude-code",
343
355
  );
344
356
  const update = plan.toUpdate.find((u) => u.id === "row-1");
@@ -30,11 +30,40 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
30
30
  import { spawn } from "@teamscala/os/spawn";
31
31
  import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
32
32
 
33
- interface VersionSource {
34
- type: "npm_package";
35
- name: string;
36
- dist_tag: string;
37
- }
33
+ /**
34
+ * Where a tool's "latest" comes from.
35
+ *
36
+ * A DISCRIMINATED UNION, deliberately, rather than an optional `repo` field
37
+ * hanging off one shape: an unhandled variant then fails to compile instead of
38
+ * silently taking the npm branch. The previous single-member form
39
+ * (`type: "npm_package"`) made a GitHub-released binary UNREPRESENTABLE, not
40
+ * merely unregistered — which is why codex and zerostack are absent from
41
+ * `config/scala-singleton-tools` entirely. Both are standalone ELF binaries in
42
+ * ~/.local/bin and neither is npm-managed, so neither could be described at all.
43
+ *
44
+ * Their LOCATION is correct and must not be "fixed": `bun-bin-is-unstable`
45
+ * designates ${HOME}/.local/bin as the home for operator CLIs precisely so they
46
+ * never shadow a /usr/local/bin singleton wrapper. The gap was currency
47
+ * tracking, never placement.
48
+ */
49
+ type VersionSource =
50
+ | {
51
+ type: "npm_package";
52
+ name: string;
53
+ dist_tag: string;
54
+ }
55
+ | {
56
+ /** A GitHub release asset — the shape codex and zerostack ship as. */
57
+ type: "github_release";
58
+ /** "owner/repo". */
59
+ repo: string;
60
+ /**
61
+ * Regex matched against each release asset's name to pick the download.
62
+ * Required: a repo publishing several platform builds has no safe default,
63
+ * and guessing one installs the wrong architecture silently.
64
+ */
65
+ asset_pattern: string;
66
+ };
38
67
 
39
68
  interface SingletonTool {
40
69
  name: string;
@@ -98,9 +127,13 @@ function semverLt(a: string, b: string): boolean {
98
127
  /** Read the installed version from a tool's real_path (symlink-resolved binary). */
99
128
  async function readInstalledVersion(tool: SingletonTool): Promise<string | null> {
100
129
  try {
101
- if (tool.kind === "js" && tool.install_dir) {
130
+ const vs = tool.version_source;
131
+ // The package.json path only exists for an npm-sourced JS tool. A
132
+ // github_release tool has no node_modules entry, so it falls through to the
133
+ // `--version` probe below like any other binary.
134
+ if (tool.kind === "js" && tool.install_dir && vs?.type === "npm_package") {
102
135
  // JS tools: read the installed package's package.json
103
- const pkgJsonPath = `${tool.install_dir}/node_modules/${tool.version_source!.name}/package.json`;
136
+ const pkgJsonPath = `${tool.install_dir}/node_modules/${vs.name}/package.json`;
104
137
  const pkg = await Bun.file(pkgJsonPath).json() as { version: string };
105
138
  return pkg.version;
106
139
  }
@@ -119,6 +152,30 @@ async function readInstalledVersion(tool: SingletonTool): Promise<string | null>
119
152
  }
120
153
  }
121
154
 
155
+ /**
156
+ * Latest published version for a GitHub-released tool.
157
+ *
158
+ * Reads the repo's latest release tag rather than enumerating assets, because
159
+ * the tag is what carries the VERSION; the asset pattern only decides which
160
+ * build to download at install time. A leading "v" is stripped so the value
161
+ * compares against `readInstalledVersion` output under the same semver parser.
162
+ */
163
+ async function fetchLatestGithubRelease(repo: string): Promise<string | null> {
164
+ try {
165
+ const res = await fetch(
166
+ `https://api.github.com/repos/${repo}/releases/latest`,
167
+ { headers: { accept: "application/vnd.github+json" } },
168
+ );
169
+ if (!res.ok) return null;
170
+ const release = (await res.json()) as { tag_name?: string };
171
+ const tag = (release.tag_name ?? "").trim().replace(/^v/, "");
172
+ if (!tag || !parseSemver(tag)) return null;
173
+ return tag;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
122
179
  /** Fetch the latest version from npm for a given package + dist-tag. */
123
180
  async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
124
181
  try {
@@ -153,7 +210,35 @@ async function fetchLatestVersion(pkg: string, distTag: string): Promise<string
153
210
  /** Reinstall a stale tool to the latest version. */
154
211
  async function reinstallTool(tool: SingletonTool, latest: string): Promise<boolean> {
155
212
  const log = getAppLogger();
156
- const vs = tool.version_source!;
213
+ const vs = tool.version_source;
214
+ if (!vs) return false;
215
+
216
+ if (vs.type === "github_release") {
217
+ // A GitHub-released binary is NOT installed by a package manager, so there
218
+ // is no "reinstall" verb to call — it would have to be downloaded, verified,
219
+ // made executable and swapped atomically over a file that may be RUNNING.
220
+ // That is a real install path with real failure modes (partial download,
221
+ // wrong architecture, replacing a binary mid-execution), and doing it badly
222
+ // is worse than not doing it: a half-written CLI is a broken CLI.
223
+ //
224
+ // So this reports the drift and stops. Detection is live from today; the
225
+ // automated swap is a separate, deliberate change. Reporting a version the
226
+ // watcher cannot actually install is honest — silently claiming currency
227
+ // would not be.
228
+ log.warn(
229
+ `[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
230
+ );
231
+ recordEvent({
232
+ kind: "singleton-tools.currency.tick",
233
+ payload: {
234
+ tool: tool.name,
235
+ latest,
236
+ status: "stale-manual-update-required",
237
+ repo: vs.repo,
238
+ },
239
+ });
240
+ return false;
241
+ }
157
242
 
158
243
  if (tool.kind === "js" && tool.install_dir) {
159
244
  // JS tool: bun add in the install_dir
@@ -212,8 +297,21 @@ async function singletonToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig })
212
297
 
213
298
  for (const tool of config.tools) {
214
299
  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)
300
+ if (!vs) {
301
+ // A tool with no version_source is NOT tracked, and that must be loud.
302
+ // This previously `continue`d silently as "not yet inside the mechanism",
303
+ // which is how a gap becomes permanent: nothing distinguishes a tool
304
+ // nobody has declared from one that is up to date, so the watcher reports
305
+ // a clean tick while the tool rots. That is the exact shape of
306
+ // `a-component-may-not-report-a-state-it-has-not-verified` — the run
307
+ // advertises coverage it never performed.
308
+ log.warn(
309
+ `[singleton-tools-currency] ${tool.name}: NO version_source — not tracked, cannot be kept current. Add one to config/scala-singleton-tools.`,
310
+ );
311
+ recordEvent({
312
+ kind: "singleton-tools.currency.tick",
313
+ payload: { tool: tool.name, status: "no-version-source" },
314
+ });
217
315
  continue;
218
316
  }
219
317
 
@@ -236,7 +334,12 @@ async function singletonToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig })
236
334
  continue;
237
335
  }
238
336
 
239
- const latest = await fetchLatestVersion(vs.name, vs.dist_tag);
337
+ // Branch on the discriminant, never on a field's presence — an unhandled
338
+ // variant is a compile error here rather than a silent fall-through to npm.
339
+ const latest =
340
+ vs.type === "npm_package"
341
+ ? await fetchLatestVersion(vs.name, vs.dist_tag)
342
+ : await fetchLatestGithubRelease(vs.repo);
240
343
  if (!latest) {
241
344
  log.warn(`[singleton-tools-currency] ${tool.name}: cannot fetch latest — skip`);
242
345
  recordEvent({
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Unit tests for the `retention_dirs` helpers (`expandGlob`, `checkDirStale`) —
7
+ * the whole-directory prune that bounds Rust `target/` waste. The file-by-mtime
8
+ * `retention` path is unchanged; these cover ONLY the new build-cache model:
9
+ * (1) glob expansion reaches every repo's target dir; (2) an actively-built dir
10
+ * is kept (short-circuits on a fresh file); (3) an abandoned dir is reported
11
+ * stale with its full size. Lives next to the handler per co-located-test rule.
12
+ */
13
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
14
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { expandGlob, checkDirStale, parseRootDiskPct } from "./sweep-host-cache.ts";
18
+
19
+ const DAY = 86_400_000;
20
+ const now = Date.now();
21
+ const daysAgo = (n: number) => new Date(now - n * DAY);
22
+ const touch = (p: string, t: Date) => utimesSync(p, t, t);
23
+
24
+ let root: string;
25
+ beforeEach(() => {
26
+ root = mkdtempSync(join(tmpdir(), "sweep-"));
27
+ });
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("expandGlob", () => {
33
+ test("expands a single-segment wildcard to concrete dirs", () => {
34
+ mkdirSync(join(root, "scala-rust", "target"), { recursive: true });
35
+ mkdirSync(join(root, "scala-os", "target"), { recursive: true });
36
+ mkdirSync(join(root, "scala-os", "src"), { recursive: true });
37
+ const matches = expandGlob(`${root}/*/target`).sort();
38
+ expect(matches).toEqual(
39
+ [join(root, "scala-os", "target"), join(root, "scala-rust", "target")].sort(),
40
+ );
41
+ });
42
+ test("literal segment matches only itself", () => {
43
+ mkdirSync(join(root, "scala-rust", "target"), { recursive: true });
44
+ mkdirSync(join(root, "other", "target"), { recursive: true });
45
+ const matches = expandGlob(`${root}/scala-rust/target`);
46
+ expect(matches).toEqual([join(root, "scala-rust", "target")]);
47
+ });
48
+ test("no matches when the wildcard segment has no children", () => {
49
+ const matches = expandGlob(`${root}/does-not-exist/*/target`);
50
+ expect(matches).toEqual([]);
51
+ });
52
+ });
53
+
54
+ describe("checkDirStale", () => {
55
+ test("stale dir: all files older than cutoff → stale + full size", async () => {
56
+ const dir = join(root, "target");
57
+ mkdirSync(join(dir, "debug", "deps"), { recursive: true });
58
+ writeFileSync(join(dir, "debug", "deps", "a.o"), "x".repeat(40));
59
+ writeFileSync(join(dir, "debug", "deps", "b.o"), "y".repeat(60));
60
+ touch(join(dir, "debug", "deps", "a.o"), daysAgo(40));
61
+ touch(join(dir, "debug", "deps", "b.o"), daysAgo(35));
62
+ const res = await checkDirStale(dir, now - 30 * DAY);
63
+ expect(res.stale).toBe(true);
64
+ expect(res.fileCount).toBe(2);
65
+ expect(res.bytes).toBe(100);
66
+ });
67
+ test("active dir: a file newer than cutoff → not stale (short-circuit)", async () => {
68
+ const dir = join(root, "target");
69
+ mkdirSync(join(dir, "debug"), { recursive: true });
70
+ writeFileSync(join(dir, "debug", "old.o"), "x".repeat(40));
71
+ touch(join(dir, "debug", "old.o"), daysAgo(40));
72
+ writeFileSync(join(dir, "debug", "fresh.o"), "y".repeat(60));
73
+ touch(join(dir, "debug", "fresh.o"), daysAgo(1));
74
+ const res = await checkDirStale(dir, now - 30 * DAY);
75
+ expect(res.stale).toBe(false);
76
+ });
77
+ test("empty dir → stale with zero bytes", async () => {
78
+ const dir = join(root, "target");
79
+ mkdirSync(dir, { recursive: true });
80
+ const res = await checkDirStale(dir, now - 30 * DAY);
81
+ expect(res.stale).toBe(true);
82
+ expect(res.bytes).toBe(0);
83
+ expect(res.fileCount).toBe(0);
84
+ });
85
+ });
86
+
87
+ describe("parseRootDiskPct", () => {
88
+ test("extracts root-fs Use% from df --output=pcent,target output", () => {
89
+ const df =
90
+ "Use% Mounted on\n" +
91
+ " 97% /\n" +
92
+ " 45% /home\n";
93
+ expect(parseRootDiskPct(df)).toBe(97);
94
+ });
95
+ test("picks the root mount (/) over nested mounts", () => {
96
+ const df =
97
+ "Use% Mounted on\n" +
98
+ " 10% /proc/x\n" +
99
+ " 76% /\n";
100
+ expect(parseRootDiskPct(df)).toBe(76);
101
+ });
102
+ test("returns null when no root-mount line present", () => {
103
+ const df = "Use% Mounted on\n 40% /mnt/other\n";
104
+ expect(parseRootDiskPct(df)).toBeNull();
105
+ });
106
+ test("returns null on empty / malformed output", () => {
107
+ expect(parseRootDiskPct("")).toBeNull();
108
+ expect(parseRootDiskPct("Use% Mounted on")).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe("disk-threshold early-warning regression lock", () => {
113
+ const { readFileSync } = require("node:fs");
114
+ // Source-shape lock (same pattern as singleton-tools-currency-watch.test.ts):
115
+ // the df-check that rides this periodic sweep was once removed in favour of an
116
+ // in-process interval watchdog — the wrong architecture (interval watchdogs
117
+ // are forbidden; observability is event-driven). This asserts the corrected
118
+ // shape survives: the sweep handler MUST carry the event-driven host.disk.high
119
+ // emission (logger.warn -> GlitchTip) + the pure parseRootDiskPct helper that
120
+ // backs it. Removing either fails this test. The asserted surface is the
121
+ // disk early-warning cell of host-resources-have-complete-monitoring.
122
+ const src = readFileSync(import.meta.dir + "/sweep-host-cache.ts", "utf-8");
123
+ test("handler emits the host.disk.high event (event-driven, not an interval)", () => {
124
+ expect(src).toContain('log.warn("host.disk.high"');
125
+ expect(src).toContain('kind: "host.disk.high"');
126
+ });
127
+ test("handler reads df via the sanctioned spawn (no in-process poller)", () => {
128
+ expect(src).toContain('command: ["df", "--output=pcent,target"]');
129
+ });
130
+ test("threshold parse is the exported pure helper", () => {
131
+ expect(src).toContain("export function parseRootDiskPct");
132
+ });
133
+ });