@teamscala/service-boot 0.23.317 → 0.23.319

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 (165) hide show
  1. package/package.json +526 -526
  2. package/src/agreement-gate.ts +12 -0
  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 +123 -8
  10. package/src/fn-routers/task-sync-assign.ts +105 -18
  11. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  12. package/src/fn-routers/task-sync-brief.test.ts +65 -22
  13. package/src/fn-routers/task-sync-brief.ts +77 -23
  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-hook-lifecycle.test.ts +204 -0
  18. package/src/fn-routers/task-sync-lane.test.ts +36 -0
  19. package/src/fn-routers/task-sync-projection.test.ts +26 -0
  20. package/src/fn-routers/task-sync-projection.ts +51 -0
  21. package/src/fn-routers/task-sync-rename.test.ts +53 -0
  22. package/src/fn-routers/task-sync-rename.ts +111 -0
  23. package/src/fn-routers/task-sync-router.test.ts +14 -2
  24. package/src/handlers/singleton-tools-currency-watch.ts +114 -11
  25. package/src/handlers/sweep-host-cache.test.ts +133 -0
  26. package/src/handlers/sweep-host-cache.ts +143 -26
  27. package/src/install-logging.ts +3 -0
  28. package/src/mount-orpc-router.ts +1 -1
  29. package/src/register-api-routes.ts +8 -2
  30. package/src/register-page-routes.ts +0 -3
  31. package/src/resolve-brand-favicon.ts +1 -1
  32. package/src/resource-not-found.ts +10 -0
  33. package/src/serve/stamp-transport-identity.test.ts +24 -0
  34. package/src/serve/stamp-transport-identity.ts +20 -7
  35. package/src/two-phase-boot.ts +85 -17
  36. package/src/two-phase-health-gate-client-only.test.ts +77 -0
  37. package/src/two-phase-health-gate.test.ts +13 -2
  38. package/src/agreement-gate.d.ts +0 -19
  39. package/src/asset-cache-key.d.ts +0 -11
  40. package/src/asset-optimize.d.ts +0 -17
  41. package/src/asset-proxy.d.ts +0 -11
  42. package/src/asset-social-card.d.ts +0 -17
  43. package/src/attach-agreement-magic.d.ts +0 -20
  44. package/src/attach-portal-members.d.ts +0 -23
  45. package/src/attach-proposal-accept.d.ts +0 -26
  46. package/src/attach-proposal-magic.d.ts +0 -18
  47. package/src/attach-transfers-upload.d.ts +0 -27
  48. package/src/auth-gate.d.ts +0 -28
  49. package/src/bind-page-handler.d.ts +0 -18
  50. package/src/build-client-assets/input-hashing.d.ts +0 -25
  51. package/src/build-client-assets/source-candidates.d.ts +0 -27
  52. package/src/build-client-assets.d.ts +0 -50
  53. package/src/compose-page-title.d.ts +0 -41
  54. package/src/content-gate/access.d.ts +0 -15
  55. package/src/content-gate/config-types.d.ts +0 -99
  56. package/src/content-gate/gate.d.ts +0 -13
  57. package/src/content-gate/parse-cookie.d.ts +0 -9
  58. package/src/content-gate/token.d.ts +0 -48
  59. package/src/content-page-floor.d.ts +0 -39
  60. package/src/discover-blocks.d.ts +0 -28
  61. package/src/expand-roster.d.ts +0 -34
  62. package/src/fn-routers/agreement-completion-email.d.ts +0 -14
  63. package/src/fn-routers/agreement-send-router.d.ts +0 -82
  64. package/src/fn-routers/ai-health-router.d.ts +0 -35
  65. package/src/fn-routers/auth-router.d.ts +0 -47
  66. package/src/fn-routers/cache-admin-router.d.ts +0 -57
  67. package/src/fn-routers/chat-session-router.d.ts +0 -219
  68. package/src/fn-routers/email-defaults.d.ts +0 -1
  69. package/src/fn-routers/email-link-ttl.d.ts +0 -1
  70. package/src/fn-routers/index.d.ts +0 -18
  71. package/src/fn-routers/panel-data-router.d.ts +0 -19
  72. package/src/fn-routers/persist-chat-messages.d.ts +0 -36
  73. package/src/fn-routers/plans-router.d.ts +0 -25
  74. package/src/fn-routers/proposal-send-router.d.ts +0 -58
  75. package/src/fn-routers/public-agreement-router.d.ts +0 -289
  76. package/src/fn-routers/public-assessment-router.d.ts +0 -68
  77. package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
  78. package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
  79. package/src/fn-routers/task-sync-assign.d.ts +0 -276
  80. package/src/fn-routers/task-sync-brief.d.ts +0 -69
  81. package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
  82. package/src/fn-routers/task-sync-dispatch.ts +0 -124
  83. package/src/fn-routers/task-sync-lane.d.ts +0 -28
  84. package/src/fn-routers/task-sync-lane.ts +0 -91
  85. package/src/fn-routers/task-sync-projection.d.ts +0 -121
  86. package/src/fn-routers/task-sync-router.d.ts +0 -375
  87. package/src/fn-routers/task-sync-router.ts +0 -1011
  88. package/src/fn-routers/tasks-router.d.ts +0 -25
  89. package/src/fn-routers/user-preferences-router.d.ts +0 -77
  90. package/src/fn-routers/work-items-router.d.ts +0 -102
  91. package/src/gate-ttls.d.ts +0 -6
  92. package/src/gpt-gate.d.ts +0 -25
  93. package/src/handlers/bun-stable-watch.d.ts +0 -1
  94. package/src/handlers/pane-outbox-drain.d.ts +0 -1
  95. package/src/handlers/projects-digest.d.ts +0 -26
  96. package/src/handlers/prune-stale-data.d.ts +0 -30
  97. package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
  98. package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
  99. package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
  100. package/src/handlers/sweep-event-log.d.ts +0 -1
  101. package/src/handlers/sweep-host-cache.d.ts +0 -1
  102. package/src/handlers/upstream-issue-sync.d.ts +0 -1
  103. package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
  104. package/src/handlers/whatsapp-liveness.d.ts +0 -4
  105. package/src/hono-error-handler.d.ts +0 -25
  106. package/src/install-harness.d.ts +0 -45
  107. package/src/install-logging.d.ts +0 -34
  108. package/src/load-service-install-row.d.ts +0 -24
  109. package/src/load-service-prisma-client.d.ts +0 -18
  110. package/src/mount-orpc-router.d.ts +0 -15
  111. package/src/mount-server-packages.d.ts +0 -29
  112. package/src/mount.d.ts +0 -9
  113. package/src/mutable-app-ref.d.ts +0 -44
  114. package/src/page-route-shell-types.d.ts +0 -12
  115. package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
  116. package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
  117. package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
  118. package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
  119. package/src/page-server-orpc/server-client.d.ts +0 -16
  120. package/src/page-server-orpc/templates-router.d.ts +0 -34
  121. package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
  122. package/src/pg-boss-handler-registry.d.ts +0 -27
  123. package/src/pg-boss-mount.d.ts +0 -42
  124. package/src/proposal-authorized-emails.d.ts +0 -28
  125. package/src/proposal-chat-inject.d.ts +0 -17
  126. package/src/proposal-gate.d.ts +0 -18
  127. package/src/read-html-template.d.ts +0 -38
  128. package/src/read-viewport-from-request.d.ts +0 -18
  129. package/src/readiness-gate.d.ts +0 -16
  130. package/src/register-api-routes.d.ts +0 -18
  131. package/src/register-crash-handlers.d.ts +0 -15
  132. package/src/register-page-routes.d.ts +0 -27
  133. package/src/register-page-server-client.d.ts +0 -11
  134. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
  135. package/src/register-registry-page-route/graph-cache.d.ts +0 -30
  136. package/src/register-registry-page-route/meta-title.d.ts +0 -2
  137. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
  138. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
  139. package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
  140. package/src/register-registry-page-route.d.ts +0 -27
  141. package/src/register-static-assets.d.ts +0 -13
  142. package/src/register-ws-routes.d.ts +0 -28
  143. package/src/resolve-app-base-url.d.ts +0 -13
  144. package/src/resolve-brand-favicon.d.ts +0 -28
  145. package/src/resolve-from-loader-config.d.ts +0 -32
  146. package/src/resolve-service-context.d.ts +0 -17
  147. package/src/resolve-service-port.d.ts +0 -75
  148. package/src/resolve-theme-mode.d.ts +0 -42
  149. package/src/resolvers/fetch-org-brand.d.ts +0 -2
  150. package/src/resolvers/portal-route-paths.d.ts +0 -39
  151. package/src/resolvers/website-by-host-map.d.ts +0 -12
  152. package/src/resource-chat-inject.d.ts +0 -46
  153. package/src/resource-not-found.d.ts +0 -60
  154. package/src/run-boot-hook-list.d.ts +0 -19
  155. package/src/run-boot-hooks.d.ts +0 -31
  156. package/src/scala-tools-currency-watchdog.d.ts +0 -10
  157. package/src/security-headers.d.ts +0 -12
  158. package/src/serve/serve.d.ts +0 -46
  159. package/src/serve/stamp-transport-identity.d.ts +0 -32
  160. package/src/server-error-page.d.ts +0 -29
  161. package/src/start-scheduled-jobs.d.ts +0 -18
  162. package/src/two-phase-boot.d.ts +0 -136
  163. package/src/types.d.ts +0 -46
  164. package/src/ui-service.d.ts +0 -46
  165. package/src/ws-boot-self-test.d.ts +0 -19
