@teamscala/service-boot 0.23.515 → 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 +2 -2
- package/src/pg-boss-mount.ts +0 -2
- package/src/register-page-server-client.test.ts +185 -0
- package/src/register-page-server-client.ts +60 -33
- package/src/resolve-app-base-url.ts +13 -25
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/upstream-issue-sync.ts +0 -220
package/package.json
CHANGED
package/src/pg-boss-mount.ts
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @system ui-service-boot
|
|
2
|
+
// @status handwritten
|
|
3
|
+
// @edit edit directly
|
|
4
|
+
//
|
|
5
|
+
// register-page-server-client.test.ts — the declaredModels lookup must never
|
|
6
|
+
// quietly report "declares nothing".
|
|
7
|
+
//
|
|
8
|
+
// The regression this exists for (measured scala-sign 2026-09-24/25): the boot
|
|
9
|
+
// looked up service_schema with `undefined as never`, so loadRegistryConfig's
|
|
10
|
+
// `v.parse(undefined, raw)` threw a TypeError on EVERY service, the bare
|
|
11
|
+
// `catch {}` swallowed it into `declaredModels = []`, and the
|
|
12
|
+
// "declares > 0 models + no router → refuse the boot" check below never fired.
|
|
13
|
+
// A service declaring 18 models logged "0 declared model(s)", registered a
|
|
14
|
+
// router-less server client as healthy, and served 500 on every page while
|
|
15
|
+
// /health answered 200.
|
|
16
|
+
//
|
|
17
|
+
// Three assertions, each the operator's demand ("the catch must tell 'no
|
|
18
|
+
// service_schema row' apart from 'the read failed'. A read error must never
|
|
19
|
+
// quietly become 'no models'. Test both"):
|
|
20
|
+
// 1. a service that DECLARES models with no generated router → REFUSES the
|
|
21
|
+
// boot (proves the refusal actually fires now — it did not before);
|
|
22
|
+
// 2. a service with NO service_schema row → passes through clean (legitimate
|
|
23
|
+
// daemon/portal shell owns no DB models);
|
|
24
|
+
// 3. a service_schema READ that FAILS → REFUSES the boot naming the error
|
|
25
|
+
// (a read error is never treated as "declares nothing").
|
|
26
|
+
|
|
27
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
28
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
|
|
32
|
+
const originalCwd = process.cwd();
|
|
33
|
+
const tempDirs: string[] = [];
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
process.chdir(originalCwd);
|
|
37
|
+
for (const directory of tempDirs.splice(0)) {
|
|
38
|
+
await rm(directory, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The registry read is scenario-driven: ONE load-config mock serves all three
|
|
43
|
+
// tests, and each test flips the mode before calling the function. The mock
|
|
44
|
+
// function is re-invoked on every call (the target imports it dynamically
|
|
45
|
+
// inside registerPageServerClient), so switching the mode takes effect.
|
|
46
|
+
let registryMode:
|
|
47
|
+
| { kind: "row"; models: string[] }
|
|
48
|
+
| { kind: "missing" }
|
|
49
|
+
| { kind: "missing-thrown" }
|
|
50
|
+
| { kind: "read-failed"; message: string } = { kind: "missing" };
|
|
51
|
+
|
|
52
|
+
// The class identity the catch's `instanceof` compares against. The target
|
|
53
|
+
// imports RegistryConfigMissingError from @teamscala/db/registry/errors, so the
|
|
54
|
+
// SAME mocked class must back both the throw path and the instanceof check.
|
|
55
|
+
class MockRegistryConfigMissingError extends Error {
|
|
56
|
+
readonly type: string;
|
|
57
|
+
readonly slug: string;
|
|
58
|
+
constructor(type: string, slug: string) {
|
|
59
|
+
super(`[registry] Config entry "${slug}" of type "${type}" not found`);
|
|
60
|
+
this.name = "RegistryConfigMissingError";
|
|
61
|
+
this.type = type;
|
|
62
|
+
this.slug = slug;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
67
|
+
loadOptionalRegistryConfig: async () => {
|
|
68
|
+
if (registryMode.kind === "missing") return null;
|
|
69
|
+
if (registryMode.kind === "missing-thrown") {
|
|
70
|
+
throw new MockRegistryConfigMissingError("service_schema", "scala-sign");
|
|
71
|
+
}
|
|
72
|
+
if (registryMode.kind === "row") return { models: registryMode.models };
|
|
73
|
+
throw new Error(registryMode.message);
|
|
74
|
+
},
|
|
75
|
+
loadRegistryConfig: async () => ({ platformOrganisationId: "org-platform" }),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
mock.module("@teamscala/db/registry/errors", () => ({
|
|
79
|
+
RegistryConfigMissingError: MockRegistryConfigMissingError,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
mock.module("@teamscala/db-validation/registry-schemas/service-schema", () => ({
|
|
83
|
+
ServiceSchemaConfigSchema: {},
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
mock.module("@teamscala/db-validation/registry-schemas/platform-identity", () => ({
|
|
87
|
+
PlatformIdentitySchema: {},
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
91
|
+
getAppLogger: () => ({
|
|
92
|
+
info: (..._args: unknown[]) => {},
|
|
93
|
+
warn: (..._args: unknown[]) => {},
|
|
94
|
+
error: (..._args: unknown[]) => {},
|
|
95
|
+
}),
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
mock.module("@orpc/server", () => ({
|
|
99
|
+
createRouterClient: () => ({}),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
mock.module("@teamscala/db/prisma-registry", () => ({
|
|
103
|
+
getPrisma: () => ({}),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
// The page-server server-client is imported dynamically via a string-cast
|
|
107
|
+
// specifier inside the function; mock the exact specifier so setServerClient
|
|
108
|
+
// is a recording no-op and the real runtime never loads.
|
|
109
|
+
const setServerClientCalls: number[] = [];
|
|
110
|
+
mock.module("@teamscala/service-boot/page-server-orpc/server-client", () => ({
|
|
111
|
+
setServerClient: () => {
|
|
112
|
+
setServerClientCalls.push(1);
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const { registerPageServerClient } = await import(
|
|
117
|
+
"./register-page-server-client.ts"
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/** Make an empty service dir with NO generated router anywhere, and reset the
|
|
121
|
+
* setServerClient recorder so each test's count is its own. */
|
|
122
|
+
async function cwdWithoutRouter(): Promise<string> {
|
|
123
|
+
setServerClientCalls.length = 0;
|
|
124
|
+
const dir = await mkdtemp(join(tmpdir(), "register-psc-test-"));
|
|
125
|
+
tempDirs.push(dir);
|
|
126
|
+
process.chdir(dir);
|
|
127
|
+
return dir;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("registerPageServerClient — declaredModels lookup never lies", () => {
|
|
131
|
+
test("refuses the boot when the service DECLARES models but no router exists", async () => {
|
|
132
|
+
await cwdWithoutRouter();
|
|
133
|
+
registryMode = {
|
|
134
|
+
kind: "row",
|
|
135
|
+
// 18 models — the exact scala-sign row size whose absence from the
|
|
136
|
+
// boot log was the outage.
|
|
137
|
+
models: Array.from({ length: 18 }, (_unused, index) => `model_${index}`),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
await expect(registerPageServerClient("scala-sign")).rejects.toThrow(
|
|
141
|
+
/service_schema declares 18 model\(s\).*no generated ORPC router is present/s,
|
|
142
|
+
);
|
|
143
|
+
expect(setServerClientCalls.length).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("passes through clean when there is NO service_schema row", async () => {
|
|
147
|
+
await cwdWithoutRouter();
|
|
148
|
+
// loadOptionalRegistryConfig returns null — the legitimately-declares-
|
|
149
|
+
// nothing case (daemon/portal shell). With no models declared there is no
|
|
150
|
+
// router required, so the boot must NOT refuse.
|
|
151
|
+
registryMode = { kind: "missing" };
|
|
152
|
+
|
|
153
|
+
await expect(registerPageServerClient("some-daemon-shell")).resolves.toBeUndefined();
|
|
154
|
+
expect(setServerClientCalls.length).toBeGreaterThan(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("refuses the boot when the service_schema READ fails, naming the error", async () => {
|
|
158
|
+
await cwdWithoutRouter();
|
|
159
|
+
// A read error (NOT a missing row) must fail the boot, never degrade to
|
|
160
|
+
// "declares nothing" — otherwise a data-backed service boots "0 models"
|
|
161
|
+
// and 500s on every page.
|
|
162
|
+
registryMode = { kind: "read-failed", message: "connection refused: db down" };
|
|
163
|
+
|
|
164
|
+
await expect(registerPageServerClient("scala-sign")).rejects.toThrow(
|
|
165
|
+
/service_schema read FAILED for "scala-sign"/,
|
|
166
|
+
);
|
|
167
|
+
await expect(registerPageServerClient("scala-sign")).rejects.toThrow(
|
|
168
|
+
/connection refused: db down/,
|
|
169
|
+
);
|
|
170
|
+
expect(setServerClientCalls.length).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("treats a thrown RegistryConfigMissingError as declares-nothing, not failure", async () => {
|
|
174
|
+
await cwdWithoutRouter();
|
|
175
|
+
// The read path throws RegistryConfigMissingError (a genuinely-absent row,
|
|
176
|
+
// the same signal as a null return, just a different code path): the catch
|
|
177
|
+
// must classify it as "declares nothing" via the instanceof check and boot
|
|
178
|
+
// CLEAN — this is exactly the branch that must NOT fail the boot. Contrast
|
|
179
|
+
// the read-failed test above, where an unrelated error DOES fail the boot.
|
|
180
|
+
registryMode = { kind: "missing-thrown" };
|
|
181
|
+
|
|
182
|
+
await expect(registerPageServerClient("scala-sign")).resolves.toBeUndefined();
|
|
183
|
+
expect(setServerClientCalls.length).toBeGreaterThan(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -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
|
|
@@ -65,37 +71,58 @@ export async function registerPageServerClient(
|
|
|
65
71
|
);
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
|
-
const declaredModels = await (async () => {
|
|
74
|
+
const declaredModels = await (async (): Promise<string[]> => {
|
|
75
|
+
const capSlug = toCapabilitySlug(serviceSlug);
|
|
76
|
+
// LOOK IT UP BY CAPABILITY, NOT BY DEPLOYABLE NAME. A service_schema
|
|
77
|
+
// row describes the APPLICATION's data surface, so there is ONE row
|
|
78
|
+
// for both lanes; the dev lane arrives here as
|
|
79
|
+
// `scala-projects-bun-dev`. Both sides normalize through
|
|
80
|
+
// toCapabilitySlug, so the join is tier-agnostic by construction.
|
|
81
|
+
// See ./capability-slug.ts.
|
|
82
|
+
//
|
|
83
|
+
// READ WITH THE REAL SCHEMA, NOT `undefined`. The previous call passed
|
|
84
|
+
// `undefined as never`, so v.parse(undefined, raw) threw a TypeError
|
|
85
|
+
// (schema is not a schema) on EVERY service, the bare catch below turned
|
|
86
|
+
// that into "declares nothing", and the refusal at the bottom NEVER
|
|
87
|
+
// fired — a service declaring 18 models booted "0 declared model(s)"
|
|
88
|
+
// and served 500s. (measured scala-sign 2026-09-24/25: row declares 18,
|
|
89
|
+
// boot logged 0.) loadOptionalRegistryConfig parses against
|
|
90
|
+
// ServiceSchemaConfigSchema and returns null ONLY for a missing row.
|
|
91
|
+
//
|
|
92
|
+
// A MISSING ROW is legitimately "declares nothing" (daemon/portal
|
|
93
|
+
// shells own no DB models) and passes through clean. A READ FAILURE is
|
|
94
|
+
// NOT "declares nothing" — it fails the boot naming the error, so the
|
|
95
|
+
// database can never quietly become "no models" and let a router-less,
|
|
96
|
+
// data-backed service boot and serve 500s
|
|
97
|
+
// (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
98
|
+
const { loadOptionalRegistryConfig } = await import(
|
|
99
|
+
"@teamscala/db/registry/load-config"
|
|
100
|
+
);
|
|
101
|
+
const { RegistryConfigMissingError } = await import(
|
|
102
|
+
"@teamscala/db/registry/errors"
|
|
103
|
+
);
|
|
104
|
+
const { ServiceSchemaConfigSchema } = await import(
|
|
105
|
+
"@teamscala/db-validation/registry-schemas/service-schema"
|
|
106
|
+
);
|
|
69
107
|
try {
|
|
70
|
-
const
|
|
71
|
-
"@teamscala/db/registry/load-config"
|
|
72
|
-
);
|
|
73
|
-
// serviceSlug is runtime-dynamic (the service being booted), so it
|
|
74
|
-
// cannot satisfy the generated ServiceSchemaKey union statically.
|
|
75
|
-
//
|
|
76
|
-
// LOOK IT UP BY CAPABILITY, NOT BY DEPLOYABLE NAME. A service_schema
|
|
77
|
-
// row describes the APPLICATION's data surface, so there is ONE row
|
|
78
|
-
// for both lanes; the dev lane arrives here as
|
|
79
|
-
// `scala-projects-bun-dev` and matched nothing. The catch below then
|
|
80
|
-
// read "declares nothing" and the refusal fifteen lines down never
|
|
81
|
-
// fired — so a router-less client registered as healthy and every
|
|
82
|
-
// data-backed page 500ed with MissingLoaderModelCapabilityError,
|
|
83
|
-
// which is precisely the outage this refusal exists to prevent
|
|
84
|
-
// (measured 2026-09-11 on scala-projects-bun-dev: "0 declared
|
|
85
|
-
// model(s)" against a row declaring 31). See ./capability-slug.ts.
|
|
86
|
-
const row = (await loadCfg(
|
|
108
|
+
const row = await loadOptionalRegistryConfig(
|
|
87
109
|
"service_schema",
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
capSlug as never,
|
|
111
|
+
ServiceSchemaConfigSchema,
|
|
112
|
+
);
|
|
113
|
+
if (row === null) return [];
|
|
114
|
+
return Array.isArray(row.models) ? row.models : [];
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (err instanceof RegistryConfigMissingError) return [];
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[scala-install:${serviceSlug}] service_schema read FAILED for ` +
|
|
119
|
+
`"${capSlug}" — this is NOT treated as "declares nothing". A ` +
|
|
120
|
+
`registry read error must never quietly become an empty model ` +
|
|
121
|
+
`set, because a data-backed service with no generated ORPC ` +
|
|
122
|
+
`router would otherwise boot healthy and 500 on every page. ` +
|
|
123
|
+
`Underlying error: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
|
|
124
|
+
{ cause: err },
|
|
125
|
+
);
|
|
99
126
|
}
|
|
100
127
|
})();
|
|
101
128
|
if (declaredModels.length > 0 && !appRouter) {
|
|
@@ -104,10 +131,10 @@ export async function registerPageServerClient(
|
|
|
104
131
|
`model(s) (${declaredModels.slice(0, 5).join(", ")}${declaredModels.length > 5 ? ", …" : ""}) ` +
|
|
105
132
|
`but no generated ORPC router is present at ${routerPath}. Every data-backed page ` +
|
|
106
133
|
`would 500 with MissingLoaderModelCapabilityError while /health reported 200. ` +
|
|
107
|
-
`This is a BUILD fault:
|
|
108
|
-
`
|
|
109
|
-
`
|
|
110
|
-
`
|
|
134
|
+
`This is a BUILD fault: the codegen_output emission for the ORPC router ` +
|
|
135
|
+
`(prisma/generated/orpc/routers/index.ts) is missing from the bundle — check that ` +
|
|
136
|
+
`the service's codegen_output rows are active and their outputs land in the image. ` +
|
|
137
|
+
`Rebuild via scala-pack; never patch the image by hand.`,
|
|
111
138
|
);
|
|
112
139
|
}
|
|
113
140
|
|
|
@@ -3,33 +3,21 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
* The scala-projects app's own public
|
|
7
|
-
* row
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
|
19
|
-
ttlMs: Number.POSITIVE_INFINITY,
|
|
20
|
-
maxSize: 1,
|
|
21
|
-
});
|
|
19
|
+
const { resolveServicePublicUrl } = makeServiceRoutingReader(ServiceRoutingConfigSchema);
|
|
22
20
|
|
|
23
|
-
export
|
|
24
|
-
|
|
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);
|