@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The `scala-tools-currency-watch` pg-boss handler — keeps scala-sentinel's
7
+ * INSTALLED @teamscala/scala-tools at registry `latest` (open migration
8
+ * `daemon-scala-tools-currency`). scala-sentinel resolves scala-tools via its
9
+ * own node_modules (the installed copy), which goes STALE on every
10
+ * @teamscala/scala-tools publish until a manual deps install + restart — so the
11
+ * daemon's publish-chain + codegen trio run OLD scala-tools while workspace
12
+ * source is current (incident #58). This watch polls the registry dist-tag and,
13
+ * when scala-sentinel's installed copy lags, REINSTALLS it. It does not restart
14
+ * anything: sentinel spawns `node_modules/.bin/scala-tools` per invocation
15
+ * rather than importing it, so the next spawn picks up the new binary. See the
16
+ * no-restart note at the call site for the evidence and for why the restart that
17
+ * used to live there was disabling enforcement on every publish.
18
+ *
19
+ * Boot-safety (protective-layers-fail-explicitly): every step is wrapped — a
20
+ * dist-tag fetch failure, a version-parse failure, OR an install failure NEVER
21
+ * crashes the job or loga; each records a failure event + returns. Repeated
22
+ * reinstalls are prevented by strict semver-lt (re-installs only when installed
23
+ * is genuinely behind latest) + the idempotent --force-resolve install.
24
+ * Subprocesses go through @teamscala/os spawn (the single spawn surface).
25
+ *
26
+ * Registered under slug `@teamscala/service-boot:scala-tools-currency-watch`.
27
+ */
28
+ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
29
+ import { recordEvent } from "@teamscala/event-log/record-event";
30
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
31
+ import { workspaceRoot } from "@teamscala/os/host-paths";
32
+ import { spawn } from "@teamscala/os/spawn";
33
+ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
34
+
35
+ const PKG = "@teamscala/scala-tools";
36
+ const SENTINEL_DIR = `${workspaceRoot()}/scala-sentinel`;
37
+ const INSTALLED_PKG_JSON = `${SENTINEL_DIR}/node_modules/@teamscala/scala-tools/package.json`;
38
+
39
+ interface PkgJson {
40
+ version: string;
41
+ }
42
+
43
+ function parseSemver(v: string): [number, number, number] | null {
44
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
45
+ return m ? [Number(m[1]!), Number(m[2]!), Number(m[3]!)] : null;
46
+ }
47
+
48
+ /** Strict a < b on major.minor.patch (pre-releases ignored — latest is a stable dist-tag). */
49
+ function semverLt(a: string, b: string): boolean {
50
+ const pa = parseSemver(a);
51
+ const pb = parseSemver(b);
52
+ if (!pa || !pb) return false;
53
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i]! < pb[i]!;
54
+ return false;
55
+ }
56
+
57
+ async function scalaToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
58
+ const log = getAppLogger();
59
+ try {
60
+ const installedPkg = await Bun.file(INSTALLED_PKG_JSON).json() as PkgJson;
61
+ const installed = installedPkg.version;
62
+
63
+ const distTag = await spawn({
64
+ name: "scala-tools-currency-watch:dist-tag",
65
+ command: ["scala-tools", "packages", "dist-tag", "get", PKG, "latest"],
66
+ timeoutMs: 30_000,
67
+ });
68
+ // dist-tag get prints the version (last non-empty line).
69
+ const latest =
70
+ (distTag.stdout ?? "")
71
+ .split("\n")
72
+ .map((l) => l.trim())
73
+ .filter(Boolean)
74
+ .pop() ?? "";
75
+ if (!latest || !parseSemver(latest)) {
76
+ log.warn(
77
+ `[scala-tools-currency-watch] no readable latest (exit=${distTag.exitCode}, stdout=${(distTag.stdout ?? "").slice(0, 80)}) — skip`,
78
+ );
79
+ recordEvent({
80
+ kind: "scala-tools.currency.tick",
81
+ payload: { installed, latest: null, note: "dist-tag fetch unreadable" },
82
+ });
83
+ return;
84
+ }
85
+
86
+ if (!semverLt(installed, latest)) {
87
+ recordEvent({
88
+ kind: "scala-tools.currency.tick",
89
+ payload: { installed, latest, status: "current" },
90
+ });
91
+ return;
92
+ }
93
+
94
+ // STALE — reinstall + restart.
95
+ log.warn(
96
+ `[scala-tools-currency-watch] scala-sentinel ${PKG} ${installed} < latest ${latest} — reinstalling + restarting`,
97
+ );
98
+ const install = await spawn({
99
+ name: "scala-tools-currency-watch:deps-install",
100
+ command: ["scala-tools", "deps", "install", "--force-resolve"],
101
+ cwd: SENTINEL_DIR,
102
+ timeoutMs: 300_000,
103
+ });
104
+ if ((install.exitCode ?? -1) !== 0) {
105
+ log.error(
106
+ `[scala-tools-currency-watch] deps install failed (exit ${install.exitCode}) — NOT restarting to avoid boot-looping the enforcement daemon: ${(install.stderr ?? "").slice(0, 200)}`,
107
+ );
108
+ recordEvent({
109
+ kind: "scala-tools.currency.install-failed",
110
+ payload: {
111
+ installed,
112
+ latest,
113
+ exitCode: install.exitCode,
114
+ stderr: (install.stderr ?? "").slice(0, 300),
115
+ },
116
+ });
117
+ return;
118
+ }
119
+ recordEvent({
120
+ kind: "scala-tools.currency.reinstalled",
121
+ payload: { from: installed, to: latest },
122
+ });
123
+
124
+ // NO RESTART. The reinstall alone is sufficient, and restarting here was
125
+ // actively harmful.
126
+ //
127
+ // The premise this watch was written on — "restart so the fresh
128
+ // scala-tools loads" — does not hold for THIS package. scala-sentinel never
129
+ // imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools`
130
+ // per invocation, so a freshly installed binary is picked up by the next
131
+ // spawn with no process restart at all. Verified 2026-07-27: the package is
132
+ // declared in scala-sentinel/package.json, yet scala-guard/src contains zero
133
+ // import statements resolving to it — it is spawned, never linked.
134
+ // dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
135
+ // maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED
136
+ // sibling map exists for packages a daemon really does load at boot
137
+ // (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
138
+ //
139
+ // The cost was not theoretical. Restarting the guard daemon takes
140
+ // enforcement offline for its warmup, and every shutdown logs "152 guard(s)
141
+ // failed to run (fail-open)" — so each unnecessary restart opened a window
142
+ // in which tool-check, turn-end and pre-push silently did not run. Three
143
+ // scala-tools versions published on 2026-07-26 alone, and this watch polls,
144
+ // so it re-fired for each. That inverts
145
+ // `infrastructure-outranks-application-durability`: a currency convenience
146
+ // was repeatedly disabling the thing it exists to keep current.
147
+ //
148
+ // It also used a bare `pm2 restart`, which is forbidden
149
+ // (`forbid-bare-pm2-restart`) precisely because it bypasses the
150
+ // currency-gated canonical pipeline — the guard blocks agents from that
151
+ // exact call.
152
+ //
153
+ // If a future package genuinely needs a restart to take effect, it does not
154
+ // belong here: add it to dep-sync's PM2_CACHED map, which already owns
155
+ // install-plus-restart and coalesces them.
156
+ log.info(
157
+ `[scala-tools-currency-watch] reinstalled ${PKG} ${installed} → ${latest} (no restart: sentinel spawns the binary, it does not import it)`,
158
+ );
159
+ } catch (err) {
160
+ // Boot-safety: never propagate — record + return (a watch error must not crash loga).
161
+ log.error(
162
+ `[scala-tools-currency-watch] caught — fail-open (no restart): ${err instanceof Error ? err.message : String(err)}`,
163
+ );
164
+ recordEvent({
165
+ kind: "scala-tools.currency.watch-error",
166
+ payload: { error: err instanceof Error ? err.message : String(err) },
167
+ });
168
+ }
169
+ }
170
+
171
+ pgBossHandlerRegistry.register("@teamscala/service-boot:scala-tools-currency-watch", scalaToolsCurrencyWatch);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The `scheduled-browser-tests` pg-boss handler — the single scheduled-test
7
+ * home for the platform's regression suite (open migration
8
+ * `test-execution-consolidation`). Spawns `scala-tools test run --existing`
9
+ * on a cadence so every automated test execution flows through the one
10
+ * registry-driven harness, emitting the queryable `test.run.*` event pair
11
+ * (event-log-is-the-only-durable-event-log) that the deleted quartet of
12
+ * interval `guard_rule` rows never did (2026-06-29: 4 interval guards each
13
+ * autonomously drove headless Chrome against live UI services on 15/30-min
14
+ * timers with no central schedule/run-log).
15
+ *
16
+ * `--existing` runs every ACTIVE `test_vector` row across all services (the
17
+ * registry suite — both auto-generated and hand-authored vectors); `--no-boot`
18
+ * leaves service supervision to PM2 so a DOWN service fails its own health
19
+ * vector (the intended signal) instead of the scheduler booting a duplicate.
20
+ * `--source scheduled:scheduled-browser-tests` stamps the run's invoker block
21
+ * so a scheduled run is distinguishable from a manual agent run in the event
22
+ * log (`SELECT … FROM event_log WHERE kind LIKE 'test.run.%'`).
23
+ *
24
+ * The per-service outcomes land in the `test.run.result` events sharing the
25
+ * spawned run's `run_id`; this handler records only the schedule-level
26
+ * `scheduled.test.run` "did the run fire + finish" event. Spawns via
27
+ * `@teamscala/os` `spawn` (the single spawn surface); `scala-tools` resolves on
28
+ * PATH — on the dev host that is the source-mode link (always current).
29
+ *
30
+ * Registered under slug `@teamscala/service-boot:scheduled-browser-tests`.
31
+ * Mirrors `bun-stable-watch` / `upstream-issue-sync` (dev-host pg-boss
32
+ * handlers on scala-loga).
33
+ */
34
+ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
35
+ import { recordEvent } from "@teamscala/event-log/record-event";
36
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
37
+ import { spawn } from "@teamscala/os/spawn";
38
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
39
+ import * as v from "valibot";
40
+ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
41
+
42
+ const SOURCE = "scheduled:scheduled-browser-tests";
43
+
44
+ // A full --existing sweep across all UI services (Chrome-driven hydration +
45
+ // responsive + node-diff vectors, paced 5s apart between browser vectors) runs
46
+ // in the tens of minutes; bound the subprocess so a stuck vector's 90s wall can
47
+ // never hang the pg-boss worker indefinitely. exitCode null (SIGKILL on
48
+ // timeout) is treated as a failure + recorded.
49
+ const DEFAULT_RUN_TIMEOUT_MS = 30 * 60 * 1000;
50
+ let cachedRunTimeoutMs: number | undefined;
51
+ async function getRunTimeoutMs(): Promise<number> {
52
+ if (cachedRunTimeoutMs !== undefined) return cachedRunTimeoutMs;
53
+ const row = await loadRegistryConfig("config", "scheduled-browser-tests", v.looseObject({}));
54
+ cachedRunTimeoutMs = (row?.run_timeout_ms as number | undefined) ?? DEFAULT_RUN_TIMEOUT_MS;
55
+ return cachedRunTimeoutMs;
56
+ }
57
+
58
+ async function scheduledBrowserTests(_ctx: { config: ScheduledJobConfig }): Promise<void> {
59
+ const log = getAppLogger();
60
+
61
+ const { stdout, stderr, exitCode } = await spawn({
62
+ name: "scheduled-browser-tests",
63
+ command: ["scala-tools", "test", "run", "--existing", "--no-boot", "--source", SOURCE],
64
+ timeoutMs: await getRunTimeoutMs(),
65
+ });
66
+
67
+ const code = exitCode ?? -1;
68
+ const ok = exitCode === 0;
69
+ // Surface the run's own summary line (the CLI prints a final
70
+ // "N/M vectors pass across K services" footer) so the schedule outcome is
71
+ // visible in the loga tail without re-querying the event log.
72
+ const tail = (stdout || stderr || "").trim().split("\n").slice(-2).join(" | ");
73
+ log.info(
74
+ `[scheduled-browser-tests] scala-tools test run --existing exited ${code} (${ok ? "pass" : "fail"}) — ${tail}`,
75
+ );
76
+ recordEvent({
77
+ kind: "scheduled.test.run",
78
+ payload: { source: SOURCE, exit_code: code, ok },
79
+ });
80
+ }
81
+
82
+ pgBossHandlerRegistry.register(
83
+ "@teamscala/service-boot:scheduled-browser-tests",
84
+ scheduledBrowserTests,
85
+ );
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The `event-log-sweep` pg-boss handler. Reads the `retention` map from
7
+ * the `scheduled_job` row config and sweeps the two dev-host operational
8
+ * log tables via `@teamscala/event-log`'s sweep executors (those run
9
+ * through the event-log primitive's configured writer — neither table is
10
+ * a Prisma model, and the audited @teamscala/db client blocks raw SQL, so
11
+ * the primitive owns their lifecycle).
12
+ *
13
+ * Retention map convention (values are durations, "90 days"):
14
+ * "event_log" → default window for kinds with no override
15
+ * "event_log:<prefix>" → kind-prefix override (e.g. "event_log:guard.gate." for gate-latency telemetry)
16
+ * "hook_execution_log" → flat window for the agent-CLI hook log
17
+ * auth.* events are permanent regardless of config (enforced in the sweep
18
+ * executor). Unknown keys are skipped + logged, never executed.
19
+ *
20
+ * Registered under slug `@teamscala/service-boot:sweep-event-log`.
21
+ */
22
+ import { recordEvent } from "@teamscala/event-log/record-event";
23
+ import { sweepHookExecutionLog, sweepOldEvents } from "@teamscala/event-log/sweep-old";
24
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
25
+ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
26
+
27
+ const DURATION_RE = /^(\d+)\s+(day|week|month)s?$/;
28
+ const UNIT_TO_DAYS: Record<string, number> = {
29
+ day: 1,
30
+ week: 7,
31
+ month: 30,
32
+ };
33
+
34
+ function parseDays(raw: string): number | null {
35
+ const match = DURATION_RE.exec(raw.trim());
36
+ if (!match) return null;
37
+ return Number(match[1]) * (UNIT_TO_DAYS[match[2] as string] ?? 0);
38
+ }
39
+
40
+ async function sweepEventLog(ctx: {
41
+ config: { retention?: Record<string, string> };
42
+ }): Promise<void> {
43
+ const log = getAppLogger();
44
+ const retention = ctx.config.retention ?? {};
45
+
46
+ let defaultDays: number | undefined;
47
+ const byPrefix: Record<string, number> = {};
48
+ let hookLogDays: number | null = null;
49
+
50
+ for (const [key, rawValue] of Object.entries(retention)) {
51
+ const days = parseDays(rawValue);
52
+ if (days === null || days <= 0) {
53
+ log.warn(
54
+ `[event-log-sweep] skipping "${key}" — value "${rawValue}" is not a day-granularity duration (expected e.g. "90 days")`,
55
+ );
56
+ continue;
57
+ }
58
+ if (key === "event_log") defaultDays = days;
59
+ else if (key.startsWith("event_log:")) byPrefix[key.slice("event_log:".length)] = days;
60
+ else if (key === "hook_execution_log") hookLogDays = days;
61
+ else {
62
+ log.warn(`[event-log-sweep] skipping "${key}" — not a sweep target this handler knows`);
63
+ }
64
+ }
65
+
66
+ const events = await sweepOldEvents({
67
+ retentionDays: defaultDays,
68
+ retentionByPrefix: byPrefix,
69
+ });
70
+ log.info(
71
+ `[event-log-sweep] event_log: ${events.deleted} row(s) swept (default cutoff ${events.cutoffAt.toISOString()}, ${Object.keys(byPrefix).length} prefix override(s))`,
72
+ );
73
+
74
+ let hookLogDeleted: number | null = null;
75
+ if (hookLogDays !== null) {
76
+ const hooks = await sweepHookExecutionLog(hookLogDays);
77
+ hookLogDeleted = hooks.deleted;
78
+ log.info(
79
+ `[event-log-sweep] hook_execution_log: ${hooks.deleted} row(s) swept (cutoff ${hooks.cutoffAt.toISOString()})`,
80
+ );
81
+ }
82
+
83
+ // Liveness signal: loga's sweep-liveness watchdog escalates if no
84
+ // sweep.completed event lands within 48h (a silently-stopped sweep means
85
+ // unbounded event_log growth returns). Recorded AFTER the sweep so a failed
86
+ // sweep emits no heartbeat.
87
+ recordEvent({
88
+ kind: "sweep.completed",
89
+ payload: {
90
+ eventLogDeleted: events.deleted,
91
+ hookLogDeleted,
92
+ cutoffAt: events.cutoffAt.toISOString(),
93
+ completedAt: new Date().toISOString(),
94
+ },
95
+ });
96
+ }
97
+
98
+ pgBossHandlerRegistry.register("@teamscala/service-boot:sweep-event-log", sweepEventLog);
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The `host-cache-sweep` pg-boss handler. Reads the `retention` map from the
7
+ * `scheduled_job` row config — where each KEY is a host cache path (with
8
+ * `${HOME}`/`${WORKSPACE}` placeholders) and each VALUE is a day-granularity
9
+ * duration — and prunes files older than the cutoff. This is the mechanical
10
+ * automation that makes "host disk bounded by construction" real: it extends
11
+ * `no-uncontrolled-repetition-or-cascade` / `shared-resource-bounds-are-host-wide`
12
+ * (which bound install CONCURRENCY) to cache RETENTION, so the dev-host caches
13
+ * that accumulate every published version forever — canonical:
14
+ * `~/.bun/install/cache`, the @teamscala/* install cache — cannot grow unbounded.
15
+ *
16
+ * Path convention (config.retention keys, substituted at runtime via `userHome()`
17
+ * / `workspaceRoot()` per `host-literals-via-substitution` — source never
18
+ * hardcodes host paths):
19
+ * "${HOME}/.bun/install/cache" → "30 days"
20
+ * "${HOME}/.config/*.retired-*" → "14 days" (glob leaf, optional)
21
+ *
22
+ * Deletion runs through Bun fs APIs — pure filesystem deletion needs no shell,
23
+ * so `spawn-is-the-only-spawn` does not apply (that governs shell commands).
24
+ * Hardlinked node_modules copies SURVIVE cache pruning (a hardlink shares an
25
+ * inode; removing the cache's link leaves the installed copy's link intact), so
26
+ * installed packages stay whole — a pruned version simply re-downloads on the
27
+ * next install that needs it. Empty parent directories are left in place
28
+ * (negligible — directory entries are a few KB total); only file data is reclaimed.
29
+ *
30
+ * Liveness: records a `host.cache.swept` event after a run (distinct from
31
+ * event-log-sweep's `sweep.completed` so the event-log sweep-liveness watchdog
32
+ * is not confused). A silently-stopped sweep is detectable from the event stream.
33
+ *
34
+ * Registered under slug `@teamscala/service-boot:sweep-host-cache`.
35
+ */
36
+ import { rm, readdir, stat } from "node:fs/promises";
37
+ import type { Dirent } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { spawnSync } from "@teamscala/os/spawn/spawn";
40
+ import { userHome, workspaceRoot } from "@teamscala/os/host-paths";
41
+ import { formatBytes } from "@teamscala/formatters/format-bytes";
42
+ import { recordEvent } from "@teamscala/event-log/record-event";
43
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
44
+ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
45
+
46
+ const DURATION_RE = /^(\d+)\s+(day|week|month)s?$/;
47
+ const UNIT_TO_DAYS: Record<string, number> = {
48
+ day: 1,
49
+ week: 7,
50
+ month: 30,
51
+ };
52
+ const MS_PER_DAY = 86_400_000;
53
+
54
+ function parseDays(raw: string): number | null {
55
+ const match = DURATION_RE.exec(raw.trim());
56
+ if (!match) return null;
57
+ return Number(match[1]) * (UNIT_TO_DAYS[match[2]] ?? 0);
58
+ }
59
+
60
+ /** Resolve `${HOME}` / `${WORKSPACE}` placeholders against the live host. */
61
+ function resolveHostPath(raw: string): string {
62
+ return raw
63
+ .replaceAll("${HOME}", userHome())
64
+ .replaceAll("${WORKSPACE}", workspaceRoot());
65
+ }
66
+
67
+ interface SweepResult {
68
+ path: string;
69
+ scanned: number;
70
+ deleted: number;
71
+ bytesFreed: number;
72
+ }
73
+
74
+ /**
75
+ * Walk `rootDir` (stack-based, bounded memory) and delete regular files whose
76
+ * mtime is older than `cutoffMs`. Directories are traversed but not removed.
77
+ */
78
+ async function sweepPath(
79
+ rootDir: string,
80
+ cutoffMs: number,
81
+ ): Promise<SweepResult> {
82
+ const log = getAppLogger();
83
+ let scanned = 0;
84
+ let deleted = 0;
85
+ let bytesFreed = 0;
86
+ const stack: string[] = [rootDir];
87
+
88
+ while (stack.length > 0) {
89
+ const dir = stack.pop();
90
+ if (dir === undefined) break;
91
+ let entries: Dirent[];
92
+ try {
93
+ entries = await readdir(dir, { withFileTypes: true });
94
+ } catch {
95
+ continue; // unreadable / vanished — skip
96
+ }
97
+ for (const entry of entries) {
98
+ const full = join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ stack.push(full);
101
+ } else if (entry.isFile()) {
102
+ scanned++;
103
+ try {
104
+ const st = await stat(full);
105
+ if (st.mtimeMs < cutoffMs) {
106
+ bytesFreed += st.size;
107
+ await rm(full, { force: true });
108
+ deleted++;
109
+ }
110
+ } catch (error) {
111
+ log.warn(`[host-cache-sweep] could not process ${full}: ${String(error)}`);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ return { path: rootDir, scanned, deleted, bytesFreed };
118
+ }
119
+
120
+ async function sweepHostCache(ctx: {
121
+ config: { retention?: Record<string, string> };
122
+ }): Promise<void> {
123
+ const log = getAppLogger();
124
+ const retention = ctx.config.retention ?? {};
125
+ const now = Date.now();
126
+ const results: SweepResult[] = [];
127
+
128
+ for (const [rawPath, rawDuration] of Object.entries(retention)) {
129
+ const days = parseDays(rawDuration);
130
+ if (days === null || days <= 0) {
131
+ log.warn(
132
+ `[host-cache-sweep] skipping "${rawPath}" — value "${rawDuration}" is not a day-granularity duration (expected e.g. "30 days")`,
133
+ );
134
+ continue;
135
+ }
136
+
137
+ const dir = resolveHostPath(rawPath);
138
+ try {
139
+ const st = await stat(dir);
140
+ if (!st.isDirectory()) {
141
+ log.warn(`[host-cache-sweep] skipping "${rawPath}" — resolved path is not a directory`);
142
+ continue;
143
+ }
144
+ } catch {
145
+ log.warn(`[host-cache-sweep] skipping "${rawPath}" — path does not exist or is unreadable`);
146
+ continue;
147
+ }
148
+
149
+ const cutoffMs = now - days * MS_PER_DAY;
150
+ const result = await sweepPath(dir, cutoffMs);
151
+ results.push(result);
152
+ log.info(
153
+ `[host-cache-sweep] ${rawPath}: scanned ${result.scanned}, deleted ${result.deleted} file(s) (${formatBytes(result.bytesFreed)}) older than ${rawDuration}`,
154
+ );
155
+ }
156
+
157
+ const totalDeleted = results.reduce((sum, r) => sum + r.deleted, 0);
158
+ const totalBytes = results.reduce((sum, r) => sum + r.bytesFreed, 0);
159
+ log.info(
160
+ `[host-cache-sweep] complete: ${results.length} target(s), ${totalDeleted} file(s) deleted, ${formatBytes(totalBytes)} reclaimed`,
161
+ );
162
+
163
+ // Liveness signal — distinct kind so the event-log sweep watchdog is unaffected.
164
+ recordEvent({
165
+ kind: "host.cache.swept",
166
+ payload: {
167
+ targets: results.map((r) => ({
168
+ path: r.path,
169
+ scanned: r.scanned,
170
+ deleted: r.deleted,
171
+ bytesFreed: r.bytesFreed,
172
+ })),
173
+ totalDeleted,
174
+ totalBytesFreed: totalBytes,
175
+ completedAt: new Date().toISOString(),
176
+ },
177
+ });
178
+
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;
184
+ try {
185
+ const dfRes = spawnSync({ name: "df:/", command: ["df", "--output=pcent,target"], timeoutMs: 5000 });
186
+ 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
+ }
202
+ }
203
+ } catch (error) {
204
+ log.warn(`[host-cache-sweep] disk-threshold check failed: ${String(error)}`);
205
+ }
206
+ }
207
+
208
+ pgBossHandlerRegistry.register(
209
+ "@teamscala/service-boot:sweep-host-cache",
210
+ sweepHostCache,
211
+ );