@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
|
-
* @indivisible-unit
|
|
5
|
+
* @indivisible-unit owner=unassigned reason=299 lines; pre-2026-08-27 exemption, unreviewed
|
|
6
6
|
*
|
|
7
7
|
* One cohesive unit — the branded resource-not-found / holding page: the
|
|
8
8
|
* PrimaryResourceMissingError trigger, the org-logo resolver the page
|
|
@@ -28,6 +28,28 @@
|
|
|
28
28
|
* floor that must always exist.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The host org's LOGO (wordmark) for the dark error page — the favicon is a
|
|
33
|
+
* tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
|
|
34
|
+
* where the launcha wordmark belonged). Resolution: the service's own
|
|
35
|
+
* install row names its owner (brand_org); the org's dark-theme logo (a
|
|
36
|
+
* light-coloured mark, correct on the dark page) is preferred, light-theme
|
|
37
|
+
* second. Every step skew-tolerant: any miss → null → the builder renders a
|
|
38
|
+
* logo-less dark page.
|
|
39
|
+
*
|
|
40
|
+
* Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
|
|
41
|
+
* the branded holding/error page ships the logo IN the initial HTML bytes —
|
|
42
|
+
* no second fetch, no post-paint pop-in, no dependency on the asset proxy the
|
|
43
|
+
* degraded service is failing to serve (incident 2026-07-27: the logo on a
|
|
44
|
+
* DEGRADED render-layer holding page appeared after the page painted). The
|
|
45
|
+
* logo sibling of icons-render-ssr-inline. Size-capped: a pathological
|
|
46
|
+
* (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
|
|
47
|
+
* HTML; the page renders logo-less, never broken.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
// Why: The host org's LOGO (wordmark) for the dark error page — the…
|
|
51
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
52
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
31
53
|
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
32
54
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
33
55
|
import { registerCacheInvalidation } from "@teamscala/db/notify-listener/index";
|
|
@@ -50,24 +72,6 @@ const logoCache = createCache<string | null>("service-boot:error-page-logo", {
|
|
|
50
72
|
maxSize: 8,
|
|
51
73
|
});
|
|
52
74
|
|
|
53
|
-
/**
|
|
54
|
-
* The host org's LOGO (wordmark) for the dark error page — the favicon is a
|
|
55
|
-
* tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
|
|
56
|
-
* where the launcha wordmark belonged). Resolution: the service's own
|
|
57
|
-
* install row names its owner (brand_org); the org's dark-theme logo (a
|
|
58
|
-
* light-coloured mark, correct on the dark page) is preferred, light-theme
|
|
59
|
-
* second. Every step skew-tolerant: any miss → null → the builder renders a
|
|
60
|
-
* logo-less dark page.
|
|
61
|
-
*
|
|
62
|
-
* Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
|
|
63
|
-
* the branded holding/error page ships the logo IN the initial HTML bytes —
|
|
64
|
-
* no second fetch, no post-paint pop-in, no dependency on the asset proxy the
|
|
65
|
-
* degraded service is failing to serve (incident 2026-07-27: the logo on a
|
|
66
|
-
* DEGRADED render-layer holding page appeared after the page painted). The
|
|
67
|
-
* logo sibling of icons-render-ssr-inline. Size-capped: a pathological
|
|
68
|
-
* (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
|
|
69
|
-
* HTML; the page renders logo-less, never broken.
|
|
70
|
-
*/
|
|
71
75
|
export async function resolveErrorPageLogo(
|
|
72
76
|
serviceSlug: string,
|
|
73
77
|
): Promise<string | null> {
|
|
@@ -18,10 +18,10 @@ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
|
18
18
|
import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
|
|
19
19
|
import { getGithubPackagesLatestVersion } from "@teamscala/publish-registry";
|
|
20
20
|
import * as v from "valibot";
|
|
21
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
21
22
|
|
|
22
23
|
const PACKAGE_NAME = "@teamscala/scala-tools";
|
|
23
24
|
const PACKAGE_PATH = "node_modules/@teamscala/scala-tools/package.json";
|
|
24
|
-
const PACKAGE_URL = "https://npm.pkg.github.com/@teamgbg%2Fscala-tools";
|
|
25
25
|
const POLL_INTERVAL_MS = 120_000;
|
|
26
26
|
|
|
27
27
|
const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
|
|
@@ -30,28 +30,43 @@ const CurrencyConfigSchema = v.looseObject({
|
|
|
30
30
|
command_timeout_ms: v.optional(v.number(), DEFAULT_COMMAND_TIMEOUT_MS),
|
|
31
31
|
fetch_timeout_ms: v.optional(v.number(), DEFAULT_FETCH_TIMEOUT_MS),
|
|
32
32
|
});
|
|
33
|
-
|
|
33
|
+
const currencyConfigCache = createCache<{
|
|
34
|
+
command_timeout_ms: number;
|
|
35
|
+
fetch_timeout_ms: number;
|
|
36
|
+
}>("service-boot:scala-tools-currency-config", {
|
|
37
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
38
|
+
maxSize: 1,
|
|
39
|
+
});
|
|
40
|
+
|
|
34
41
|
async function getCurrencyConfig(): Promise<{ command_timeout_ms: number; fetch_timeout_ms: number }> {
|
|
35
|
-
|
|
42
|
+
const hit = currencyConfigCache.get("default");
|
|
43
|
+
if (hit) return hit;
|
|
36
44
|
const cfg = await loadRegistryConfig("config", "scala-tools-currency", CurrencyConfigSchema);
|
|
37
|
-
|
|
45
|
+
const config = {
|
|
38
46
|
command_timeout_ms: cfg?.command_timeout_ms ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
39
47
|
fetch_timeout_ms: cfg?.fetch_timeout_ms ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
40
48
|
};
|
|
41
|
-
|
|
49
|
+
currencyConfigCache.set("default", config);
|
|
50
|
+
return config;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
|
|
53
|
+
const githubTokenCache = createCache<string | null>(
|
|
54
|
+
"service-boot:github-packages-token",
|
|
55
|
+
{ ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
|
|
56
|
+
);
|
|
57
|
+
|
|
45
58
|
async function getGithubPackagesToken(): Promise<string | null> {
|
|
46
|
-
|
|
59
|
+
const hit = githubTokenCache.get("default");
|
|
60
|
+
if (hit !== undefined) return hit;
|
|
47
61
|
const raw = await loadRegistryConfig("secret", "github-packages-token", v.unknown());
|
|
48
62
|
if (!raw) {
|
|
49
|
-
|
|
63
|
+
githubTokenCache.set("default", null);
|
|
50
64
|
return null;
|
|
51
65
|
}
|
|
52
66
|
const decrypted = decryptSecretConfig(raw as never) as Record<string, unknown>;
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
const token = typeof decrypted.token === "string" ? decrypted.token : null;
|
|
68
|
+
githubTokenCache.set("default", token);
|
|
69
|
+
return token;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
interface PackageManifest {
|
|
@@ -86,8 +101,22 @@ function registryFromPackageUrl(pkgUrl: string): { registry: string; name: strin
|
|
|
86
101
|
async function readLatestVersion(_fetchTimeoutMs: number): Promise<string | null> {
|
|
87
102
|
const token = await getGithubPackagesToken();
|
|
88
103
|
if (!token) return null;
|
|
89
|
-
const
|
|
90
|
-
|
|
104
|
+
const registryHost = await getPublishRegistryHost();
|
|
105
|
+
if (!registryHost) return null;
|
|
106
|
+
return getGithubPackagesLatestVersion(PACKAGE_NAME, token, registryHost);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getPublishRegistryHost(): Promise<string | null> {
|
|
110
|
+
try {
|
|
111
|
+
const row = await loadRegistryConfig(
|
|
112
|
+
"secret",
|
|
113
|
+
"publish-registry-config",
|
|
114
|
+
v.looseObject({ registry: v.string() }),
|
|
115
|
+
);
|
|
116
|
+
return row?.registry ?? null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
async function checkCurrency(): Promise<{ healthy: boolean; details?: Record<string, unknown> }> {
|
|
@@ -9,22 +9,6 @@
|
|
|
9
9
|
* in atomically once complete.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
|
-
import type { Hono } from "hono";
|
|
14
|
-
import { buildClientAssets } from "./build-client-assets.ts";
|
|
15
|
-
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
16
|
-
import { swapApp } from "./mutable-app-ref.ts";
|
|
17
|
-
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
18
|
-
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
19
|
-
import type { PageServerDeps } from "./ui-service.ts";
|
|
20
|
-
|
|
21
|
-
export interface FullAppResult {
|
|
22
|
-
app: Hono;
|
|
23
|
-
blocks: { slug: string; packagePath: string }[];
|
|
24
|
-
routes: string[];
|
|
25
|
-
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
12
|
/**
|
|
29
13
|
* Build the full app with hydration. This adds the expensive operations that
|
|
30
14
|
* were deferred from the minimal app:
|
|
@@ -37,13 +21,10 @@ export interface FullAppResult {
|
|
|
37
21
|
* NOTE: All routes (including page routes) are already registered in Phase 1,
|
|
38
22
|
* so we don't need to modify the app here. The app reference stays the same.
|
|
39
23
|
*/
|
|
40
|
-
export async function buildFullApp(
|
|
41
|
-
minimalResult: MinimalAppResult,
|
|
42
|
-
deps: PageServerDeps,
|
|
43
|
-
): Promise<FullAppResult> {
|
|
44
|
-
const log = getAppLogger();
|
|
45
|
-
const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
|
|
46
24
|
|
|
25
|
+
// Why: Build the full app with hydration. This adds the expensive o…
|
|
26
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
27
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
47
28
|
// Client asset building (expensive: Vite build).
|
|
48
29
|
//
|
|
49
30
|
// ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
|
|
@@ -63,6 +44,50 @@ export async function buildFullApp(
|
|
|
63
44
|
// The steps after this one do not depend on client assets: pg-boss needs the
|
|
64
45
|
// registry and the DB, not a bundle. Ordering it behind a Vite build was
|
|
65
46
|
// incidental, so the coupling was never intended.
|
|
47
|
+
|
|
48
|
+
// Why: Client asset building (expensive: Vite build).
|
|
49
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
50
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
51
|
+
// pg-boss (mount for UI services - wasn't mounted in minimal app)
|
|
52
|
+
//
|
|
53
|
+
// Wrapped for the SAME reason buildClientAssets above is, in the other
|
|
54
|
+
// direction: a background-work failure must not cost the page. Unwrapped,
|
|
55
|
+
// anything mountPgBoss throws propagates out of the full-app build, the
|
|
56
|
+
// caller falls back to the MINIMAL app, and the service serves 0 api routes
|
|
57
|
+
// with /health 503 — so the tunnel health check fails and the site is
|
|
58
|
+
// unreachable from outside while localhost answers in 0.1s.
|
|
59
|
+
//
|
|
60
|
+
// Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
|
|
61
|
+
// build expecting a newer shared pgboss schema than the database carried, its
|
|
62
|
+
// migration threw, and an outage of every page followed from a scheduled-job
|
|
63
|
+
// dependency. Jobs are the degradable half; pages are not.
|
|
64
|
+
|
|
65
|
+
// Why: pg-boss (mount for UI services - wasn't mounted in minimal a…
|
|
66
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
67
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
68
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
69
|
+
import type { Hono } from "hono";
|
|
70
|
+
import { buildClientAssets } from "./build-client-assets.ts";
|
|
71
|
+
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
72
|
+
import { swapApp } from "./mutable-app-ref.ts";
|
|
73
|
+
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
74
|
+
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
75
|
+
import type { PageServerDeps } from "./ui-service.ts";
|
|
76
|
+
|
|
77
|
+
export interface FullAppResult {
|
|
78
|
+
app: Hono;
|
|
79
|
+
blocks: { slug: string; packagePath: string }[];
|
|
80
|
+
routes: string[];
|
|
81
|
+
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function buildFullApp(
|
|
85
|
+
minimalResult: MinimalAppResult,
|
|
86
|
+
deps: PageServerDeps,
|
|
87
|
+
): Promise<FullAppResult> {
|
|
88
|
+
const log = getAppLogger();
|
|
89
|
+
const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
|
|
90
|
+
|
|
66
91
|
try {
|
|
67
92
|
await buildClientAssets(serviceSlug);
|
|
68
93
|
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
@@ -76,13 +101,20 @@ export async function buildFullApp(
|
|
|
76
101
|
);
|
|
77
102
|
}
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
let pgBossJobs: Awaited<ReturnType<typeof mountPgBoss>>["jobs"] = [];
|
|
105
|
+
try {
|
|
106
|
+
({ jobs: pgBossJobs } = await mountPgBoss({
|
|
107
|
+
databaseUrl: minimalResult.databaseUrl,
|
|
108
|
+
serviceSlug,
|
|
109
|
+
packages: config.server_packages ?? [],
|
|
110
|
+
}));
|
|
111
|
+
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
log.error(
|
|
114
|
+
`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss FAILED — background jobs are DOWN for this service, pages continue`,
|
|
115
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
86
118
|
if (pgBossJobs.length > 0) {
|
|
87
119
|
log.info(
|
|
88
120
|
`[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
|
|
@@ -9,6 +9,45 @@
|
|
|
9
9
|
* it in atomically — eliminating the ~25s boot outage window.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
14
|
+
* - Client asset building (Vite build)
|
|
15
|
+
* - Boot hooks (may have expensive initialization)
|
|
16
|
+
* - Scheduled jobs (can start later)
|
|
17
|
+
* - pg-boss (can start later)
|
|
18
|
+
*
|
|
19
|
+
* CRITICAL: All route registration MUST happen here before serving, because
|
|
20
|
+
* Hono's RegExpRouter compiles on the first request and becomes immutable.
|
|
21
|
+
* Once compiled, no new routes can be added. So we include:
|
|
22
|
+
* - Block discovery and page routes (registers all page paths)
|
|
23
|
+
* - All other routes (API, WS, data, static assets)
|
|
24
|
+
*
|
|
25
|
+
* The minimal app includes everything needed for MCP tools and ORPC:
|
|
26
|
+
* - Error handling, readiness gate, health check
|
|
27
|
+
* - Security headers, CORS, compression, timing
|
|
28
|
+
* - Content gate access, auth gates, static assets
|
|
29
|
+
* - ORPC router, API routes, WS routes, data routes, page routes
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// Why: Build the minimal tool-only app. This excludes the expensive…
|
|
33
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
34
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
35
|
+
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|
|
36
|
+
// below. These three touch NO app route and have no ordering dependency on
|
|
37
|
+
// the gate/mount sequence, so they run concurrently with it:
|
|
38
|
+
// - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
|
|
39
|
+
// that overlaps the gate chain's cross-internet DB I/O — same speculative
|
|
40
|
+
// pattern as create-boot-pipeline's preloads). mountServerPackages later
|
|
41
|
+
// hits the cache (instant imports), paying only the ~33ms of mount work.
|
|
42
|
+
// - initGateTtls: loads config rows into module-level vars. The TTL getters
|
|
43
|
+
// are read at REQUEST time (inside route-handler closures), not at boot,
|
|
44
|
+
// so this only needs to finish before serving — safe to run in parallel.
|
|
45
|
+
// - discoverBlocks: loads the page-manifest (no app mutation, no gate
|
|
46
|
+
// dependency). Its result is consumed by registerPageRoutes much later.
|
|
47
|
+
|
|
48
|
+
// Why: Fire independent I/O + compilation NOW, overlapping with the…
|
|
49
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
50
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
12
51
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
52
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
14
53
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
@@ -63,25 +102,6 @@ export interface MinimalAppResult {
|
|
|
63
102
|
skipped: string[]; // Skipped block names
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
/**
|
|
67
|
-
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
68
|
-
* - Client asset building (Vite build)
|
|
69
|
-
* - Boot hooks (may have expensive initialization)
|
|
70
|
-
* - Scheduled jobs (can start later)
|
|
71
|
-
* - pg-boss (can start later)
|
|
72
|
-
*
|
|
73
|
-
* CRITICAL: All route registration MUST happen here before serving, because
|
|
74
|
-
* Hono's RegExpRouter compiles on the first request and becomes immutable.
|
|
75
|
-
* Once compiled, no new routes can be added. So we include:
|
|
76
|
-
* - Block discovery and page routes (registers all page paths)
|
|
77
|
-
* - All other routes (API, WS, data, static assets)
|
|
78
|
-
*
|
|
79
|
-
* The minimal app includes everything needed for MCP tools and ORPC:
|
|
80
|
-
* - Error handling, readiness gate, health check
|
|
81
|
-
* - Security headers, CORS, compression, timing
|
|
82
|
-
* - Content gate access, auth gates, static assets
|
|
83
|
-
* - ORPC router, API routes, WS routes, data routes, page routes
|
|
84
|
-
*/
|
|
85
105
|
export async function buildMinimalApp(
|
|
86
106
|
config: ServiceInstallConfig,
|
|
87
107
|
serviceSlug: string,
|
|
@@ -167,18 +187,6 @@ export async function buildMinimalApp(
|
|
|
167
187
|
);
|
|
168
188
|
attachRequestLogger(app, serviceSlug);
|
|
169
189
|
|
|
170
|
-
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|
|
171
|
-
// below. These three touch NO app route and have no ordering dependency on
|
|
172
|
-
// the gate/mount sequence, so they run concurrently with it:
|
|
173
|
-
// - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
|
|
174
|
-
// that overlaps the gate chain's cross-internet DB I/O — same speculative
|
|
175
|
-
// pattern as create-boot-pipeline's preloads). mountServerPackages later
|
|
176
|
-
// hits the cache (instant imports), paying only the ~33ms of mount work.
|
|
177
|
-
// - initGateTtls: loads config rows into module-level vars. The TTL getters
|
|
178
|
-
// are read at REQUEST time (inside route-handler closures), not at boot,
|
|
179
|
-
// so this only needs to finish before serving — safe to run in parallel.
|
|
180
|
-
// - discoverBlocks: loads the page-manifest (no app mutation, no gate
|
|
181
|
-
// dependency). Its result is consumed by registerPageRoutes much later.
|
|
182
190
|
const serverPackages = ((config as { server_packages?: string[] })
|
|
183
191
|
.server_packages ?? []) as string[];
|
|
184
192
|
const preloadPromise = runStep("preload-server-packages", () =>
|
|
@@ -9,30 +9,6 @@
|
|
|
9
9
|
* handler once phase 2 completes. Every reader goes through these exports.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
|
|
13
|
-
import { WAKING_RETRY_SECONDS } from "@teamscala/os/brand/waking-page-budget.generated";
|
|
14
|
-
import type { Context } from "hono";
|
|
15
|
-
import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
|
|
16
|
-
import { resolveErrorPageLogo } from "./resource-not-found.ts";
|
|
17
|
-
import type { CreatePageHandler } from "./types.ts";
|
|
18
|
-
|
|
19
|
-
// Module-level storage for the real page handler, updated when Phase 2 completes.
|
|
20
|
-
// This allows the temporary Phase 1 handler to delegate to the real handler once ready.
|
|
21
|
-
let realPageHandler: CreatePageHandler | null = null;
|
|
22
|
-
let fullAppReady = false;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
26
|
-
*
|
|
27
|
-
* Registering the SSR handler implies the full app is built, so this also flips
|
|
28
|
-
* readiness — but the reverse does NOT hold, which is why `markFullAppReady`
|
|
29
|
-
* exists separately. See its note.
|
|
30
|
-
*/
|
|
31
|
-
export function setPageHandler(handler: CreatePageHandler): void {
|
|
32
|
-
realPageHandler = handler;
|
|
33
|
-
markFullAppReady();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
12
|
/**
|
|
37
13
|
* Flip readiness once phase 2 has built the full app.
|
|
38
14
|
*
|
|
@@ -53,10 +29,10 @@ export function setPageHandler(handler: CreatePageHandler): void {
|
|
|
53
29
|
* built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
|
|
54
30
|
* still reported starting — while three untouched UI services reported ok.
|
|
55
31
|
*/
|
|
56
|
-
export function markFullAppReady(): void {
|
|
57
|
-
fullAppReady = true;
|
|
58
|
-
}
|
|
59
32
|
|
|
33
|
+
// Why: Flip readiness once phase 2 has built the full app.
|
|
34
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
35
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
60
36
|
/**
|
|
61
37
|
* Reset both phase-2 flags. TEST-ONLY.
|
|
62
38
|
*
|
|
@@ -71,18 +47,10 @@ export function markFullAppReady(): void {
|
|
|
71
47
|
* Never called from production code — `setPageHandler`/`markFullAppReady` are
|
|
72
48
|
* the only writers on the boot path.
|
|
73
49
|
*/
|
|
74
|
-
export function __resetPhase2StateForTest(): void {
|
|
75
|
-
realPageHandler = null;
|
|
76
|
-
fullAppReady = false;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Get the current page handler state.
|
|
81
|
-
*/
|
|
82
|
-
export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
83
|
-
return { ready: fullAppReady, hasHandler: realPageHandler !== null };
|
|
84
|
-
}
|
|
85
50
|
|
|
51
|
+
// Why: Reset both phase-2 flags. TEST-ONLY.
|
|
52
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
53
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
86
54
|
/**
|
|
87
55
|
* `/health` for a UI service — 200 ONLY once the full app can actually render
|
|
88
56
|
* a page, 503 before that.
|
|
@@ -125,6 +93,50 @@ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
|
125
93
|
* tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
|
|
126
94
|
* one consumer contract is "can you serve me a page", is moved to phase 2.
|
|
127
95
|
*/
|
|
96
|
+
|
|
97
|
+
// Why: `/health` for a UI service — 200 ONLY once the full app can …
|
|
98
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
99
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
100
|
+
import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
|
|
101
|
+
import { WAKING_RETRY_SECONDS } from "@teamscala/os/brand/waking-page-budget.generated";
|
|
102
|
+
import type { Context } from "hono";
|
|
103
|
+
import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
|
|
104
|
+
import { resolveErrorPageLogo } from "./resource-not-found.ts";
|
|
105
|
+
import type { CreatePageHandler } from "./types.ts";
|
|
106
|
+
|
|
107
|
+
// Module-level storage for the real page handler, updated when Phase 2 completes.
|
|
108
|
+
// This allows the temporary Phase 1 handler to delegate to the real handler once ready.
|
|
109
|
+
let realPageHandler: CreatePageHandler | null = null;
|
|
110
|
+
let fullAppReady = false;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
114
|
+
*
|
|
115
|
+
* Registering the SSR handler implies the full app is built, so this also flips
|
|
116
|
+
* readiness — but the reverse does NOT hold, which is why `markFullAppReady`
|
|
117
|
+
* exists separately. See its note.
|
|
118
|
+
*/
|
|
119
|
+
export function setPageHandler(handler: CreatePageHandler): void {
|
|
120
|
+
realPageHandler = handler;
|
|
121
|
+
markFullAppReady();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function markFullAppReady(): void {
|
|
125
|
+
fullAppReady = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function __resetPhase2StateForTest(): void {
|
|
129
|
+
realPageHandler = null;
|
|
130
|
+
fullAppReady = false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get the current page handler state.
|
|
135
|
+
*/
|
|
136
|
+
export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
137
|
+
return { ready: fullAppReady, hasHandler: realPageHandler !== null };
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
export function healthHandler(c: Context): Response {
|
|
129
141
|
if (!fullAppReady) return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
|
|
130
142
|
// Assert the CAPABILITY, not just the phase flag. On 2026-08-20 this returned
|
package/src/ui-service.ts
CHANGED
|
@@ -8,6 +8,56 @@
|
|
|
8
8
|
* served by @teamscala/service-runtime/serve (one Bun.serve in dev and prod).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Boots a UI service using two-phase boot for fast availability.
|
|
13
|
+
*
|
|
14
|
+
* Phase 1 (fast): Build and serve minimal tool-only app (ORPC, data routes,
|
|
15
|
+
* auth gates). This becomes available within ~10s, enabling the MCP tool
|
|
16
|
+
* surface to accept requests immediately after restart.
|
|
17
|
+
*
|
|
18
|
+
* Phase 2 (background): Build full app (pages, hydration) and atomically swap
|
|
19
|
+
* it in. The readiness gate flips to ready after Phase 1, not Phase 2.
|
|
20
|
+
*
|
|
21
|
+
* The interim mitigation (503 + gateway retry + circuit breaker) remains in
|
|
22
|
+
* place and is NOT undone.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// Why: Boots a UI service using two-phase boot for fast availabilit…
|
|
26
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
27
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
28
|
+
// Resolve the listen port through the SHARED resolver (`resolveServicePort`),
|
|
29
|
+
// the same one the backend branch uses. The registry wins wherever it has an
|
|
30
|
+
// opinion and a contradicting PORT env is reported, never silently obeyed —
|
|
31
|
+
// full reasoning in that file.
|
|
32
|
+
//
|
|
33
|
+
// This branch previously took `process.env.PORT` unconditionally, which is
|
|
34
|
+
// how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
|
|
35
|
+
// leaked env and silently split that daemon's traffic (2026-07-31). On the
|
|
36
|
+
// dev host the registry wins a conflict (below). On Railway the injected PORT
|
|
37
|
+
// wins instead — Railway healthchecks exactly that port, and the row's port
|
|
38
|
+
// is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
|
|
39
|
+
// makes the healthcheck probe a dead port and the deploy fails (`isRailway`
|
|
40
|
+
// path in the resolver).
|
|
41
|
+
|
|
42
|
+
// Why: Resolve the listen port through the SHARED resolver (`resolv…
|
|
43
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
44
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
45
|
+
// NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
|
|
46
|
+
// (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
|
|
47
|
+
// The minimal-app fallback keeps serving SSR from the LAST-successful
|
|
48
|
+
// manifest (now a tombstone, see build-client-assets.ts), so the page
|
|
49
|
+
// looks "up" while the client bundle is stale. Without this signal the
|
|
50
|
+
// failure is buried in boot stderr and a published fix that reached
|
|
51
|
+
// node_modules but failed to bundle ships invisibly stale (the
|
|
52
|
+
// scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
|
|
53
|
+
// served pre-fix bytes across restarts while node_modules was
|
|
54
|
+
// mid-install). The boot.client-build-failed event routes into
|
|
55
|
+
// event-log's 7-day retention → surfaces in GlitchTip as an incident
|
|
56
|
+
// trail the operator actually watches.
|
|
57
|
+
|
|
58
|
+
// Why: NO-SILENT-REVERTS — surface the failed client build to OBSER…
|
|
59
|
+
// (full note hoisted to this file's leading header — kept out of the body per
|
|
60
|
+
// no-inline-prose-blocks / prose-stays-in-docs)
|
|
11
61
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
12
62
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
63
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
@@ -46,19 +96,6 @@ export interface PageServerDeps {
|
|
|
46
96
|
getSharedDb?: () => unknown;
|
|
47
97
|
}
|
|
48
98
|
|
|
49
|
-
/**
|
|
50
|
-
* Boots a UI service using two-phase boot for fast availability.
|
|
51
|
-
*
|
|
52
|
-
* Phase 1 (fast): Build and serve minimal tool-only app (ORPC, data routes,
|
|
53
|
-
* auth gates). This becomes available within ~10s, enabling the MCP tool
|
|
54
|
-
* surface to accept requests immediately after restart.
|
|
55
|
-
*
|
|
56
|
-
* Phase 2 (background): Build full app (pages, hydration) and atomically swap
|
|
57
|
-
* it in. The readiness gate flips to ready after Phase 1, not Phase 2.
|
|
58
|
-
*
|
|
59
|
-
* The interim mitigation (503 + gateway retry + circuit breaker) remains in
|
|
60
|
-
* place and is NOT undone.
|
|
61
|
-
*/
|
|
62
99
|
export async function bootUiService(
|
|
63
100
|
config: ServiceInstallConfig,
|
|
64
101
|
serviceSlug: string,
|
|
@@ -77,19 +114,6 @@ export async function bootUiService(
|
|
|
77
114
|
deps.getSharedDb,
|
|
78
115
|
);
|
|
79
116
|
|
|
80
|
-
// Resolve the listen port through the SHARED resolver (`resolveServicePort`),
|
|
81
|
-
// the same one the backend branch uses. The registry wins wherever it has an
|
|
82
|
-
// opinion and a contradicting PORT env is reported, never silently obeyed —
|
|
83
|
-
// full reasoning in that file.
|
|
84
|
-
//
|
|
85
|
-
// This branch previously took `process.env.PORT` unconditionally, which is
|
|
86
|
-
// how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
|
|
87
|
-
// leaked env and silently split that daemon's traffic (2026-07-31). On the
|
|
88
|
-
// dev host the registry wins a conflict (below). On Railway the injected PORT
|
|
89
|
-
// wins instead — Railway healthchecks exactly that port, and the row's port
|
|
90
|
-
// is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
|
|
91
|
-
// makes the healthcheck probe a dead port and the deploy fails (`isRailway`
|
|
92
|
-
// path in the resolver).
|
|
93
117
|
const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
|
|
94
118
|
const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
|
|
95
119
|
// The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
|
|
@@ -160,18 +184,6 @@ export async function bootUiService(
|
|
|
160
184
|
log.error(
|
|
161
185
|
`[ui-service-boot:${serviceSlug}] Failed to build full app, continuing with minimal app: ${reason}`,
|
|
162
186
|
);
|
|
163
|
-
// NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
|
|
164
|
-
// (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
|
|
165
|
-
// The minimal-app fallback keeps serving SSR from the LAST-successful
|
|
166
|
-
// manifest (now a tombstone, see build-client-assets.ts), so the page
|
|
167
|
-
// looks "up" while the client bundle is stale. Without this signal the
|
|
168
|
-
// failure is buried in boot stderr and a published fix that reached
|
|
169
|
-
// node_modules but failed to bundle ships invisibly stale (the
|
|
170
|
-
// scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
|
|
171
|
-
// served pre-fix bytes across restarts while node_modules was
|
|
172
|
-
// mid-install). The boot.client-build-failed event routes into
|
|
173
|
-
// event-log's 7-day retention → surfaces in GlitchTip as an incident
|
|
174
|
-
// trail the operator actually watches.
|
|
175
187
|
recordEvent({
|
|
176
188
|
kind: "boot.client-build-failed",
|
|
177
189
|
payload: {
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The `pane-outbox-drain` pg-boss handler — delivers messages the operator's
|
|
7
|
-
* WhatsApp PA queues into `pane_messages` to the addressed agent pane.
|
|
8
|
-
*
|
|
9
|
-
* WHY THE REGISTRATION LIVES HERE AND THE WORK LIVES IN @teamscala/channel.
|
|
10
|
-
* The delivery itself belongs to the channel capability, so `paneOutboxDrainTick`
|
|
11
|
-
* is owned by `@teamscala/channel` beside the dispatch handler it calls. But
|
|
12
|
-
* `pgBossHandlerRegistry` is SERVER tier and `@teamscala/channel` is ADAPTER
|
|
13
|
-
* tier, so the channel package cannot register a pg-boss handler without an
|
|
14
|
-
* upward dependency — forbidden by `vertical-dependency-only`, which has no
|
|
15
|
-
* carve-out. server → adapter is DOWNWARD, so the registration sits here and
|
|
16
|
-
* imports the work, exactly as the four sibling handlers in this directory do.
|
|
17
|
-
*
|
|
18
|
-
* The MountContext route (`ctx.registerScheduledJob`) is tier-legal but NOT
|
|
19
|
-
* usable: `start-scheduled-jobs.ts` offers only `long_running` (invoked once,
|
|
20
|
-
* runs forever) and `recurring` (whose handlers "manage their own setInterval").
|
|
21
|
-
* Both are the in-process timer that `pg-boss-for-background-work` forbids and
|
|
22
|
-
* that the timer half of `in-memory-state-is-not-durable` describes — a timer
|
|
23
|
-
* only fires if its host outlives it, so on a restart-prone service it silently
|
|
24
|
-
* never runs while logging that it started. pg-boss is the only durable option,
|
|
25
|
-
* because the job row outlives any worker.
|
|
26
|
-
*
|
|
27
|
-
* Scoped to scala-channel by its `scheduled_job` row's `service_slug`, so it
|
|
28
|
-
* runs on the service that owns the channel rather than on scala-agents, whose
|
|
29
|
-
* UI-asset build failures used to take PA delivery down with them.
|
|
30
|
-
*
|
|
31
|
-
* Registered under slug `@teamscala/service-boot:pane-outbox-drain`.
|
|
32
|
-
*/
|
|
33
|
-
import { paneOutboxDrainTick } from "@teamscala/channel/pane-outbox/drain-tick";
|
|
34
|
-
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
35
|
-
|
|
36
|
-
pgBossHandlerRegistry.register(
|
|
37
|
-
"@teamscala/service-boot:pane-outbox-drain",
|
|
38
|
-
paneOutboxDrainTick,
|
|
39
|
-
);
|