@teamscala/service-boot 0.23.322 → 0.23.324

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 (163) 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 +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-rename.test.ts +53 -0
  21. package/src/fn-routers/task-sync-rename.ts +111 -0
  22. package/src/fn-routers/task-sync-router.test.ts +14 -2
  23. package/src/handlers/singleton-tools-currency-watch.ts +114 -11
  24. package/src/handlers/sweep-host-cache.test.ts +133 -0
  25. package/src/handlers/sweep-host-cache.ts +143 -26
  26. package/src/install-logging.ts +3 -0
  27. package/src/mount-orpc-router.ts +1 -1
  28. package/src/register-page-routes.ts +0 -3
  29. package/src/resolve-brand-favicon.ts +1 -1
  30. package/src/resource-not-found.ts +10 -0
  31. package/src/serve/stamp-transport-identity.test.ts +24 -0
  32. package/src/serve/stamp-transport-identity.ts +14 -2
  33. package/src/two-phase-boot.ts +85 -17
  34. package/src/two-phase-health-gate-client-only.test.ts +77 -0
  35. package/src/two-phase-health-gate.test.ts +13 -2
  36. package/src/agreement-gate.d.ts +0 -19
  37. package/src/asset-cache-key.d.ts +0 -11
  38. package/src/asset-optimize.d.ts +0 -17
  39. package/src/asset-proxy.d.ts +0 -11
  40. package/src/asset-social-card.d.ts +0 -17
  41. package/src/attach-agreement-magic.d.ts +0 -20
  42. package/src/attach-portal-members.d.ts +0 -23
  43. package/src/attach-proposal-accept.d.ts +0 -26
  44. package/src/attach-proposal-magic.d.ts +0 -18
  45. package/src/attach-transfers-upload.d.ts +0 -27
  46. package/src/auth-gate.d.ts +0 -28
  47. package/src/bind-page-handler.d.ts +0 -18
  48. package/src/build-client-assets/input-hashing.d.ts +0 -25
  49. package/src/build-client-assets/source-candidates.d.ts +0 -27
  50. package/src/build-client-assets.d.ts +0 -50
  51. package/src/compose-page-title.d.ts +0 -41
  52. package/src/content-gate/access.d.ts +0 -15
  53. package/src/content-gate/config-types.d.ts +0 -99
  54. package/src/content-gate/gate.d.ts +0 -13
  55. package/src/content-gate/parse-cookie.d.ts +0 -9
  56. package/src/content-gate/token.d.ts +0 -48
  57. package/src/content-page-floor.d.ts +0 -39
  58. package/src/discover-blocks.d.ts +0 -28
  59. package/src/expand-roster.d.ts +0 -34
  60. package/src/fn-routers/agreement-completion-email.d.ts +0 -14
  61. package/src/fn-routers/agreement-send-router.d.ts +0 -82
  62. package/src/fn-routers/ai-health-router.d.ts +0 -35
  63. package/src/fn-routers/auth-router.d.ts +0 -47
  64. package/src/fn-routers/cache-admin-router.d.ts +0 -57
  65. package/src/fn-routers/chat-session-router.d.ts +0 -219
  66. package/src/fn-routers/email-defaults.d.ts +0 -1
  67. package/src/fn-routers/email-link-ttl.d.ts +0 -1
  68. package/src/fn-routers/index.d.ts +0 -18
  69. package/src/fn-routers/panel-data-router.d.ts +0 -19
  70. package/src/fn-routers/persist-chat-messages.d.ts +0 -36
  71. package/src/fn-routers/plans-router.d.ts +0 -25
  72. package/src/fn-routers/proposal-send-router.d.ts +0 -58
  73. package/src/fn-routers/public-agreement-router.d.ts +0 -289
  74. package/src/fn-routers/public-assessment-router.d.ts +0 -68
  75. package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
  76. package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
  77. package/src/fn-routers/task-sync-assign.d.ts +0 -305
  78. package/src/fn-routers/task-sync-brief.d.ts +0 -96
  79. package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
  80. package/src/fn-routers/task-sync-dispatch.ts +0 -124
  81. package/src/fn-routers/task-sync-lane.d.ts +0 -49
  82. package/src/fn-routers/task-sync-lane.ts +0 -125
  83. package/src/fn-routers/task-sync-projection.d.ts +0 -121
  84. package/src/fn-routers/task-sync-router.d.ts +0 -402
  85. package/src/fn-routers/task-sync-router.ts +0 -1097
  86. package/src/fn-routers/tasks-router.d.ts +0 -25
  87. package/src/fn-routers/user-preferences-router.d.ts +0 -77
  88. package/src/fn-routers/work-items-router.d.ts +0 -102
  89. package/src/gate-ttls.d.ts +0 -6
  90. package/src/gpt-gate.d.ts +0 -25
  91. package/src/handlers/bun-stable-watch.d.ts +0 -1
  92. package/src/handlers/pane-outbox-drain.d.ts +0 -1
  93. package/src/handlers/projects-digest.d.ts +0 -26
  94. package/src/handlers/prune-stale-data.d.ts +0 -30
  95. package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
  96. package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
  97. package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
  98. package/src/handlers/sweep-event-log.d.ts +0 -1
  99. package/src/handlers/sweep-host-cache.d.ts +0 -1
  100. package/src/handlers/upstream-issue-sync.d.ts +0 -1
  101. package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
  102. package/src/handlers/whatsapp-liveness.d.ts +0 -4
  103. package/src/hono-error-handler.d.ts +0 -25
  104. package/src/install-harness.d.ts +0 -45
  105. package/src/install-logging.d.ts +0 -34
  106. package/src/load-service-install-row.d.ts +0 -24
  107. package/src/load-service-prisma-client.d.ts +0 -18
  108. package/src/mount-orpc-router.d.ts +0 -15
  109. package/src/mount-server-packages.d.ts +0 -29
  110. package/src/mount.d.ts +0 -9
  111. package/src/mutable-app-ref.d.ts +0 -44
  112. package/src/page-route-shell-types.d.ts +0 -12
  113. package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
  114. package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
  115. package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
  116. package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
  117. package/src/page-server-orpc/server-client.d.ts +0 -16
  118. package/src/page-server-orpc/templates-router.d.ts +0 -34
  119. package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
  120. package/src/pg-boss-handler-registry.d.ts +0 -27
  121. package/src/pg-boss-mount.d.ts +0 -42
  122. package/src/proposal-authorized-emails.d.ts +0 -28
  123. package/src/proposal-chat-inject.d.ts +0 -17
  124. package/src/proposal-gate.d.ts +0 -18
  125. package/src/read-html-template.d.ts +0 -38
  126. package/src/read-viewport-from-request.d.ts +0 -18
  127. package/src/readiness-gate.d.ts +0 -16
  128. package/src/register-api-routes.d.ts +0 -18
  129. package/src/register-crash-handlers.d.ts +0 -15
  130. package/src/register-page-routes.d.ts +0 -27
  131. package/src/register-page-server-client.d.ts +0 -11
  132. package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
  133. package/src/register-registry-page-route/graph-cache.d.ts +0 -30
  134. package/src/register-registry-page-route/meta-title.d.ts +0 -2
  135. package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
  136. package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
  137. package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
  138. package/src/register-registry-page-route.d.ts +0 -27
  139. package/src/register-static-assets.d.ts +0 -13
  140. package/src/register-ws-routes.d.ts +0 -28
  141. package/src/resolve-app-base-url.d.ts +0 -13
  142. package/src/resolve-brand-favicon.d.ts +0 -28
  143. package/src/resolve-from-loader-config.d.ts +0 -32
  144. package/src/resolve-service-context.d.ts +0 -17
  145. package/src/resolve-service-port.d.ts +0 -75
  146. package/src/resolve-theme-mode.d.ts +0 -42
  147. package/src/resolvers/fetch-org-brand.d.ts +0 -2
  148. package/src/resolvers/portal-route-paths.d.ts +0 -39
  149. package/src/resolvers/website-by-host-map.d.ts +0 -12
  150. package/src/resource-chat-inject.d.ts +0 -46
  151. package/src/resource-not-found.d.ts +0 -60
  152. package/src/run-boot-hook-list.d.ts +0 -19
  153. package/src/run-boot-hooks.d.ts +0 -31
  154. package/src/scala-tools-currency-watchdog.d.ts +0 -10
  155. package/src/security-headers.d.ts +0 -12
  156. package/src/serve/serve.d.ts +0 -46
  157. package/src/serve/stamp-transport-identity.d.ts +0 -32
  158. package/src/server-error-page.d.ts +0 -29
  159. package/src/start-scheduled-jobs.d.ts +0 -18
  160. package/src/two-phase-boot.d.ts +0 -136
  161. package/src/types.d.ts +0 -46
  162. package/src/ui-service.d.ts +0 -46
  163. package/src/ws-boot-self-test.d.ts +0 -19