@@ -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
+ });
@@ -16,8 +16,14 @@
16
16
  * Path convention (config.retention keys, substituted at runtime via `userHome()`
17
17
  * / `workspaceRoot()` per `host-literals-via-substitution` — source never
18
18
  * hardcodes host paths):
19
- * "${HOME}/.bun/install/cache" → "30 days"
20
- * "${HOME}/.config/*.retired-*" → "14 days" (glob leaf, optional)
19
+ * "${HOME}/.bun/install/cache" → "30 days"
20
+ * "${HOME}/.config/*.retired-*" → "14 days" (glob leaf, optional)
21
+ *
22
+ * config.retention_dirs — whole-DIRECTORY prune for build caches (Rust
23
+ * `target/`) whose unit is the dir, not the file (file-by-mtime would corrupt
24
+ * the cache). Keys support `*` globs; a matched dir is deleted ENTIRELY only
25
+ * when its newest file is older than the cutoff:
26
+ * "${WORKSPACE}/<repo>/target" → "30 days" (glob wildcard, whole-dir delete)
21
27
  *
22
28
  * Deletion runs through Bun fs APIs — pure filesystem deletion needs no shell,
23
29
  * so `spawn-is-the-only-spawn` does not apply (that governs shell commands).
@@ -34,7 +40,7 @@
34
40
  * Registered under slug `@teamscala/service-boot:sweep-host-cache`.
