@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.
- package/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- 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
|
+
);
|