@teamscala/service-boot 0.23.516 → 0.23.517

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 CHANGED
@@ -1187,5 +1187,5 @@
1187
1187
  "repository": "https://github.com/teamgbg/service-boot",
1188
1188
  "sideEffects": false,
1189
1189
  "type": "module",
1190
- "version": "0.23.516"
1191
- }
1190
+ "version": "0.23.517"
1191
+ }
@@ -31,10 +31,8 @@ import "./handlers/sweep-event-log.ts";
31
31
  import "./handlers/bun-stable-watch.ts";
32
32
  import "./handlers/whatsapp-liveness.ts";
33
33
  import "./handlers/messaging-send-recording-watch.ts";
34
- import "./handlers/upstream-issue-sync.ts";
35
34
  import "./handlers/scala-tools-currency-watch.ts";
36
35
  import "./handlers/singleton-tools-currency-watch.ts";
37
- import "./handlers/registry-stale-inactive-sweep.ts";
38
36
  import "./handlers/platform-fault-escalation.ts";
39
37
  import "./handlers/quiz-research.ts";
40
38
 
@@ -7,6 +7,12 @@
7
7
  * the platform-owned routers into the service's generated `appRouter` and
8
8
  * stashes the resulting client via setServerClient. Called once at boot before
9
9
  * any page route handler fires.
10
+ *
11
+ * Handwritten because no machine-readable contract generates it: this is the
12
+ * EXECUTOR half of a rows+executor pair (`business-logic-is-rows-plus-executors`),
13
+ * whose declared half is the `service_install`/`service_schema` rows read at
14
+ * boot. There is no supplier spec, CLI or registry row that could emit this
15
+ * boot-time wiring, so it stays hand-written; the row half remains the source.
10
16
  */
11
17
 
12
18
  // REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
@@ -3,33 +3,21 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * The scala-projects app's own public hostname, read from its `service_install`
7
- * row rather than hardcoded — a service knows its own deploy config only
8
- * through the registry (registries-are-source-of-truth), never a literal in
9
- * source. Cached for the process lifetime: this changes only on a redeploy,
10
- * not per-request. Shared by every server-side path that builds a link back
11
- * into the app (approval emails, mention emails, digest emails).
6
+ * The scala-projects app's own public base URL, from its `service_routing`
7
+ * row through db's canonical reader (`makeServiceRoutingReader`) — the one
8
+ * copy of every public hostname, the same row the tunnel's ingress and DNS
9
+ * derive from (collapsed 2026-09-26: service_install no longer carries
10
+ * public_hostname). A missing hostname throws naming the row; there is no
11
+ * fallback literal to link to a guessed address. Shared by every server-side
12
+ * path that builds a link back into the app (approval, mention and digest
13
+ * emails).
12
14
  */
13
15
 
14
- import { ServiceInstallConfigSchema } from "@teamscala/db-validation/registry-schemas/service-install";
15
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
16
- import { createCache } from "@teamscala/cache/create-cache";
16
+ import { ServiceRoutingConfigSchema } from "@teamscala/db-validation/registry-schemas/service-routing";
17
+ import { makeServiceRoutingReader } from "@teamscala/db/registry/service-routing";
17
18
 
18
- const appUrlCache = createCache<string>("service-boot:app-base-url", {
19
- ttlMs: Number.POSITIVE_INFINITY,
20
- maxSize: 1,
21
- });
19
+ const { resolveServicePublicUrl } = makeServiceRoutingReader(ServiceRoutingConfigSchema);
22
20
 
23
- export async function resolveAppBaseUrl(): Promise<string> {
24
- const hit = appUrlCache.get("default");
25
- if (hit) return hit;
26
- try {
27
- const config = await loadRegistryConfig("service_install", "scala-projects", ServiceInstallConfigSchema);
28
- const url = `https://${config.public_hostname}`;
29
- appUrlCache.set("default", url);
30
- return url;
31
- } catch {
32
- appUrlCache.set("default", "https://projects.scala.business");
33
- return "https://projects.scala.business";
34
- }
21
+ export function resolveAppBaseUrl(): Promise<string> {
22
+ return resolveServicePublicUrl("scala-projects");
35
23
  }
@@ -1,82 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `registry-stale-inactive-sweep` pg-boss handler. Surfaces registry
7
- * rows that have been inactive beyond a threshold with no deliberate-pause
8
- * metadata — the "deactivated and forgotten" class that caused the
9
- * fleet_agent_slots miscount (operator directive 2026-07-20).
10
- *
11
- * This is a MONITOR (detection), not enforcement: it records an event_log
12
- * entry so the operator sees stale rows accumulate, but never blocks or
13
- * auto-reaps. The structural prevention is the `deactivated_at` stamp the
14
- * registry_edit deactivate path writes; this sweep is the detection
15
- * backstop that surfaces rows whose `updated_at` (the deactivation
16
- * timestamp) has aged past the threshold.
17
- *
18
- * Excluded from the sweep (deliberate deactivation — NOT stale):
19
- * - config.pause_owner / config.reactivate_when (progressive-activation paused guards)
20
- * - config.operator_deactivated = true (operator emergency off)
21
- *
22
- * Registered under slug `@teamscala/service-boot:registry-stale-inactive-sweep`.
23
- */
24
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
25
- import { getPrisma } from "@teamscala/db/prisma-registry";
26
- import { recordEvent } from "@teamscala/event-log/record-event";
27
- import { getAppLogger } from "@teamscala/logger/app-loggers";
28
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
29
-
30
- const STALE_AFTER_DAYS = 30;
31
- const SAMPLE_LIMIT = 20;
32
-
33
- interface StaleRow {
34
- type: string;
35
- slug: string;
36
- updated_at: Date;
37
- }
38
-
39
- async function registryStaleInactiveSweep(): Promise<void> {
40
- const log = getAppLogger();
41
- const prisma = getPrisma();
42
-
43
- const rows = await prisma.$queryRawUnsafe<StaleRow[]>(`
44
- SELECT type, slug, updated_at
45
- FROM registry_entries
46
- WHERE is_active = false
47
- AND updated_at < NOW() - INTERVAL '${STALE_AFTER_DAYS} days'
48
- AND NOT (config ? 'pause_owner')
49
- AND NOT (config ? 'reactivate_when')
50
- AND NOT (config->>'operator_deactivated' = 'true')
51
- ORDER BY updated_at ASC
52
- `);
53
-
54
- const byType = new Map<string, number>();
55
- for (const r of rows) {
56
- byType.set(r.type, (byType.get(r.type) ?? 0) + 1);
57
- }
58
-
59
- const samples = rows
60
- .slice(0, SAMPLE_LIMIT)
61
- .map((r) => `${r.type}/${r.slug}(${r.updated_at.toISOString().slice(0, 10)})`);
62
-
63
- recordEvent({
64
- kind: "registry.stale-inactive.detected",
65
- payload: {
66
- totalStale: rows.length,
67
- staleAfterDays: STALE_AFTER_DAYS,
68
- byType: Object.fromEntries(byType),
69
- samples,
70
- },
71
- });
72
-
73
- log.info(
74
- `[registry-stale-inactive-sweep] ${rows.length} stale inactive row(s) (>${STALE_AFTER_DAYS}d, no pause metadata) — recorded to event_log`,
75
- { byType: Object.fromEntries(byType), samples },
76
- );
77
- }
78
-
79
- pgBossHandlerRegistry.register(
80
- "@teamscala/service-boot:registry-stale-inactive-sweep",
81
- registryStaleInactiveSweep as (ctx: { config: ScheduledJobConfig }) => Promise<void>,
82
- );
@@ -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);