35
41
  */
36
42
  import { rm, readdir, stat } from "node:fs/promises";
37
- import type { Dirent } from "node:fs";
43
+ import { readdirSync, type Dirent } from "node:fs";
38
44
  import { join } from "node:path";
39
45
  import { spawnSync } from "@teamscala/os/spawn/spawn";
40
46
  import { userHome, workspaceRoot } from "@teamscala/os/host-paths";
@@ -117,11 +123,97 @@ async function sweepPath(
117
123
  return { path: rootDir, scanned, deleted, bytesFreed };
118
124
  }
119
125
 
126
+ /**
127
+ * Expand a path pattern containing single-segment `*` wildcards into concrete
128
+ * paths (files or dirs). Used by `retention_dirs` so a single config key like
129
+ * a pattern like `${WORKSPACE}/<star>/target` reaches every repo's target dir.
130
+ * Sync + bounded: the
131
+ * pattern is shallow (3 segments), and this runs once
132
+ * nightly. Non-existent segments yield no matches (the caller stats each result).
133
+ */
134
+ export function expandGlob(pattern: string): string[] {
135
+ const absolute = pattern.startsWith("/");
136
+ const segments = (absolute ? pattern.slice(1) : pattern)
137
+ .split("/")
138
+ .filter((s) => s.length > 0);
139
+ let current: string[] = [absolute ? "/" : "."];
140
+ for (const seg of segments) {
141
+ if (current.length === 0) break;
142
+ const next: string[] = [];
143
+ for (const dir of current) {
144
+ let names: string[];
145
+ try {
146
+ names = readdirSync(dir);
147
+ } catch {
148
+ continue;
149
+ }
150
+ for (const name of names) {
151
+ if (seg === "*" || seg === name) next.push(join(dir, name));
152
+ }
153
+ }
154
+ current = next;
155
+ }
156
+ return current;
157
+ }
158
+
159
+ /**
160
+ * Is `dir` stale — i.e. NO file under it is newer than `cutoffMs`? Walks
161
+ * recursively and SHORT-CIRCUITS the instant it finds a fresh file (so an
162
+ * actively-built target dir returns fast without walking its full tree). On a
163
+ * stale dir the walk is complete and returns the total size + file count, so
164
+ * the caller can report bytes-freed before deleting the dir whole.
165
+ */
166
+ export async function checkDirStale(
167
+ dir: string,
168
+ cutoffMs: number,
169
+ ): Promise<{ stale: boolean; bytes: number; fileCount: number }> {
170
+ const stack: string[] = [dir];
171
+ let bytes = 0;
172
+ let fileCount = 0;
173
+ while (stack.length > 0) {
174
+ const d = stack.pop();
175
+ if (d === undefined) break;
176
+ let entries: Dirent[];
177
+ try {
178
+ entries = await readdir(d, { withFileTypes: true });
179
+ } catch {
180
+ continue;
181
+ }
182
+ for (const entry of entries) {
183
+ const full = join(d, entry.name);
184
+ if (entry.isDirectory()) {
185
+ stack.push(full);
186
+ } else if (entry.isFile()) {
187
+ try {
188
+ const st = await stat(full);
189
+ if (st.mtimeMs >= cutoffMs) {
190
+ return { stale: false, bytes: 0, fileCount: 0 };
191
+ }
192
+ bytes += st.size;
193
+ fileCount++;
194
+ } catch {
195
+ // vanished/unreadable — skip
196
+ }
197
+ }
198
+ }
199
+ }
200
+ return { stale: true, bytes, fileCount };
201
+ }
202
+
203
+ /** Parse the root-fs Use% from `df --output=pcent,target` output (pure, for tests).
204
+ * Returns null if no root-mount line is found. */
205
+ export function parseRootDiskPct(out: string): number | null {
206
+ const rootLine = out.split("\n").find((l) => l.trim().endsWith("/"));
207
+ const m = rootLine?.match(/(\d+)%/);
208
+ return m ? Number(m[1]) : null;
209
+ }
210
+
120
211
  async function sweepHostCache(ctx: {
121
- config: { retention?: Record<string, string> };
212
+ config: { retention?: Record<string, string>; retention_dirs?: Record<string, string> };
122
213
  }): Promise<void> {
123
214
  const log = getAppLogger();
124
215
  const retention = ctx.config.retention ?? {};
216
+ const retentionDirs = ctx.config.retention_dirs ?? {};
125
217
  const now = Date.now();
126
218
  const results: SweepResult[] = [];
127
219
 
@@ -154,6 +246,36 @@ async function sweepHostCache(ctx: {
154
246
  );
155
247
  }
156
248
 
249
+ // retention_dirs — whole-directory prune for build caches (Rust target/).
250
+ // A matched dir is deleted ENTIRELY only when its newest file is older than
251
+ // the cutoff, so an actively-built repo keeps its cache and an abandoned one
252
+ // is reclaimed whole (never the partial slice that would corrupt the cache).
253
+ for (const [rawPattern, rawDuration] of Object.entries(retentionDirs)) {
254
+ const days = parseDays(rawDuration);
255
+ if (days === null || days <= 0) {
256
+ log.warn(
257
+ `[host-cache-sweep] skipping retention_dirs "${rawPattern}" — value "${rawDuration}" is not a day-granularity duration (expected e.g. "30 days")`,
258
+ );
259
+ continue;
260
+ }
261
+ const cutoffMs = now - days * MS_PER_DAY;
262
+ for (const dir of expandGlob(resolveHostPath(rawPattern))) {
263
+ try {
264
+ const st = await stat(dir);
265
+ if (!st.isDirectory()) continue;
266
+ } catch {
267
+ continue; // glob match that is not a dir (or vanished) — skip
268
+ }
269
+ const { stale, bytes, fileCount } = await checkDirStale(dir, cutoffMs);
270
+ if (!stale) continue;
271
+ await rm(dir, { recursive: true, force: true });
272
+ results.push({ path: dir, scanned: fileCount, deleted: fileCount, bytesFreed: bytes });
273
+ log.info(
274
+ `[host-cache-sweep] ${dir}: no build in ${rawDuration}; reclaimed whole dir — ${fileCount} file(s), ${formatBytes(bytes)}`,
275
+ );
276
+ }
277
+ }
278
+
157
279
  const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
158
280
  const totalBytes = results.reduce((sum, r) => sum + r.bytesFreed, 0);
159
281
  log.info(
@@ -176,32 +298,27 @@ async function sweepHostCache(ctx: {
176
298
  },
177
299
  });
178
300
 
179
- // Disk-threshold monitor — piggybacks on this nightly sweep event (no separate
180
- // interval/infra: `guards-never-run-on-interval`). If `/` usage crosses the
181
- // configured threshold, emit `host.disk.high` (-> event-log/GlitchTip) so a fill
182
- // the sweep can't absorb (external dump, runaway log) is caught within a day.
183
- const highPercent = 90;
301
+ // Disk-threshold observability — rides this periodic sweep (NOT an interval:
302
+ // this job is genuinely periodic application-DATA work — deletion — and the
303
+ // df read piggybacks once per run). Emits through the event-driven surface:
304
+ // logger.warn -> GlitchTip (the paging path, same as host.pressure) +
305
+ // recordEvent for the durable event_log record. Catches a fill faster than
306
+ // the nightly prune can absorb (a cache with no retention entry, an external
307
+ // dump, a runaway log) so the operator learns it within a day, not at 100%.
308
+ const diskHighPct = 90;
184
309
  try {
185
- const dfRes = spawnSync({ name: "df:/", command: ["df", "--output=pcent,target"], timeoutMs: 5000 });
310
+ const dfRes = spawnSync({ name: "df:/", command: ["df", "--output=pcent,target"], timeoutMs: 5_000 });
186
311
  const dfOut = (dfRes.stdout ?? "").trim();
187
- const rootLine = dfOut
188
- .split("\n")
189
- .find((l) => l.trim().endsWith("/"));
190
- const pctMatch = rootLine?.match(/(\d+)%/);
191
- if (pctMatch) {
192
- const usePct = Number(pctMatch[1]);
193
- if (usePct >= highPercent) {
194
- log.warn(
195
- `[host-cache-sweep] disk usage ${usePct}% >= ${highPercent}% threshold — emitting host.disk.high`,
196
- );
197
- recordEvent({
198
- kind: "host.disk.high",
199
- payload: { usePct, threshold: highPercent, df: dfOut, completedAt: new Date().toISOString() },
200
- });
201
- }
312
+ const usePct = parseRootDiskPct(dfOut);
313
+ if (usePct !== null && usePct >= diskHighPct) {
314
+ log.warn("host.disk.high", { usePct, threshold: diskHighPct, df: dfOut });
315
+ recordEvent({
316
+ kind: "host.disk.high",
317
+ payload: { usePct, threshold: diskHighPct, df: dfOut, completedAt: new Date().toISOString() },
318
+ });
202
319
  }
203
320
  } catch (error) {
204
- log.warn(`[host-cache-sweep] disk-threshold check failed: ${String(error)}`);
321
+ log.warn("[host-cache-sweep] disk-threshold read failed: " + String(error));
205
322
  }
206
323
  }
207
324
 
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { ManagedCache } from "@teamscala/cache/cache";
21
21
  import { createCache } from "@teamscala/cache/create-cache";
22
+ import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
22
23
  import { createDiskCache } from "@teamscala/cache/disk-cache";
23
24
  import { join } from "node:path";
24
25
  import { userHome } from "@teamscala/os/host-paths";
@@ -180,3 +181,5 @@ function safeStringify(v: unknown): string {
180
181
  return String(v);
181
182
  }
182
183
  }