@@ -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
@@ -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
+ });
@@ -31,6 +31,18 @@ import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
31
31
 
32
32
  const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
33
33
 
34
+ /**
35
+ * Preserve useful process attribution without copying arbitrary argv into logs.
36
+ * Arguments routinely contain bearer headers, inline scripts, URLs and secret
37
+ * values. There is no complete flag-name redaction list, so the safe boundary
38
+ * is structural: retain argv[0] and state that arguments were removed.
39
+ */
40
+ export function summariseLocalCommand(cmdline: readonly string[]): string | null {
41
+ const executable = cmdline[0]?.trim();
42
+ if (!executable) return null;
43
+ return cmdline.length > 1 ? `${executable} [arguments redacted]` : executable;
44
+ }
45
+
34
46
  /**
35
47
  * Return a Request with transport-identity headers stamped, or the original
36
48
  * `req` when no stamping is needed/possible. Never throws.
@@ -77,8 +89,8 @@ export function stampTransportIdentity(
77
89
  const owner = resolveSocketPeer(port);
78
90
  if (owner) {
79
91
  headers.set(CALLER_HEADERS.LOCAL_COMM, owner.comm);
80
- const cmd = owner.cmdline.join(" ").trim();
81
- if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd.slice(0, 160));
92
+ const cmd = summariseLocalCommand(owner.cmdline);
93
+ if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd);
82
94
  }
83
95
  }
84
96
  return new Request(req.url, {
@@ -54,6 +54,7 @@ import { registerPageRoutes } from "./register-page-routes.ts";
54
54
  import { runBootHookList } from "./run-boot-hook-list.ts";
55
55
  import { buildClientAssets } from "./build-client-assets.ts";
56
56
  import { discoverBlocks } from "./discover-blocks.ts";
57
+ import { registerPageServerClient } from "./register-page-server-client.ts";
57
58
  import { swapApp } from "./mutable-app-ref.ts";
58
59
  import type { CreatePageHandler } from "./types.ts";
59
60
 
@@ -63,13 +64,60 @@ let realPageHandler: CreatePageHandler | null = null;
63
64
  let fullAppReady = false;
64
65
 
65
66
  /**
66
- * Set the real page handler (called when Phase 2 completes).
67
+ * Set the real page handler (called when Phase 2 completes on an SSR service).
68
+ *
69
+ * Registering the SSR handler implies the full app is built, so this also flips
70
+ * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
71
+ * exists separately. See its note.
67
72
  */
