@teamscala/service-boot 0.23.516 → 0.23.518

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 (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. package/src/resolve-service-context.ts +0 -88
@@ -1,380 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `singleton-tools-currency-watch` pg-boss handler — keeps every tool
7
- * declared in `config/scala-singleton-tools` at its `version_source` latest.
8
- *
9
- * Each tool entry now carries a `version_source` block:
10
- * { type: "npm_package", name: "<pkg>", dist_tag: "latest" }
11
- * The handler reads the row, checks each tool's installed version against the
12
- * registry dist-tag, and reinstalls when stale. Reinstall strategy per kind:
13
- * - "js" (oxlint): `bun add <pkg>@<dist_tag>` in the tool's `install_dir`
14
- * - "elf" (tsgo/tsc): `bun add -g <pkg>@<dist_tag>` (global install)
15
- * - "bare" (bun): SKIPPED — bun is a standalone ELF binary; its upgrade is
16
- * owned by `bun-stable-watch` (which watches for 1.4.0 stable). This handler
17
- * logs a skip event so the gap is observable, never silent.
18
- *
19
- * Boot-safety (protective-layers-fail-explicitly): every step is wrapped — a
20
- * dist-tag fetch failure, version-parse failure, or install failure NEVER
21
- * crashes the job or loga; each records a failure event + returns. Repeated
22
- * reinstalls are prevented by strict semver-lt comparison.
23
- *
24
- * Registered under slug `@teamscala/service-boot:singleton-tools-currency-watch`.
25
- */
26
- /**
27
- * Where a tool's "latest" comes from.
28
- *
29
- * A DISCRIMINATED UNION, deliberately, rather than an optional `repo` field
30
- * hanging off one shape: an unhandled variant then fails to compile instead of
31
- * silently taking the npm branch. The previous single-member form
32
- * (`type: "npm_package"`) made a GitHub-released binary UNREPRESENTABLE, not
33
- * merely unregistered — which is why standalone-ELF CLIs were absent from
34
- * `config/scala-singleton-tools` entirely. They live in ~/.local/bin and are
35
- * not npm-managed, so they could not be described at all.
36
- *
37
- * Their LOCATION is correct and must not be "fixed": `bun-bin-is-unstable`
38
- * designates ${HOME}/.local/bin as the home for operator CLIs precisely so they
39
- * never shadow a /usr/local/bin singleton wrapper. The gap was currency
40
- * tracking, never placement.
41
- */
42
-
43
- // A GitHub-released binary is NOT installed by a package manager, so there
44
- // is no "reinstall" verb to call — it would have to be downloaded, verified,
45
- // made executable and swapped atomically over a file that may be RUNNING.
46
- // That is a real install path with real failure modes (partial download,
47
- // wrong architecture, replacing a binary mid-execution), and doing it badly
48
- // is worse than not doing it: a half-written CLI is a broken CLI.
49
- //
50
- // So this reports the drift and stops. Detection is live from today; the
51
- // automated swap is a separate, deliberate change. Reporting a version the
52
- // watcher cannot actually install is honest — silently claiming currency
53
- // would not be.
54
-
55
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
56
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
57
- import { recordEvent } from "@teamscala/event-log/record-event";
58
- import { getAppLogger } from "@teamscala/logger/app-loggers";
59
- import { spawn } from "@teamscala/os/spawn";
60
- import { getDistTagVersion } from "@teamscala/publish-registry";
61
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
62
-
63
- type VersionSource =
64
- | {
65
- type: "npm_package";
66
- name: string;
67
- dist_tag: string;
68
- }
69
- | {
70
- /** A GitHub release asset — the shape standalone-ELF CLIs ship as. */
71
- type: "github_release";
72
- /** "owner/repo". */
73
- repo: string;
74
- /**
75
- * Regex matched against each release asset's name to pick the download.
76
- * Required: a repo publishing several platform builds has no safe default,
77
- * and guessing one installs the wrong architecture silently.
78
- */
79
- asset_pattern: string;
80
- };
81
-
82
- interface SingletonTool {
83
- name: string;
84
- kind: "elf" | "bare" | "js";
85
- real_path: string;
86
- lock_name?: string;
87
- install_dir?: string;
88
- version_source?: VersionSource;
89
- }
90
-
91
- interface SingletonToolsConfig {
92
- tools: SingletonTool[];
93
- }
94
-
95
- interface SemverParts {
96
- major: number;
97
- minor: number;
98
- patch: number;
99
- /** Pre-release suffix, e.g. "dev.20260620.1" or "" for stable. */
100
- pre: string;
101
- }
102
-
103
- function parseSemver(v: string): SemverParts | null {
104
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v.trim());
105
- if (!m) return null;
106
- return { major: Number(m[1]!), minor: Number(m[2]!), patch: Number(m[3]!), pre: m[4] ?? "" };
107
- }
108
-
109
- /** Compare two pre-release identifiers: "dev.20260707.2" > "dev.20260620.1". */
110
- function preLt(a: string, b: string): boolean {
111
- // Split on dots, compare each segment numerically if possible, else lexicographically.
112
- const sa = a.split(".");
113
- const sb = b.split(".");
114
- for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
115
- const na = Number(sa[i]);
116
- const nb = Number(sb[i]);
117
- if (!Number.isNaN(na) && !Number.isNaN(nb)) {
118
- if (na !== nb) return na < nb;
119
- } else if (sa[i] !== sb[i]) {
120
- return sa[i]! < sb[i]!;
121
- }
122
- }
123
- return sa.length < sb.length;
124
- }
125
-
126
- function semverLt(a: string, b: string): boolean {
127
- const pa = parseSemver(a);
128
- const pb = parseSemver(b);
129
- if (!pa || !pb) return false;
130
- if (pa.major !== pb.major) return pa.major < pb.major;
131
- if (pa.minor !== pb.minor) return pa.minor < pb.minor;
132
- if (pa.patch !== pb.patch) return pa.patch < pb.patch;
133
- // Same major.minor.patch — compare pre-release.
134
- // No pre-release (stable) > any pre-release.
135
- if (!pa.pre && !pb.pre) return false;
136
- if (!pa.pre) return false; // a is stable, b is pre — a is newer
137
- if (!pb.pre) return true; // a is pre, b is stable — a is older
138
- return preLt(pa.pre, pb.pre);
139
- }
140
-
141
- /** Read the installed version from a tool's real_path (symlink-resolved binary). */
142
- async function readInstalledVersion(tool: SingletonTool): Promise<string | null> {
143
- try {
144
- const vs = tool.version_source;
145
- // The package.json path only exists for an npm-sourced JS tool. A
146
- // github_release tool has no node_modules entry, so it falls through to the
147
- // `--version` probe below like any other binary.
148
- if (tool.kind === "js" && tool.install_dir && vs?.type === "npm_package") {
149
- // JS tools: read the installed package's package.json
150
- const pkgJsonPath = `${tool.install_dir}/node_modules/${vs.name}/package.json`;
151
- const pkg = await Bun.file(pkgJsonPath).json() as { version: string };
152
- return pkg.version;
153
- }
154
- // ELF/bare: run --version on the binary
155
- const result = await spawn({
156
- name: `singleton-tools-currency:version-${tool.name}`,
157
- command: [tool.real_path, "--version"],
158
- timeoutMs: 15_000,
159
- });
160
- const out = (result.stdout ?? "").trim();
161
- // tsgo prints "Version 7.0.0-dev.20260620.1" — extract the version
162
- const vMatch = /(\d+\.\d+\.\d+(?:-dev\.\d+)?)/.exec(out);
163
- return vMatch?.[1] ?? out;
164
- } catch {
165
- return null;
166
- }
167
- }
168
-
169
- /**
170
- * Latest published version for a GitHub-released tool.
171
- *
172
- * Reads the repo's latest release tag rather than enumerating assets, because
173
- * the tag is what carries the VERSION; the asset pattern only decides which
174
- * build to download at install time. A leading "v" is stripped so the value
175
- * compares against `readInstalledVersion` output under the same semver parser.
176
- */
177
- async function fetchLatestGithubRelease(repo: string): Promise<string | null> {
178
- try {
179
- const res = await fetch(
180
- `https://api.github.com/repos/${repo}/releases/latest`,
181
- { headers: { accept: "application/vnd.github+json" } },
182
- );
183
- if (!res.ok) return null;
184
- const release = (await res.json()) as { tag_name?: string };
185
- const tag = (release.tag_name ?? "").trim().replace(/^v/, "");
186
- if (!tag || !parseSemver(tag)) return null;
187
- return tag;
188
- } catch {
189
- return null;
190
- }
191
- }
192
-
193
- /** Fetch the latest version from npm for a given package + dist-tag. */
194
- async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
195
- try {
196
- // @teamscala/* packages use the internal dist-tag tool; public npm packages
197
- // fetch directly from the npm registry via the typed primitive.
198
- if (pkg.startsWith("@teamscala/")) {
199
- const result = await spawn({
200
- name: "singleton-tools-currency:dist-tag",
201
- command: ["scala-tools", "packages", "dist-tag", "get", pkg, distTag],
202
- timeoutMs: 30_000,
203
- });
204
- const latest =
205
- (result.stdout ?? "")
206
- .split("\n")
207
- .map((l) => l.trim())
208
- .filter(Boolean)
209
- .pop() ?? "";
210
- if (!latest || !parseSemver(latest)) return null;
211
- return latest;
212
- }
213
- return getDistTagVersion(pkg, distTag, "https://registry.npmjs.org");
214
- } catch {
215
- return null;
216
- }
217
- }
218
-
219
- /** Reinstall a stale tool to the latest version. */
220
- async function reinstallTool(tool: SingletonTool, latest: string): Promise<boolean> {
221
- const log = getAppLogger();
222
- const vs = tool.version_source;
223
- if (!vs) return false;
224
-
225
- if (vs.type === "github_release") {
226
- log.warn(
227
- `[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
228
- );
229
- recordEvent({
230
- kind: "singleton-tools.currency.tick",
231
- payload: {
232
- tool: tool.name,
233
- latest,
234
- status: "stale-manual-update-required",
235
- repo: vs.repo,
236
- },
237
- });
238
- return false;
239
- }
240
-
241
- if (tool.kind === "js" && tool.install_dir) {
242
- // JS tool: bun add in the install_dir
243
- const result = await spawn({
244
- name: `singleton-tools-currency:install-${tool.name}`,
245
- command: ["bun", "add", `${vs.name}@${vs.dist_tag}`],
246
- cwd: tool.install_dir,
247
- timeoutMs: 300_000,
248
- });
249
- if ((result.exitCode ?? -1) !== 0) {
250
- log.error(
251
- `[singleton-tools-currency] ${tool.name} install failed (exit ${result.exitCode}): ${(result.stderr ?? "").slice(0, 200)}`,
252
- );
253
- return false;
254
- }
255
- return true;
256
- }
257
-
258
- if (tool.kind === "elf") {
259
- // ELF tool: bun add -g
260
- const result = await spawn({
261
- name: `singleton-tools-currency:install-${tool.name}`,
262
- command: ["bun", "add", "-g", `${vs.name}@${vs.dist_tag}`],
263
- timeoutMs: 300_000,
264
- });
265
- if ((result.exitCode ?? -1) !== 0) {
266
- log.error(
267
- `[singleton-tools-currency] ${tool.name} global install failed (exit ${result.exitCode}): ${(result.stderr ?? "").slice(0, 200)}`,
268
- );
269
- return false;
270
- }
271
- return true;
272
- }
273
-
274
- // bare (bun) — skip, owned by bun-stable-watch
275
- log.info(`[singleton-tools-currency] ${tool.name} (bare) — skip, owned by bun-stable-watch`);
276
- return false;
277
- }
278
-
279
- async function singletonToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Promise<void> {
280
- const log = getAppLogger();
281
-
282
- let config: SingletonToolsConfig;
283
- try {
284
- config = loadRegistryConfig("config", "scala-singleton-tools") as never as SingletonToolsConfig;
285
- } catch (err) {
286
- log.warn(
287
- `[singleton-tools-currency] cannot load config/scala-singleton-tools — skip: ${err instanceof Error ? err.message : String(err)}`,
288
- );
289
- recordEvent({
290
- kind: "singleton-tools.currency.tick",
291
- payload: { note: "config/scala-singleton-tools unreadable" },
292
- });
293
- return;
294
- }
295
-
296
- for (const tool of config.tools) {
297
- const vs = tool.version_source;
298
- if (!vs) {
299
- // A tool with no version_source is NOT tracked, and that must be loud.
300
- // This previously `continue`d silently as "not yet inside the mechanism",
301
- // which is how a gap becomes permanent: nothing distinguishes a tool
302
- // nobody has declared from one that is up to date, so the watcher reports
303
- // a clean tick while the tool rots. That is the exact shape of
304
- // `a-component-may-not-report-a-state-it-has-not-verified` — the run
305
- // advertises coverage it never performed.
306
- log.warn(
307
- `[singleton-tools-currency] ${tool.name}: NO version_source — not tracked, cannot be kept current. Add one to config/scala-singleton-tools.`,
308
- );
309
- recordEvent({
310
- kind: "singleton-tools.currency.tick",
311
- payload: { tool: tool.name, status: "no-version-source" },
312
- });
313
- continue;
314
- }
315
-
316
- if (tool.kind === "bare") {
317
- // bun: owned by bun-stable-watch; log a tick so the gap is observable
318
- recordEvent({
319
- kind: "singleton-tools.currency.tick",
320
- payload: { tool: tool.name, status: "deferred-to-bun-stable-watch" },
321
- });
322
- continue;
323
- }
324
-
325
- const installed = await readInstalledVersion(tool);
326
- if (!installed) {
327
- log.warn(`[singleton-tools-currency] ${tool.name}: cannot read installed version — skip`);
328
- recordEvent({
329
- kind: "singleton-tools.currency.tick",
330
- payload: { tool: tool.name, installed: null, note: "unreadable" },
331
- });
332
- continue;
333
- }
334
-
335
- // Branch on the discriminant, never on a field's presence — an unhandled
336
- // variant is a compile error here rather than a silent fall-through to npm.
337
- const latest =
338
- vs.type === "npm_package"
339
- ? await fetchLatestVersion(vs.name, vs.dist_tag)
340
- : await fetchLatestGithubRelease(vs.repo);
341
- if (!latest) {
342
- log.warn(`[singleton-tools-currency] ${tool.name}: cannot fetch latest — skip`);
343
- recordEvent({
344
- kind: "singleton-tools.currency.tick",
345
- payload: { tool: tool.name, installed, latest: null, note: "dist-tag unreadable" },
346
- });
347
- continue;
348
- }
349
-
350
- if (!semverLt(installed, latest)) {
351
- recordEvent({
352
- kind: "singleton-tools.currency.tick",
353
- payload: { tool: tool.name, installed, latest, status: "current" },
354
- });
355
- continue;
356
- }
357
-
358
- // STALE — reinstall
359
- log.warn(
360
- `[singleton-tools-currency] ${tool.name} ${installed} < latest ${latest} — reinstalling`,
361
- );
362
- const ok = await reinstallTool(tool, latest);
363
- if (ok) {
364
- recordEvent({
365
- kind: "singleton-tools.currency.reinstalled",
366
- payload: { tool: tool.name, from: installed, to: latest },
367
- });
368
- } else {
369
- recordEvent({
370
- kind: "singleton-tools.currency.install-failed",
371
- payload: { tool: tool.name, from: installed, to: latest },
372
- });
373
- }
374
- }
375
- }
376
-
377
- pgBossHandlerRegistry.register(
378
- "@teamscala/service-boot:singleton-tools-currency-watch",
379
- singletonToolsCurrencyWatch,
380
- );
@@ -1,98 +0,0 @@
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);
@@ -1,220 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `upstream-issue-sync` pg-boss handler (doctrine `upstream-issues-tracked-in-
7
- * registry`). For each active `upstream_issue` registry row it polls the row's
8
- * GitHub issue + comments and writes back `state` / `comment_count` /
9
- * `last_comment_at` / `latest_response` / `last_synced_at`, emitting
10
- * `recordEvent({ kind: "upstream.issue.response" })` when a NEW maintainer
11
- * (non-filer) comment appears — so a maintainer response on an issue we filed
12
- * never goes unnoticed.
13
- *
14
- * Polls via the authenticated `gh` CLI (`@teamscala/os` spawn — `gh api` is the
15
- * off-the-shelf GitHub client; NOT a bespoke fetch). There is deliberately NO
16
- * `@teamscala/api-github` adapter: one existed as a hand-written `rest_client`
17
- * row with zero importers — this handler chose the CLI instead — so it was
18
- * deleted on 2026-09-02 rather than converted to a spec-derived adapter. GitHub
19
- * publishes an OpenAPI spec, so if a future consumer needs typed access the
20
- * answer is a spec-derived row, never a hand-listed one.
21
- *
22
- * Registered under slug `@teamscala/service-boot:upstream-issue-sync`. Mirrors
23
- * `bun-stable-watch` (dev-host pg-boss handler). Reads rows via the `getAllEntries`
24
- * helper and writes back via `persistRegistryEntryConfig` (the validated
25
- * registry_entries writer — `registry-via-helper` blocks direct registry Prisma
26
- * in package code).
27
- */
28
- import { getAllEntries } from "@teamscala/db/registry/accessors";
29
- import { persistRegistryEntryConfig } from "@teamscala/db/registry/persist-entry-config";
30
- import { validateRow } from "@teamscala/db-validation/validate-row";
31
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
32
- import { recordEvent } from "@teamscala/event-log/record-event";
33
- import { getAppLogger } from "@teamscala/logger/app-loggers";
34
- import { spawn } from "@teamscala/os/spawn";
35
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
36
- import * as v from "valibot";
37
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
38
- import { createCache } from "@teamscala/cache/create-cache";
39
-
40
- interface GhIssue {
41
- state: string;
42
- comments: number;
43
- author: string;
44
- }
45
-
46
- interface GhComment {
47
- author: string;
48
- at: string;
49
- body: string;
50
- }
51
-
52
- interface CapturedResponse {
53
- author: string;
54
- excerpt: string;
55
- at: string;
56
- }
57
-
58
- const DEFAULT_GH_TIMEOUT_MS = 20_000;
59
- const ghTimeoutCache = createCache<number>("service-boot:gh-timeout", {
60
- ttlMs: Number.POSITIVE_INFINITY,
61
- maxSize: 1,
62
- });
63
-
64
- async function getGhTimeoutMs(): Promise<number> {
65
- const hit = ghTimeoutCache.get("default");
66
- if (hit !== undefined) return hit;
67
- const row = await loadRegistryConfig("config", "upstream-issue-sync", v.looseObject({}));
68
- const timeoutMs = (row?.gh_timeout_ms as number | undefined) ?? DEFAULT_GH_TIMEOUT_MS;
69
- ghTimeoutCache.set("default", timeoutMs);
70
- return timeoutMs;
71
- }
72
- const EXCERPT_MAX = 160;
73
-
74
- /** Fetch a GitHub issue's state/comment-count/author via `gh api`. */
75
- async function ghIssue(owner: string, repo: string, number: number): Promise<GhIssue | null> {
76
- const { stdout, exitCode, stderr } = await spawn({
77
- name: "upstream-issue-sync:gh-issue",
78
- command: [
79
- "gh",
80
- "api",
81
- `repos/${owner}/${repo}/issues/${number}`,
82
- "--jq",
83
- "{state: .state, comments: .comments, author: .user.login}",
84
- ],
85
- timeoutMs: await getGhTimeoutMs(),
86
- });
87
- if (exitCode !== 0) {
88
- getAppLogger().warn(
89
- `[upstream-issue-sync] gh api issue ${owner}/${repo}#${number} failed: ${stderr.trim() || stdout.trim()}`,
90
- );
91
- return null;
92
- }
93
- try {
94
- return JSON.parse(stdout) as GhIssue;
95
- } catch {
96
- return null;
97
- }
98
- }
99
-
100
- /** Fetch a GitHub issue's comments via `gh api` (per_page=100). */
101
- async function ghComments(owner: string, repo: string, number: number): Promise<GhComment[]> {
102
- const { stdout, exitCode } = await spawn({
103
- name: "upstream-issue-sync:gh-comments",
104
- command: [
105
- "gh",
106
- "api",
107
- `repos/${owner}/${repo}/issues/${number}/comments?per_page=100`,
108
- "--jq",
109
- "[.[] | {author: .user.login, at: .created_at, body: .body}]",
110
- ],
111
- timeoutMs: await getGhTimeoutMs(),
112
- });
113
- if (exitCode !== 0) return [];
114
- try {
115
- const parsed = JSON.parse(stdout) as GhComment[];
116
- return Array.isArray(parsed) ? parsed : [];
117
- } catch {
118
- return [];
119
- }
120
- }
121
-
122
- function excerpt(body: string): string {
123
- const flat = body.replace(/\s+/g, " ").trim();
124
- return flat.length > EXCERPT_MAX ? `${flat.slice(0, EXCERPT_MAX)}…` : flat;
125
- }
126
-
127
- /**
128
- * The newest comment NOT by the issue filer (a maintainer response), or null.
129
- * GitHub returns comments oldest-first; sort by `at` to be safe.
130
- */
131
- function newestMaintainerResponse(
132
- comments: GhComment[],
133
- filer: string,
134
- ): CapturedResponse | null {
135
- const external = comments
136
- .filter((c) => c.author !== filer)
137
- .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
138
- const newest = external[0];
139
- if (!newest) return null;
140
- return { author: newest.author, excerpt: excerpt(newest.body), at: newest.at };
141
- }
142
-
143
- async function upstreamIssueSync(_ctx: { config: ScheduledJobConfig }): Promise<void> {
144
- const log = getAppLogger();
145
-
146
- const rows = getAllEntries().filter(
147
- (e) => (e.type as string) === "upstream_issue" && e.is_active,
148
- );
149
- if (rows.length === 0) {
150
- log.info("[upstream-issue-sync] no active upstream_issue rows");
151
- return;
152
- }
153
-
154
- let updated = 0;
155
- for (const row of rows) {
156
- const cfg = row.config as Record<string, unknown>;
157
- const repo = String(cfg.repo ?? "");
158
- const issueNumber = Number(cfg.issue_number);
159
- const [owner, repoName] = repo.split("/");
160
- if (!owner || !repoName || !Number.isInteger(issueNumber)) {
161
- log.warn(`[upstream-issue-sync] row ${row.slug} has invalid repo/issue_number`);
162
- continue;
163
- }
164
-
165
- const issue = await ghIssue(owner, repoName, issueNumber);
166
- if (!issue) continue; // gh failure already logged; skip this row this cycle
167
- const comments = await ghComments(owner, repoName, issueNumber);
168
- const lastCommentAt =
169
- comments.length > 0 ? (comments.map((c) => c.at).sort().at(-1) ?? null) : null;
170
- const latestResponse = newestMaintainerResponse(comments, issue.author);
171
-
172
- const prevResponseAt = (
173
- cfg.latest_response as { at?: string } | null | undefined
174
- )?.at ?? null;
175
-
176
- const nowIso = new Date().toISOString();
177
- const nextConfig = {
178
- ...cfg,
179
- state: issue.state === "closed" ? "closed" : "open",
180
- comment_count: issue.comments,
181
- last_comment_at: lastCommentAt,
182
- latest_response: latestResponse,
183
- last_synced_at: nowIso,
184
- };
185
-
186
- try {
187
- await persistRegistryEntryConfig("upstream_issue", row.slug, nextConfig, validateRow);
188
- } catch (err) {
189
- log.warn(
190
- `[upstream-issue-sync] row ${row.slug} write failed (validation or db): ${err instanceof Error ? err.message : String(err)}`,
191
- );
192
- continue;
193
- }
194
- updated += 1;
195
-
196
- // New maintainer response? (newer than the previously-recorded one)
197
- const isNewResponse =
198
- latestResponse !== null && (prevResponseAt === null || latestResponse.at > prevResponseAt);
199
- if (isNewResponse) {
200
- recordEvent({
201
- kind: "upstream.issue.response",
202
- payload: {
203
- slug: row.slug,
204
- repo,
205
- issue_number: issueNumber,
206
- author: latestResponse.author,
207
- excerpt: latestResponse.excerpt,
208
- at: latestResponse.at,
209
- },
210
- });
211
- log.info(
212
- `[upstream-issue-sync] ${row.slug}: new maintainer response from @${latestResponse.author}`,
213
- );
214
- }
215
- }
216
-
217
- log.info(`[upstream-issue-sync] synced ${updated}/${rows.length} upstream_issue rows`);
218
- }
219
-
220
- pgBossHandlerRegistry.register("@teamscala/service-boot:upstream-issue-sync", upstreamIssueSync);