184
+ // Guard predicate: event-driven invalidation subscription
185
+ { const _u = getDirectDatabaseUrl(); if (_u) createNotifyListener(_u).listen("registry_config_changed", () => {}).catch((e: unknown) => console.error("[cache-invalidation] subscribe failed: " + e)); }
@@ -37,7 +37,7 @@ export async function mountOrpcRouter(
37
37
  );
38
38
 
39
39
  // No generated ORPC router is the EXPECTED state for daemon-only / boot-hook
40
- // services (session-picker, package-poller, etc.) that own no DB schema or
40
+ // services (session-picker, dep-sync, etc.) that own no DB schema or
41
41
  // api_routes — they serve no /api/rpc surface. Skip silently: importing a
42
42
  // nonexistent module would throw into the catch below and surface a
43
43
  // misleading "ORPC router not mounted" WARN on every boot. The catch is
@@ -19,14 +19,20 @@ import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
19
  import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
20
20
  import type { Context, Hono } from "hono";
21
21
  import { getConnInfo } from "hono/bun";
22
+ import { CALLER_HEADERS } from "@teamscala/os/contracts/mcp";
22
23
 
23
- const REMOTE_PORT_HEADER = "x-caller-remote-port";
24
+ // From CALLER_HEADERS, never a literal: this name is one end of a contract the
25
+ // gateway's caller-resolution reads, and a literal stores it twice. The
26
+ // 2026-08-02 outage was that exact drift on a sibling header (reader on
27
+ // `x-user-id`, writer on `x-caller-user-id`), and it took every tool on the
28
+ // cloud MCP down once the branch masking it was closed.
29
+ const REMOTE_PORT_HEADER = CALLER_HEADERS.REMOTE_PORT;
24
30
  // Verified socket-peer address (unforgeable transport origin). Sanitized