68
73
  export function setPageHandler(handler: CreatePageHandler): void {
69
74
  realPageHandler = handler;
75
+ markFullAppReady();
76
+ }
77
+
78
+ /**
79
+ * Flip readiness once phase 2 has built the full app.
80
+ *
81
+ * SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
82
+ * means "phase 2 finished"; the page handler means "this service renders pages
83
+ * server-side". Every service was SSR, so one flag honestly carried both — until
84
+ * `render_mode=client` (`granular runtime`) made a service that completes phase 2
85
+ * and never registers an SSR handler.
86
+ *
87
+ * Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
88
+ * un-ready: `/health` answered `{"status":"starting"}` forever while the process
89
+ * was healthy and serving. That is the failure `readiness-gated-boot` is written
90
+ * against — a service must be "binary: fully-ready or cleanly-unavailable", and
91
+ * a never-ready health signal makes the activator's wake gate and every
92
+ * health-gated router treat a working service as down.
93
+ *
94
+ * Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
95
+ * built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
96
+ * still reported starting — while three untouched UI services reported ok.
97
+ */
98
+ export function markFullAppReady(): void {
70
99
  fullAppReady = true;
71
100
  }
72
101
 
102
+ /**
103
+ * Reset both phase-2 flags. TEST-ONLY.
104
+ *
105
+ * `fullAppReady` is module state that only ever transitions false -> true in
106
+ * production, and Bun shares a module instance across every test file in a run —
107
+ * so the SSR readiness test and the client-only readiness test contaminate each
108
+ * other purely by file order. Splitting them across files does NOT isolate them;
109
+ * it only hides the coupling until CI runs them together.
110
+ *
111
+ * A test that passes alone and fails in a suite is worse than no test, so the
112
+ * reset is exported rather than left as an ordering convention nobody can see.
113
+ * Never called from production code — `setPageHandler`/`markFullAppReady` are
114
+ * the only writers on the boot path.
115
+ */
116
+ export function __resetPhase2StateForTest(): void {
117
+ realPageHandler = null;
118
+ fullAppReady = false;
119
+ }
120
+
73
121
  /**
74
122
  * Get the current page handler state.
75
123
  */
