@teamscala/service-boot 0.23.431 → 0.23.433
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 +12 -19
- package/src/agreement-gate/signing-shell.ts +19 -15
- package/src/attach-proposal-accept.ts +5 -2
- package/src/auth-gate/public-paths.ts +7 -16
- package/src/auth-gate.ts +45 -42
- package/src/bind-page-handler.ts +17 -32
- package/src/build-client-assets/build-lock.ts +15 -6
- package/src/build-client-assets/hashed-output.ts +3 -1
- package/src/build-client-assets/hydrate-bundle.ts +50 -57
- package/src/build-client-assets/input-fingerprint.ts +41 -57
- package/src/build-client-assets/input-hashing.ts +2 -4
- package/src/build-client-assets/manifest.ts +8 -11
- package/src/build-client-assets/source-candidates.ts +8 -16
- package/src/build-client-assets/sourcemaps-upload.ts +14 -10
- package/src/build-client-assets/styles-build.ts +20 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +13 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +20 -18
- package/src/content-gate/gate.ts +4 -3
- package/src/discover-blocks.ts +5 -7
- package/src/fn-routers/agreement-certificate.ts +1 -1
- package/src/fn-routers/agreement-send-router.ts +5 -4
- package/src/fn-routers/agreement-sign-side-effects.ts +24 -22
- package/src/fn-routers/assessment-report-email.ts +3 -2
- package/src/fn-routers/auth-router.ts +20 -21
- package/src/fn-routers/chat-session-router-shared.ts +12 -3
- package/src/fn-routers/chat-session-router.ts +8 -6
- package/src/fn-routers/conversation-ownership.ts +3 -2
- package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
- package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
- package/src/fn-routers/persist-chat-messages.ts +6 -4
- package/src/fn-routers/proposal-send-router.ts +4 -3
- package/src/fn-routers/public-agreement-router.ts +5 -4
- package/src/fn-routers/public-assessment-router.ts +5 -4
- package/src/fn-routers/public-gpt-session-router.ts +17 -12
- package/src/fn-routers/user-preferences-router.ts +6 -15
- package/src/fn-routers/work-items-router.ts +18 -17
- package/src/handlers/platform-fault-escalation/orpc-client.ts +35 -18
- package/src/handlers/projects-digest.ts +1 -1
- package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
- package/src/handlers/scala-tools-currency-watch.ts +20 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +21 -19
- package/src/handlers/upstream-issue-sync.ts +11 -4
- package/src/handlers/whatsapp-liveness.ts +14 -4
- package/src/install-harness.ts +1 -1
- package/src/install-logging.ts +1 -1
- package/src/mount-orpc-router.test.ts +56 -10
- package/src/mount-orpc-router.ts +23 -9
- package/src/mount-server-packages.ts +14 -15
- package/src/pg-boss-mount.ts +5 -12
- package/src/proposal-gate.ts +7 -2
- package/src/read-html-template.test.ts +12 -15
- package/src/read-html-template.ts +2 -1
- package/src/readiness-gate.ts +2 -1
- package/src/register-crash-handlers.ts +4 -8
- package/src/register-page-server-client.ts +12 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +13 -12
- package/src/register-registry-page-route/apply-page-branding.ts +59 -56
- package/src/register-registry-page-route/graph-cache.ts +19 -18
- package/src/register-registry-page-route/meta-title.ts +21 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +3 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +2 -3
- package/src/register-registry-page-route/run-content-gates.ts +2 -4
- package/src/register-registry-page-route.ts +1 -2
- package/src/register-static-assets.ts +6 -5
- package/src/resolve-app-base-url.ts +12 -5
- package/src/resolvers/portal-route-paths.ts +12 -11
- package/src/resource-chat-inject.ts +75 -72
- package/src/resource-not-found.ts +20 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +52 -29
- package/src/two-phase-minimal-app.ts +33 -31
- package/src/two-phase-readiness.ts +41 -38
- package/src/ui-service.ts +41 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- package/src/handlers/prune-stale-data.ts +0 -110
|
@@ -11,20 +11,6 @@
|
|
|
11
11
|
* byte-for-byte move; import paths adjusted only.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
15
|
-
import * as v from "valibot";
|
|
16
|
-
|
|
17
|
-
let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
|
|
18
|
-
export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
19
|
-
if (cachedSystemIdentity) return cachedSystemIdentity;
|
|
20
|
-
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
21
|
-
cachedSystemIdentity = {
|
|
22
|
-
userId: row?.system_user_id as string | undefined,
|
|
23
|
-
organisationId: row?.system_org_id as string | undefined,
|
|
24
|
-
};
|
|
25
|
-
return cachedSystemIdentity;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
14
|
/**
|
|
29
15
|
* The system-authed in-process ORPC client, built LAZILY on first use from the
|
|
30
16
|
* SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
|
|
@@ -40,9 +26,39 @@ export async function getSystemIdentity(): Promise<{ userId: string | undefined;
|
|
|
40
26
|
* the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
|
|
41
27
|
* never Date instances (the generated valibot schemas require it).
|
|
42
28
|
*/
|
|
43
|
-
|
|
29
|
+
|
|
30
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
31
|
+
import * as v from "valibot";
|
|
32
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
33
|
+
|
|
34
|
+
const systemIdentityCache = createCache<{
|
|
35
|
+
userId: string | undefined;
|
|
36
|
+
organisationId: string | undefined;
|
|
37
|
+
}>("service-boot:fault-escalation-system-identity", {
|
|
38
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
39
|
+
maxSize: 1,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
43
|
+
const hit = systemIdentityCache.get("default");
|
|
44
|
+
if (hit) return hit;
|
|
45
|
+
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
46
|
+
const identity = {
|
|
47
|
+
userId: row?.system_user_id as string | undefined,
|
|
48
|
+
organisationId: row?.system_org_id as string | undefined,
|
|
49
|
+
};
|
|
50
|
+
systemIdentityCache.set("default", identity);
|
|
51
|
+
return identity;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const orpcSurfaceCache = createCache<OrpcSurface>(
|
|
55
|
+
"service-boot:fault-escalation-orpc",
|
|
56
|
+
{ ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
|
|
57
|
+
);
|
|
58
|
+
|
|
44
59
|
export async function orpc(): Promise<OrpcSurface> {
|
|
45
|
-
|
|
60
|
+
const hit = orpcSurfaceCache.get("default");
|
|
61
|
+
if (hit) return hit;
|
|
46
62
|
const { createRouterClient } = await import("@orpc/server");
|
|
47
63
|
const { getAppRouter } = await import("@teamscala/orpc/app-router");
|
|
48
64
|
const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
|
|
@@ -61,7 +77,7 @@ export async function orpc(): Promise<OrpcSurface> {
|
|
|
61
77
|
"platform-identity",
|
|
62
78
|
PlatformIdentitySchema,
|
|
63
79
|
);
|
|
64
|
-
|
|
80
|
+
const surface = createRouterClient(
|
|
65
81
|
{ ...appRouter } as never,
|
|
66
82
|
{
|
|
67
83
|
context: {
|
|
@@ -74,7 +90,8 @@ export async function orpc(): Promise<OrpcSurface> {
|
|
|
74
90
|
},
|
|
75
91
|
} as never,
|
|
76
92
|
) as unknown as OrpcSurface;
|
|
77
|
-
|
|
93
|
+
orpcSurfaceCache.set("default", surface);
|
|
94
|
+
return surface;
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
/** The router subset this handler uses — mirrors the generated procedure args. */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The `projects-daily-digest` / `projects-weekly-digest` pg-boss handlers for
|
|
7
|
-
* scala-projects.
|
|
7
|
+
* scala-projects. Fixed-query shape: no row-driven
|
|
8
8
|
* SQL, every query is a literal Prisma call against work_items; the
|
|
9
9
|
* scheduled_job row only carries the cron.
|
|
10
10
|
*
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -20,10 +19,7 @@ import { join } from "node:path";
|
|
|
20
19
|
* the module proves it cannot fire on any path. It also stays honest if someone
|
|
21
20
|
* reintroduces the restart behind a new condition.
|
|
22
21
|
*/
|
|
23
|
-
const SOURCE =
|
|
24
|
-
join(import.meta.dir, "scala-tools-currency-watch.ts"),
|
|
25
|
-
"utf-8",
|
|
26
|
-
);
|
|
22
|
+
const SOURCE = await Bun.file(join(import.meta.dir, "scala-tools-currency-watch.ts")).text();
|
|
27
23
|
|
|
28
24
|
/** Comment prose legitimately discusses the removed restart; code must not do it. */
|
|
29
25
|
const CODE = SOURCE.split("\n")
|
|
@@ -25,6 +25,26 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Registered under slug `@teamscala/service-boot:scala-tools-currency-watch`.
|
|
27
27
|
*/
|
|
28
|
+
// NO RESTART. The reinstall alone is sufficient, and restarting here was actively harmful.
|
|
29
|
+
//
|
|
30
|
+
// The premise this watch was written on — "restart so the fresh scala-tools loads" — does not hold for THIS package. scala-sentinel never
|
|
31
|
+
// imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools` per invocation, so a freshly installed binary is picked up by the next
|
|
32
|
+
// spawn with no process restart at all. Verified 2026-07-27: the package is declared in scala-sentinel/package.json, yet scala-guard/src contains zero
|
|
33
|
+
// import statements resolving to it — it is spawned, never linked. dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
|
|
34
|
+
// maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED sibling map exists for packages a daemon really does load at boot
|
|
35
|
+
// (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
|
|
36
|
+
//
|
|
37
|
+
// The cost was not theoretical. Restarting the guard daemon takes enforcement offline for its warmup, and every shutdown logs "152 guard(s)
|
|
38
|
+
// failed to run (fail-open)" — so each unnecessary restart opened a window in which tool-check, turn-end and pre-push silently did not run. Three
|
|
39
|
+
// scala-tools versions published on 2026-07-26 alone, and this watch polls, so it re-fired for each. That inverts
|
|
40
|
+
// `infrastructure-outranks-application-durability`: a currency convenience was repeatedly disabling the thing it exists to keep current.
|
|
41
|
+
//
|
|
42
|
+
// It also used a bare `pm2 restart`, which is forbidden (`forbid-bare-pm2-restart`) precisely because it bypasses the
|
|
43
|
+
// currency-gated canonical pipeline — the guard blocks agents from that exact call.
|
|
44
|
+
//
|
|
45
|
+
// If a future package genuinely needs a restart to take effect, it does not belong here: add it to dep-sync's PM2_CACHED map, which already owns
|
|
46
|
+
// install-plus-restart and coalesces them.
|
|
47
|
+
|
|
28
48
|
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
29
49
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
30
50
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -124,38 +144,6 @@ async function scalaToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Pr
|
|
|
124
144
|
payload: { from: installed, to: latest },
|
|
125
145
|
});
|
|
126
146
|
|
|
127
|
-
// NO RESTART. The reinstall alone is sufficient, and restarting here was
|
|
128
|
-
// actively harmful.
|
|
129
|
-
//
|
|
130
|
-
// The premise this watch was written on — "restart so the fresh
|
|
131
|
-
// scala-tools loads" — does not hold for THIS package. scala-sentinel never
|
|
132
|
-
// imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools`
|
|
133
|
-
// per invocation, so a freshly installed binary is picked up by the next
|
|
134
|
-
// spawn with no process restart at all. Verified 2026-07-27: the package is
|
|
135
|
-
// declared in scala-sentinel/package.json, yet scala-guard/src contains zero
|
|
136
|
-
// import statements resolving to it — it is spawned, never linked.
|
|
137
|
-
// dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
|
|
138
|
-
// maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED
|
|
139
|
-
// sibling map exists for packages a daemon really does load at boot
|
|
140
|
-
// (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
|
|
141
|
-
//
|
|
142
|
-
// The cost was not theoretical. Restarting the guard daemon takes
|
|
143
|
-
// enforcement offline for its warmup, and every shutdown logs "152 guard(s)
|
|
144
|
-
// failed to run (fail-open)" — so each unnecessary restart opened a window
|
|
145
|
-
// in which tool-check, turn-end and pre-push silently did not run. Three
|
|
146
|
-
// scala-tools versions published on 2026-07-26 alone, and this watch polls,
|
|
147
|
-
// so it re-fired for each. That inverts
|
|
148
|
-
// `infrastructure-outranks-application-durability`: a currency convenience
|
|
149
|
-
// was repeatedly disabling the thing it exists to keep current.
|
|
150
|
-
//
|
|
151
|
-
// It also used a bare `pm2 restart`, which is forbidden
|
|
152
|
-
// (`forbid-bare-pm2-restart`) precisely because it bypasses the
|
|
153
|
-
// currency-gated canonical pipeline — the guard blocks agents from that
|
|
154
|
-
// exact call.
|
|
155
|
-
//
|
|
156
|
-
// If a future package genuinely needs a restart to take effect, it does not
|
|
157
|
-
// belong here: add it to dep-sync's PM2_CACHED map, which already owns
|
|
158
|
-
// install-plus-restart and coalesces them.
|
|
159
147
|
log.info(
|
|
160
148
|
`[scala-tools-currency-watch] reinstalled ${PKG} ${installed} → ${latest} (no restart: sentinel spawns the binary, it does not import it)`,
|
|
161
149
|
);
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
|
|
5
|
-
const SOURCE =
|
|
6
|
-
join(import.meta.dir, "singleton-tools-currency-watch.ts"),
|
|
7
|
-
"utf-8",
|
|
8
|
-
);
|
|
4
|
+
const SOURCE = await Bun.file(join(import.meta.dir, "singleton-tools-currency-watch.ts")).text();
|
|
9
5
|
|
|
10
6
|
const CODE = SOURCE.split("\n")
|
|
11
7
|
.filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*"))
|
|
@@ -23,14 +23,6 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Registered under slug `@teamscala/service-boot:singleton-tools-currency-watch`.
|
|
25
25
|
*/
|
|
26
|
-
import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
|
|
27
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
28
|
-
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
29
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
30
|
-
import { spawn } from "@teamscala/os/spawn";
|
|
31
|
-
import { getDistTagVersion } from "@teamscala/publish-registry";
|
|
32
|
-
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
33
|
-
|
|
34
26
|
/**
|
|
35
27
|
* Where a tool's "latest" comes from.
|
|
36
28
|
*
|
|
@@ -47,6 +39,27 @@ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
|
47
39
|
* never shadow a /usr/local/bin singleton wrapper. The gap was currency
|
|
48
40
|
* tracking, never placement.
|
|
49
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
|
+
|
|
50
63
|
type VersionSource =
|
|
51
64
|
| {
|
|
52
65
|
type: "npm_package";
|
|
@@ -210,17 +223,6 @@ async function reinstallTool(tool: SingletonTool, latest: string): Promise<boole
|
|
|
210
223
|
if (!vs) return false;
|
|
211
224
|
|
|
212
225
|
if (vs.type === "github_release") {
|
|
213
|
-
// A GitHub-released binary is NOT installed by a package manager, so there
|
|
214
|
-
// is no "reinstall" verb to call — it would have to be downloaded, verified,
|
|
215
|
-
// made executable and swapped atomically over a file that may be RUNNING.
|
|
216
|
-
// That is a real install path with real failure modes (partial download,
|
|
217
|
-
// wrong architecture, replacing a binary mid-execution), and doing it badly
|
|
218
|
-
// is worse than not doing it: a half-written CLI is a broken CLI.
|
|
219
|
-
//
|
|
220
|
-
// So this reports the drift and stops. Detection is live from today; the
|
|
221
|
-
// automated swap is a separate, deliberate change. Reporting a version the
|
|
222
|
-
// watcher cannot actually install is honest — silently claiming currency
|
|
223
|
-
// would not be.
|
|
224
226
|
log.warn(
|
|
225
227
|
`[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
|
|
226
228
|
);
|
|
@@ -32,6 +32,7 @@ import { spawn } from "@teamscala/os/spawn";
|
|
|
32
32
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
33
33
|
import * as v from "valibot";
|
|
34
34
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
35
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
35
36
|
|
|
36
37
|
interface GhIssue {
|
|
37
38
|
state: string;
|
|
@@ -52,12 +53,18 @@ interface CapturedResponse {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const DEFAULT_GH_TIMEOUT_MS = 20_000;
|
|
55
|
-
|
|
56
|
+
const ghTimeoutCache = createCache<number>("service-boot:gh-timeout", {
|
|
57
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
58
|
+
maxSize: 1,
|
|
59
|
+
});
|
|
60
|
+
|
|
56
61
|
async function getGhTimeoutMs(): Promise<number> {
|
|
57
|
-
|
|
62
|
+
const hit = ghTimeoutCache.get("default");
|
|
63
|
+
if (hit !== undefined) return hit;
|
|
58
64
|
const row = await loadRegistryConfig("config", "upstream-issue-sync", v.looseObject({}));
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
const timeoutMs = (row?.gh_timeout_ms as number | undefined) ?? DEFAULT_GH_TIMEOUT_MS;
|
|
66
|
+
ghTimeoutCache.set("default", timeoutMs);
|
|
67
|
+
return timeoutMs;
|
|
61
68
|
}
|
|
62
69
|
const EXCERPT_MAX = 160;
|
|
63
70
|
|
|
@@ -32,6 +32,7 @@ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
|
32
32
|
import { detectLivenessTransitions, type WhatsAppDeviceState } from "./whatsapp-liveness-logic.ts";
|
|
33
33
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
34
34
|
import * as v from "valibot";
|
|
35
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
35
36
|
|
|
36
37
|
/** In-process last-known device state, keyed by GOWA device id. */
|
|
37
38
|
const lastKnown = new Map<string, string>();
|
|
@@ -46,18 +47,27 @@ interface GowaListDevicesResponse {
|
|
|
46
47
|
}>;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
const systemIdentityCache = createCache<{
|
|
51
|
+
userId: string | undefined;
|
|
52
|
+
organisationId: string | undefined;
|
|
53
|
+
}>("service-boot:whatsapp-liveness-system-identity", {
|
|
54
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
55
|
+
maxSize: 1,
|
|
56
|
+
});
|
|
57
|
+
|
|
50
58
|
/** The system-caller identity for platform-action tool dispatches — shared by
|
|
51
59
|
* the sibling handlers that dispatch GOWA reads through the central /mcp
|
|
52
60
|
* (messaging-send-recording-watch reconcile sweep). */
|
|
53
61
|
export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
|
|
54
|
-
|
|
62
|
+
const hit = systemIdentityCache.get("default");
|
|
63
|
+
if (hit) return hit;
|
|
55
64
|
const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
|
|
56
|
-
|
|
65
|
+
const identity = {
|
|
57
66
|
userId: row?.system_user_id as string | undefined,
|
|
58
67
|
organisationId: row?.system_org_id as string | undefined,
|
|
59
68
|
};
|
|
60
|
-
|
|
69
|
+
systemIdentityCache.set("default", identity);
|
|
70
|
+
return identity;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export async function pollWhatsAppLiveness({
|
package/src/install-harness.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @system server-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit
|
|
5
|
+
* @indivisible-unit owner=unassigned reason=81 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
6
|
*
|
|
7
7
|
* Install harness — sandbox-mounts each declared package and captures
|
|
8
8
|
* registry.require() calls via AsyncLocalStorage. Validates all required
|
package/src/install-logging.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @system server-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit
|
|
5
|
+
* @indivisible-unit owner=unassigned reason=188 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
6
|
*
|
|
7
7
|
* One-call install of the entire logging pipeline. After installLogging()
|
|
8
8
|
* runs, every error in the service — process-level, HTTP middleware, Hono
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* service, so production boot must fail before it can report healthy.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
12
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { mountOrpcRouter } from "./mount-orpc-router.ts";
|
|
@@ -17,22 +17,19 @@ import { mountOrpcRouter } from "./mount-orpc-router.ts";
|
|
|
17
17
|
const originalCwd = process.cwd();
|
|
18
18
|
const tempDirs: string[] = [];
|
|
19
19
|
|
|
20
|
-
afterEach(() => {
|
|
20
|
+
afterEach(async () => {
|
|
21
21
|
process.chdir(originalCwd);
|
|
22
22
|
for (const directory of tempDirs.splice(0)) {
|
|
23
|
-
|
|
23
|
+
await rm(directory, { recursive: true, force: true });
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
describe("mountOrpcRouter generated-artifact seam", () => {
|
|
28
28
|
test("fails when schema exists but generated router is absent", async () => {
|
|
29
|
-
const service =
|
|
29
|
+
const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
|
|
30
30
|
tempDirs.push(service);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
join(service, "prisma/schema.prisma"),
|
|
34
|
-
'datasource db { provider = "postgresql" }',
|
|
35
|
-
);
|
|
31
|
+
await mkdir(join(service, "prisma"), { recursive: true });
|
|
32
|
+
await Bun.file(join(service, "prisma/schema.prisma")).write('datasource db { provider = "postgresql" }');
|
|
36
33
|
process.chdir(service);
|
|
37
34
|
|
|
38
35
|
await expect(
|
|
@@ -43,4 +40,53 @@ describe("mountOrpcRouter generated-artifact seam", () => {
|
|
|
43
40
|
" but the generated ORPC router is missing",
|
|
44
41
|
);
|
|
45
42
|
});
|
|
43
|
+
|
|
44
|
+
test("the configured getMcpGatewayUrl returns undefined without throwing (ctx regression)", async () => {
|
|
45
|
+
// Regression: the previous form read an out-of-scope `ctx` (commit
|
|
46
|
+
// c39d279) and threw ReferenceError on first invoke. A getter that
|
|
47
|
+
// crashes when called can hide indefinitely — the regression is a test
|
|
48
|
+
// that actually CALLS it. Returning undefined explicitly matches what
|
|
49
|
+
// callers (sdk-mcp-forwarder) already handle: an absent gateway URL
|
|
50
|
+
// means "SDK tools unusable in this service".
|
|
51
|
+
const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
|
|
52
|
+
tempDirs.push(service);
|
|
53
|
+
await mkdir(join(service, "prisma/generated/orpc/routers"), {
|
|
54
|
+
recursive: true,
|
|
55
|
+
});
|
|
56
|
+
await Bun.file(join(service, "prisma/schema.prisma")).write(
|
|
57
|
+
'datasource db { provider = "postgresql" }',
|
|
58
|
+
);
|
|
59
|
+
await Bun.file(
|
|
60
|
+
join(service, "prisma/generated/orpc/routers/index.ts"),
|
|
61
|
+
).write(
|
|
62
|
+
"export const router = { healthz: { health: async () => ({ ok: true }) } };\n",
|
|
63
|
+
);
|
|
64
|
+
process.chdir(service);
|
|
65
|
+
|
|
66
|
+
// Stub the collaborators the mount reaches BEFORE configureToolExecutor
|
|
67
|
+
// — the point is to capture the opts object and call the getter.
|
|
68
|
+
const capture: { getMcpGatewayUrl?: () => unknown } = {};
|
|
69
|
+
mock.module("@teamscala/orpc/app-router", () => ({
|
|
70
|
+
getAppRouter: async () => ({
|
|
71
|
+
healthz: { health: async () => ({ ok: true }) },
|
|
72
|
+
}),
|
|
73
|
+
}));
|
|
74
|
+
mock.module("@teamscala/orpc/configure", () => ({
|
|
75
|
+
getPrisma: () => ({}),
|
|
76
|
+
}));
|
|
77
|
+
mock.module("@teamscala/tool-executor/configure", () => ({
|
|
78
|
+
configure: (opts: { getMcpGatewayUrl?: () => unknown }) => {
|
|
79
|
+
capture.getMcpGatewayUrl = opts.getMcpGatewayUrl;
|
|
80
|
+
},
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const app = { post: () => {} };
|
|
84
|
+
await mountOrpcRouter(app as never, "scala-mcp");
|
|
85
|
+
|
|
86
|
+
expect(typeof capture.getMcpGatewayUrl).toBe("function");
|
|
87
|
+
// THE regression: invoking the getter must not throw.
|
|
88
|
+
expect(() => capture.getMcpGatewayUrl!()).not.toThrow();
|
|
89
|
+
// And it returns undefined — explicit, never a ReferenceError.
|
|
90
|
+
expect(capture.getMcpGatewayUrl!()).toBeUndefined();
|
|
91
|
+
});
|
|
46
92
|
});
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* routers with custom routers registered via registerCustomRouter().
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
13
12
|
import { join } from "node:path";
|
|
14
13
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
14
|
import { registerFnRouters } from "./fn-routers/index.ts";
|
|
@@ -45,8 +44,8 @@ export async function mountOrpcRouter(
|
|
|
45
44
|
// reserved for GENUINE mount failures (router present but fails to load).
|
|
46
45
|
// A service that SHOULD have a router but doesn't is a codegen gap caught by
|
|
47
46
|
// API verification, not a boot warning.
|
|
48
|
-
if (!
|
|
49
|
-
if (
|
|
47
|
+
if (!await Bun.file(routerPath).exists()) {
|
|
48
|
+
if (await Bun.file(schemaPath).exists()) {
|
|
50
49
|
throw new Error(
|
|
51
50
|
`[scala-install:${serviceSlug}] Prisma schema exists at ${schemaPath} ` +
|
|
52
51
|
`but the generated ORPC router is missing at ${routerPath}. ` +
|
|
@@ -84,17 +83,32 @@ export async function mountOrpcRouter(
|
|
|
84
83
|
const { configure: configureToolExecutor } = await import(
|
|
85
84
|
"@teamscala/tool-executor/configure"
|
|
86
85
|
);
|
|
86
|
+
// The host-command-bus collaborators arrive here (the boot layer) —
|
|
87
|
+
// tool-executor cannot import its tier-2 siblings statically
|
|
88
|
+
// (vertical-dependency-only: a same-tier edge). Resolved eagerly at
|
|
89
|
+
// mount so the injected factories stay synchronous; the modules are
|
|
90
|
+
// small and their postgres dependency is already loaded by the db
|
|
91
|
+
// primitive.
|
|
92
|
+
const [{ createHostCommandInvoker }, { getCallerContext }, { decryptSecretConfig }] =
|
|
93
|
+
await Promise.all([
|
|
94
|
+
import("@teamscala/host-command-bus/invoker"),
|
|
95
|
+
import("@teamscala/mcp-multi-session/caller-context"),
|
|
96
|
+
import("@teamscala/encryption/crypto/secret-config"),
|
|
97
|
+
]);
|
|
87
98
|
configureToolExecutor({
|
|
88
99
|
getAppRouter: () => getAppRouter(),
|
|
89
100
|
getPrisma: () => getConfiguredPrisma(),
|
|
90
101
|
// Central MCP gateway URL — sdk:* tool calls from any non-host service
|
|
91
102
|
// forward here (scala-mcp owns every @teamscala/api-* adapter per
|
|
92
|
-
// operator ruling 2026-08-26).
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// `
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
// operator ruling 2026-08-26). Returns undefined explicitly: callers
|
|
104
|
+
// (sdk-mcp-forwarder) treat absent as "SDK tool unusable in this
|
|
105
|
+
// service" and refuse with that message. The previous form read an
|
|
106
|
+
// out-of-scope `ctx` (c39d279) and would have thrown ReferenceError
|
|
107
|
+
// on first invoke instead of returning undefined.
|
|
108
|
+
getMcpGatewayUrl: () => undefined,
|
|
109
|
+
createHostCommandInvoker: (config) => createHostCommandInvoker(config as never),
|
|
110
|
+
getCallerContext: () => getCallerContext(),
|
|
111
|
+
decryptSecretConfig: (raw) => decryptSecretConfig(raw as never),
|
|
98
112
|
});
|
|
99
113
|
|
|
100
114
|
const resolveUser = async (headers: Headers) => {
|
|
@@ -10,6 +10,20 @@
|
|
|
10
10
|
* register-ws-routes / run-boot-hook-list / start-scheduled-jobs.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Speculatively import every server_package's mount module so Bun compiles
|
|
15
|
+
* the module graphs into its cache NOW, overlapping the (CPU-bound)
|
|
16
|
+
* compilation with whatever I/O the caller runs next. When mountServerPackages
|
|
17
|
+
* later imports the same paths they hit the cache (instant).
|
|
18
|
+
*
|
|
19
|
+
* Same pattern as create-boot-pipeline's speculative preloads — the
|
|
20
|
+
* cross-internet DB I/O in the gate chain (~3.4s) is dead time the compiler
|
|
21
|
+
* can use. Best-effort + non-fatal: a failed import here (wrong candidate
|
|
22
|
+
* path, transient resolution race) is silently swallowed because
|
|
23
|
+
* mountServerPackages will re-attempt with full error surfacing; the preload
|
|
24
|
+
* only warms the cache, never gates boot.
|
|
25
|
+
*/
|
|
26
|
+
|
|
13
27
|
import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
|
|
14
28
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
29
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
@@ -31,19 +45,6 @@ function mountCandidates(pkg: string): string[] {
|
|
|
31
45
|
return [`${pkg}/mount.ts`, `${pkg}/boot/mount.ts`, pkg];
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
/**
|
|
35
|
-
* Speculatively import every server_package's mount module so Bun compiles
|
|
36
|
-
* the module graphs into its cache NOW, overlapping the (CPU-bound)
|
|
37
|
-
* compilation with whatever I/O the caller runs next. When mountServerPackages
|
|
38
|
-
* later imports the same paths they hit the cache (instant).
|
|
39
|
-
*
|
|
40
|
-
* Same pattern as create-boot-pipeline's speculative preloads — the
|
|
41
|
-
* cross-internet DB I/O in the gate chain (~3.4s) is dead time the compiler
|
|
42
|
-
* can use. Best-effort + non-fatal: a failed import here (wrong candidate
|
|
43
|
-
* path, transient resolution race) is silently swallowed because
|
|
44
|
-
* mountServerPackages will re-attempt with full error surfacing; the preload
|
|
45
|
-
* only warms the cache, never gates boot.
|
|
46
|
-
*/
|
|
47
48
|
export async function preloadServerPackages(packages: string[]): Promise<void> {
|
|
48
49
|
await Promise.allSettled(
|
|
49
50
|
packages.flatMap((pkg) =>
|
|
@@ -70,8 +71,6 @@ export async function mountServerPackages(
|
|
|
70
71
|
return mountRegistry;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
74
|
for (const pkg of serverPackages) {
|
|
76
75
|
try {
|
|
77
76
|
let mod: {
|
package/src/pg-boss-mount.ts
CHANGED
|
@@ -23,7 +23,6 @@ import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
|
|
|
23
23
|
|
|
24
24
|
// Bundled pg-boss handlers self-register on import (package-subscriber shape).
|
|
25
25
|
// External handler packages register via their own boot hook / subscriber import.
|
|
26
|
-
import "./handlers/prune-stale-data.ts";
|
|
27
26
|
import "./handlers/projects-digest.ts";
|
|
28
27
|
import "./handlers/sweep-event-log.ts";
|
|
29
28
|
// `sweep-host-cache` is Rust-native: loga (the job's service_slug) registers it
|
|
@@ -36,7 +35,6 @@ import "./handlers/upstream-issue-sync.ts";
|
|
|
36
35
|
import "./handlers/scala-tools-currency-watch.ts";
|
|
37
36
|
import "./handlers/singleton-tools-currency-watch.ts";
|
|
38
37
|
import "./handlers/registry-stale-inactive-sweep.ts";
|
|
39
|
-
import "./handlers/pane-outbox-drain.ts";
|
|
40
38
|
import "./handlers/platform-fault-escalation.ts";
|
|
41
39
|
|
|
42
40
|
/**
|
|
@@ -298,16 +296,11 @@ export async function mountPgBoss(
|
|
|
298
296
|
}
|
|
299
297
|
}
|
|
300
298
|
|
|
301
|
-
// Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
// to be one-off. Deactivation must actually stop the cron. A schedule is
|
|
307
|
-
// "backed" iff ANY active registry row's slug OR queue_name equals its name —
|
|
308
|
-
// this covers scheduled_job AND sync_job_handler (and any future
|
|
309
|
-
// schedule-owning type) without hardcoding the type list, so live sync jobs
|
|
310
|
-
// (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
|
|
299
|
+
// Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no longer corresponds to an ACTIVE registry row. Deactivating/deleting a
|
|
300
|
+
// scheduled_job (or sync_job_handler) previously left its cron lingering in pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
|
|
301
|
+
// found 2026-07-27, still scraping every 3/15min months after the job was meant to be one-off. Deactivation must actually stop the cron. A schedule is
|
|
302
|
+
// "backed" iff ANY active registry row's slug OR queue_name equals its name — this covers scheduled_job AND sync_job_handler (and any future
|
|
303
|
+
// schedule-owning type) without hardcoding the type list, so live sync jobs (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
|
|
311
304
|
// mount and reconciles GLOBALLY: an orphan is removed regardless of which
|
|
312
305
|
// service created it, since a cron with no active backing row is drift by
|
|
313
306
|
// definition. A service with zero active scheduled jobs early-returns above
|
package/src/proposal-gate.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { AccessConfig, ContentGateConfig } from "./content-gate/config-type
|
|
|
18
18
|
import { signAccessToken, verifyAccessToken } from "./content-gate/token.ts";
|
|
19
19
|
import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
|
|
20
20
|
import { propTtlMs } from "./gate-ttls.ts";
|
|
21
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
21
22
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
22
23
|
import * as v from "valibot";
|
|
23
24
|
|
|
@@ -49,15 +50,19 @@ const ProposalGateSettingsSchema = v.object({
|
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
async function loadProposalGateSettings(): Promise<ProposalGateSettings> {
|
|
53
|
+
let settingsFailed = false;
|
|
52
54
|
try {
|
|
53
55
|
return await loadRegistryConfig(
|
|
54
56
|
"config",
|
|
55
57
|
"content-gate-proposal",
|
|
56
58
|
ProposalGateSettingsSchema,
|
|
57
59
|
);
|
|
58
|
-
} catch {
|
|
59
|
-
|
|
60
|
+
} catch (error) {
|
|
61
|
+
getAppLogger().warn("[proposal-gate] settings read failed — defaults", error);
|
|
62
|
+
settingsFailed = true;
|
|
60
63
|
}
|
|
64
|
+
if (settingsFailed) return PROPOSAL_GATE_DEFAULTS;
|
|
65
|
+
return PROPOSAL_GATE_DEFAULTS;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
const PROP_COOKIE = "prop_token";
|