@teamscala/service-boot 0.23.430 → 0.23.432
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 +54 -42
- package/src/bind-page-handler.ts +20 -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 +59 -57
- package/src/build-client-assets/input-fingerprint.ts +50 -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 +17 -10
- package/src/build-client-assets/styles-build.ts +17 -19
- package/src/build-client-assets.test.ts +2 -2
- package/src/build-client-assets.ts +16 -22
- package/src/content-gate/access.ts +5 -2
- package/src/content-gate/catalog.ts +26 -18
- package/src/content-gate/gate.ts +7 -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 +27 -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 +21 -17
- package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
- package/src/handlers/platform-fault-escalation-logic.ts +26 -5
- package/src/handlers/platform-fault-escalation.test.ts +67 -15
- package/src/handlers/platform-fault-escalation.ts +16 -0
- 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 +23 -32
- package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
- package/src/handlers/singleton-tools-currency-watch.ts +27 -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 +17 -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 +15 -19
- package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
- package/src/register-registry-page-route/apply-page-branding.ts +68 -56
- package/src/register-registry-page-route/graph-cache.ts +22 -18
- package/src/register-registry-page-route/meta-title.ts +24 -20
- package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
- package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -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 +15 -11
- package/src/resource-chat-inject.ts +84 -72
- package/src/resource-not-found.ts +23 -19
- package/src/scala-tools-currency-watchdog.ts +41 -12
- package/src/two-phase-full-app.ts +61 -29
- package/src/two-phase-minimal-app.ts +39 -31
- package/src/two-phase-readiness.ts +50 -38
- package/src/ui-service.ts +50 -38
- package/src/handlers/pane-outbox-drain.ts +0 -39
- package/src/handlers/prune-stale-data.ts +0 -110
|
@@ -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,23 @@
|
|
|
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
|
+
|
|
27
|
+
// Why: Speculatively import every server_package's mount module so …
|
|
28
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
29
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
13
30
|
import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
|
|
14
31
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
32
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
@@ -31,19 +48,6 @@ function mountCandidates(pkg: string): string[] {
|
|
|
31
48
|
return [`${pkg}/mount.ts`, `${pkg}/boot/mount.ts`, pkg];
|
|
32
49
|
}
|
|
33
50
|
|
|
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
51
|
export async function preloadServerPackages(packages: string[]): Promise<void> {
|
|
48
52
|
await Promise.allSettled(
|
|
49
53
|
packages.flatMap((pkg) =>
|
|
@@ -70,8 +74,6 @@ export async function mountServerPackages(
|
|
|
70
74
|
return mountRegistry;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
|
|
74
|
-
|
|
75
77
|
for (const pkg of serverPackages) {
|
|
76
78
|
try {
|
|
77
79
|
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";
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* hydration. These tests pin that a manifest change re-substitutes the HTML.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
14
15
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { readHtmlTemplate } from "./read-html-template.ts";
|
|
@@ -20,28 +20,25 @@ import { readHtmlTemplate } from "./read-html-template.ts";
|
|
|
20
20
|
let dir: string;
|
|
21
21
|
let origCwd: string;
|
|
22
22
|
|
|
23
|
-
function writeManifest(hydrate: string, styles: string): void {
|
|
24
|
-
|
|
25
|
-
join(dir, "dist", "client", "asset-manifest.json"),
|
|
26
|
-
`${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`,
|
|
27
|
-
);
|
|
23
|
+
async function writeManifest(hydrate: string, styles: string): Promise<void> {
|
|
24
|
+
await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write(`${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`);
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
beforeEach(() => {
|
|
27
|
+
beforeEach(async () => {
|
|
31
28
|
origCwd = process.cwd();
|
|
32
|
-
dir =
|
|
33
|
-
|
|
29
|
+
dir = await mkdtemp(join(tmpdir(), "read-html-template-"));
|
|
30
|
+
await mkdir(join(dir, "dist", "client"), { recursive: true });
|
|
34
31
|
process.chdir(dir);
|
|
35
32
|
});
|
|
36
33
|
|
|
37
|
-
afterEach(() => {
|
|
34
|
+
afterEach(async () => {
|
|
38
35
|
process.chdir(origCwd);
|
|
39
|
-
|
|
36
|
+
await rm(dir, { recursive: true, force: true });
|
|
40
37
|
});
|
|
41
38
|
|
|
42
39
|
describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
43
40
|
test("renders the shared Puck document without a per-service HTML file", async () => {
|
|
44
|
-
writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
41
|
+
await writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
45
42
|
const html = await readHtmlTemplate();
|
|
46
43
|
expect(html).toContain('<div id="__page"><!--ssr-outlet--></div>');
|
|
47
44
|
expect(html).toContain("<!--head-outlet-->");
|
|
@@ -50,21 +47,21 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
|
50
47
|
});
|
|
51
48
|
|
|
52
49
|
test("substitutes the current manifest hashes", async () => {
|
|
53
|
-
writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
50
|
+
await writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
54
51
|
const html = await readHtmlTemplate();
|
|
55
52
|
expect(html).toContain("/assets/hydrate-aaa.js");
|
|
56
53
|
expect(html).toContain("/assets/styles-aaa.css");
|
|
57
54
|
});
|
|
58
55
|
|
|
59
56
|
test("re-substitutes after a post-boot manifest change (the 404 regression)", async () => {
|
|
60
|
-
writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
|
|
57
|
+
await writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
|
|
61
58
|
const first = await readHtmlTemplate();
|
|
62
59
|
expect(first).toContain("/assets/hydrate-old.js");
|
|
63
60
|
|
|
64
61
|
// Simulate build-client-assets rebuilding: new manifest, old bundle gone.
|
|
65
62
|
// mtime must advance for the stat-keyed cache to invalidate.
|
|
66
63
|
await Bun.sleep(10);
|
|
67
|
-
writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
|
|
64
|
+
await writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
|
|
68
65
|
|
|
69
66
|
const second = await readHtmlTemplate();
|
|
70
67
|
expect(second).toContain("/assets/hydrate-new.js");
|
|
@@ -101,7 +101,8 @@ async function loadManifest(): Promise<AssetManifest> {
|
|
|
101
101
|
if (parsed.stylesPath && parsed.hydratePath) {
|
|
102
102
|
return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
|
|
103
103
|
}
|
|
104
|
-
} catch {}
|
|
104
|
+
} catch (error) {}
|
|
105
|
+
getAppLogger().warn("asset manifest unreadable — falling back to default template wiring", error);
|
|
105
106
|
}
|
|
106
107
|
return FALLBACK;
|
|
107
108
|
}
|
package/src/readiness-gate.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* the normal handler rather than producing a hard 500.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
15
|
import type { Context, Next } from "hono";
|
|
15
16
|
|
|
16
17
|
let _ready = false;
|
|
@@ -33,7 +34,7 @@ export async function readinessGate(c: Context, next: Next): Promise<Response |
|
|
|
33
34
|
// handler — but the failure must be OBSERVABLE, not silent. Practically
|
|
34
35
|
// unreachable (the body is pure reads of module flags + c.req.path); the
|
|
35
36
|
// log surfaces a regression if it ever fires.
|
|
36
|
-
|
|
37
|
+
getAppLogger().error("[readiness-gate] gate threw — failing open to handler", { error: String(err) });
|
|
37
38
|
return next();
|
|
38
39
|
}
|
|
39
40
|
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* identification. Per constitution crash-isolation.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
12
|
+
|
|
11
13
|
export function registerCrashHandlers(opts: { serviceSlug: string }): {
|
|
12
14
|
unhandledRejection: boolean;
|
|
13
15
|
uncaughtException: boolean;
|
|
@@ -17,20 +19,14 @@ export function registerCrashHandlers(opts: { serviceSlug: string }): {
|
|
|
17
19
|
|
|
18
20
|
if (process.listenerCount("unhandledRejection") === 0) {
|
|
19
21
|
process.on("unhandledRejection", (reason) => {
|
|
20
|
-
|
|
21
|
-
`[scala-install:${opts.serviceSlug}] unhandledRejection:`,
|
|
22
|
-
reason,
|
|
23
|
-
);
|
|
22
|
+
getAppLogger().error(`[scala-install:${opts.serviceSlug}] unhandledRejection:`, { error: String(reason) });
|
|
24
23
|
});
|
|
25
24
|
unhandledRejection = true;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
if (process.listenerCount("uncaughtException") === 0) {
|
|
29
28
|
process.on("uncaughtException", (err) => {
|
|
30
|
-
|
|
31
|
-
`[scala-install:${opts.serviceSlug}] uncaughtException:`,
|
|
32
|
-
err,
|
|
33
|
-
);
|
|
29
|
+
getAppLogger().error(`[scala-install:${opts.serviceSlug}] uncaughtException:`, { error: String(err) });
|
|
34
30
|
});
|
|
35
31
|
uncaughtException = true;
|
|
36
32
|
}
|
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
* any page route handler fires.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
|
|
13
|
+
// success. That client carries only the three platform routers below and NO CRUD models, which is indistinguishable from a healthy one until a
|
|
14
|
+
// page asks it for a model — at which point resolve-from-loader-config throws MissingLoaderModelCapabilityError, far from the actual fault.
|
|
15
|
+
//
|
|
16
|
+
// Measured 2026-08-20: scala-quiz and scala-forms served 500s on every data-backed page while /health returned 200 and this line logged "ORPC
|
|
17
|
+
// serverClient registered". The build had produced no generated router (its railpack.json ran registry codegen against an unreachable
|
|
18
|
+
// *.railway.internal host), and nothing anywhere said so.
|
|
19
|
+
//
|
|
20
|
+
// A service whose service_schema DECLARES models and has no generated router is not degraded, it is non-functional — so this fails the boot
|
|
21
|
+
// rather than reporting a state it has not verified (`a-component-may-not-report-a-state-it-has-not-verified`). A service
|
|
22
|
+
// that declares no models (daemon/portal shells owning no DB schema) still passes through cleanly.
|
|
23
|
+
|
|
24
|
+
// Why: REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false us…
|
|
25
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
26
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
12
27
|
import { join } from "node:path";
|
|
13
28
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
29
|
|
|
@@ -52,25 +67,6 @@ export async function registerPageServerClient(
|
|
|
52
67
|
);
|
|
53
68
|
}
|
|
54
69
|
}
|
|
55
|
-
// REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall
|
|
56
|
-
// straight through with appRouter=null and still register a client + log
|
|
57
|
-
// success. That client carries only the three platform routers below and
|
|
58
|
-
// NO CRUD models, which is indistinguishable from a healthy one until a
|
|
59
|
-
// page asks it for a model — at which point resolve-from-loader-config
|
|
60
|
-
// throws MissingLoaderModelCapabilityError, far from the actual fault.
|
|
61
|
-
//
|
|
62
|
-
// Measured 2026-08-20: scala-quiz and scala-forms served 500s on every
|
|
63
|
-
// data-backed page while /health returned 200 and this line logged "ORPC
|
|
64
|
-
// serverClient registered". The build had produced no generated router
|
|
65
|
-
// (its railpack.json ran registry codegen against an unreachable
|
|
66
|
-
// *.railway.internal host), and nothing anywhere said so.
|
|
67
|
-
//
|
|
68
|
-
// A service whose service_schema DECLARES models and has no generated
|
|
69
|
-
// router is not degraded, it is non-functional — so this fails the boot
|
|
70
|
-
// rather than reporting a state it has not verified
|
|
71
|
-
// (`a-component-may-not-report-a-state-it-has-not-verified`). A service
|
|
72
|
-
// that declares no models (daemon/portal shells owning no DB schema)
|
|
73
|
-
// still passes through cleanly.
|
|
74
70
|
const declaredModels = await (async () => {
|
|
75
71
|
try {
|
|
76
72
|
const { loadRegistryConfig: loadCfg } = await import(
|
|
@@ -7,6 +7,22 @@
|
|
|
7
7
|
* panel renders the real conversation on first paint. Extracted from
|
|
8
8
|
* register-registry-page-route.ts (single-purpose-file split).
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
|
|
12
|
+
* panel renders the real conversation on first paint — never a loading gate
|
|
13
|
+
* (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
|
|
14
|
+
* isn't rendered when the panel is closed, so its block serverLoader can't run
|
|
15
|
+
* at page-load; this prefetches server-side (regardless of panel open-state) +
|
|
16
|
+
* stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
|
|
17
|
+
* resolved from the /ai panel page's own AiChat block (single source of truth —
|
|
18
|
+
* no duplicate config to drift). Fired in parallel with the post-processors +
|
|
19
|
+
* bounded by their window; a failure degrades silently to the client-side
|
|
20
|
+
* createOrRestore fallback, never an error page.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Why: SSR-prefetch the signed-in user's AI-chat conversation so a …
|
|
24
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
25
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
10
26
|
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
11
27
|
|
|
12
28
|
/**
|
|
@@ -33,18 +49,6 @@ function findWebsiteId(root: unknown): string | undefined {
|
|
|
33
49
|
return undefined;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
/**
|
|
37
|
-
* SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
|
|
38
|
-
* panel renders the real conversation on first paint — never a loading gate
|
|
39
|
-
* (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
|
|
40
|
-
* isn't rendered when the panel is closed, so its block serverLoader can't run
|
|
41
|
-
* at page-load; this prefetches server-side (regardless of panel open-state) +
|
|
42
|
-
* stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
|
|
43
|
-
* resolved from the /ai panel page's own AiChat block (single source of truth —
|
|
44
|
-
* no duplicate config to drift). Fired in parallel with the post-processors +
|
|
45
|
-
* bounded by their window; a failure degrades silently to the client-side
|
|
46
|
-
* createOrRestore fallback, never an error page.
|
|
47
|
-
*/
|
|
48
52
|
export async function prefetchAiChatSession(
|
|
49
53
|
resolved: Record<string, unknown>,
|
|
50
54
|
sessionUserId: string | undefined,
|