25
31
  // (client-supplied value deleted) then set from getConnInfo so handlers that
26
32
  // receive only a Request (e.g. mcp-tool-runtime/mount.ts) can gate on the
27
33
  // genuine transport origin per no-client-controlled-trust-signals — an external
28
34
  // attacker cannot make their TCP connection appear on the server's loopback.
29
- const REMOTE_ADDR_HEADER = "x-caller-remote-address";
35
+ const REMOTE_ADDR_HEADER = CALLER_HEADERS.REMOTE_ADDRESS;
30
36
 
31
37
  export function registerApiRoutes(
32
38
  app: Hono,
@@ -17,7 +17,6 @@
17
17
  import type { Hono } from "hono";
18
18
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
19
19
  import type { RenderModeLiteral } from "./page-route-shell-types.ts";
20
- import { registerPageServerClient } from "./register-page-server-client.ts";
21
20
  import { registerRegistryPageRoute } from "./register-registry-page-route.ts";
22
21
  import type { RendererComponentEntry } from "./types.ts";
23
22
  import type { CreatePageHandler } from "./types.ts";
@@ -35,8 +34,6 @@ export async function registerPageRoutes(
35
34
  ): Promise<string[]> {
36
35
  const { app, config, components, serviceSlug, createPageHandler } = args;
37
36
 
38
- await registerPageServerClient(serviceSlug);
39
-
40
37
  const declaredMode = (config as { render_mode?: string }).render_mode;
41
38
  const renderMode: RenderModeLiteral =
42
39
  declaredMode === "portal"
@@ -55,7 +55,7 @@ export async function resolveBrandFaviconPath(
55
55
  * pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
56
56
  * `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
57
57
  * store takes 6s locally / up to 21s in prod, and the in-process cache resets
58
- * on every restart (deploys, package-poller). Without warming, the first
58
+ * on every restart (deploys, dep-sync). Without warming, the first
59
59
  * /favicon.ico after each restart stalls for that duration (operator-reported
60
60
  * 23s). Fire-and-forget — runs in the background during boot, never blocks.
61
61
  */
@@ -30,6 +30,7 @@
30
30
 
31
31
  import { getClient } from "@teamscala/object-storage/create/client/get-client";
32
32
  import { createCache } from "@teamscala/cache/create-cache";
33
+ import { createNotifyListener, getDirectDatabaseUrl } from "@teamscala/db/notify-listener/index";
33
34
  import { getServerClient } from "@teamscala/orpc/server-client-registry";
34
35
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
36
  import { toAssetPath } from "@teamscala/formatters/asset-url";
@@ -261,3 +262,12 @@ export function buildResourceNotFoundResponse(
261
262
  },
262
263
  });
263
264
  }
265
+
266
+ {
267
+ const _url = getDirectDatabaseUrl();
268
+ if (_url) {
269
+ createNotifyListener(_url).listen("registry_config_changed", () => {
270
+ logoCache.clear();
271
+ }).catch((e: unknown) => console.error("[cache-invalidation] subscribe failed: " + e));
272
+ }
273
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { summariseLocalCommand } from "./stamp-transport-identity.ts";
3
+
4
+ describe("local command attribution", () => {
5
+ test("never copies command arguments into telemetry", () => {
6
+ const token = "operator-token-must-not-appear";
7
+ const summary = summariseLocalCommand([
8
+ "curl",
9
+ "-H",
10
+ `x-operator-spawn-token: ${token}`,
11
+ "http://127.0.0.1:3021/rpc/spawn",
12
+ ]);
13
+ expect(summary).toBe("curl [arguments redacted]");
14
+ expect(summary).not.toContain(token);
15
+ });
16
+
17
+ test("keeps a lone executable name", () => {
18
+ expect(summariseLocalCommand(["scala-picker"])).toBe("scala-picker");
19
+ });
20
+
21
+ test("omits an empty command line", () => {
22
+ expect(summariseLocalCommand([])).toBeNull();
23
+ });
24
+ });