@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.
- package/package.json +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- 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);
|