@@ -247,16 +295,6 @@ export async function buildMinimalApp(
247
295
  preloadServerPackages(serverPackages),
248
296
  );
249
297
  const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
250
- const discoverPromise = runStep("discover-blocks", () =>
251
- discoverBlocks(blockPackages),
252
- );
253
- // Attach an immediate rejection sink: discoverPromise is fired here but
254
- // awaited much later (after the gate chain + mounts). If discoverBlocks
255
- // rejects during that overlap window, the rejection would surface as an
256
- // unhandledRejection before the await below can catch it. The sink is a
257
- // no-op — the real error still propagates at the `await discoverPromise`
258
- // site because each consumer of a rejected promise receives the rejection.
259
- discoverPromise.catch(() => {});
260
298
 
261
299
  // Public access endpoints — MUST be registered before the auth gate (Hono
262
300
  // dispatches middleware + handlers in registration order; the content-gate
@@ -305,6 +343,21 @@ export async function buildMinimalApp(
305
343
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
306
344
  }
307
345
 
346
+ // Register the in-process ORPC server client BEFORE discoverBlocks — it
347
+ // calls getServerClient() which needs the client set here. Must follow
348
+ // mount-orpc-router (which builds the router getAppRouter uses).
349
+ // Previously ran inside register-page-routes (too late — discoverBlocks
350
+ // fired before it, hitting the empty-router fallback).
351
+ await runStep("register-page-server-client", () =>
352
+ registerPageServerClient(serviceSlug),
353
+ );
354
+
355
+ // NOW fire discoverBlocks (getServerClient is set). Awaited below before
356
+ // register-page-routes consumes the result.
357
+ const discoverPromise = runStep("discover-blocks", () =>
358
+ discoverBlocks(blockPackages),
359
+ );
360
+
308
361
  const wsRouteCount = await runStep("register-ws-routes", () =>
309
362
  registerWsRoutes(app, config, mountRegistry, serviceSlug),
310
363
  );
@@ -376,7 +429,8 @@ export async function buildMinimalApp(
376
429
  // (the same pulsating-logo holding page scale-to-zero uses) —
377
430
  // never bare plain text (loading-via-logo-loading).
378
431
  if (!fullAppReady || !realPageHandler) {
379
- return new Response(buildWakingPageHtml({}), {
432
+ const logo = await resolveErrorPageLogo(serviceSlug);
433
+ return new Response(buildWakingPageHtml({ logoUrl: logo }), {
380
434
  status: 503,
381
435
  headers: {
382
436
  "content-type": "text/html; charset=utf-8",
@@ -498,11 +552,25 @@ export async function buildFullApp(
498
552
  `${Object.keys(components).length} component(s)`,
499
553
  );
500
554
 
501
- // Set the real page handler so the dynamic handler in Phase 1 can delegate to it
502
- setPageHandler(deps.createPageHandler);
503
- log.info(
504
- `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
505
- );
555
+ // Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
556
+ // Client-only services (render_mode=client) have no SSR page handler — skip
557
+ // (they serve via the client bundle, never SSR). Guarded so undefined is a
558
+ // no-op rather than a crash.
559
+ if (deps.createPageHandler) {
560
+ setPageHandler(deps.createPageHandler);
561
+ log.info(
562
+ `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
563
+ );
564
+ } else {
565
+ // The full app IS built — this service simply never renders server-side,
566
+ // so readiness is flipped directly rather than as a side effect of
567
+ // registering an SSR handler that will never exist. Without this a
568
+ // client-only service is permanently un-ready (see markFullAppReady).
569
+ markFullAppReady();
570
+ log.info(
571
+ `[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
572
+ );
573
+ }
506
574
 
507
575
  const resolvedBlocks = loaded.map((slug) => ({
508
576
  slug,