@teamscala/service-boot 0.23.385 → 0.23.387

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.385",
9
+ "version": "0.23.387",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -418,6 +418,22 @@
418
418
  "types": "./src/handlers/bun-stable-watch.d.ts",
419
419
  "default": "./src/handlers/bun-stable-watch.ts"
420
420
  },
421
+ "./handlers/messaging-send-recording-logic": {
422
+ "types": "./src/handlers/messaging-send-recording-logic.d.ts",
423
+ "default": "./src/handlers/messaging-send-recording-logic.ts"
424
+ },
425
+ "./handlers/messaging-send-recording-logic.ts": {
426
+ "types": "./src/handlers/messaging-send-recording-logic.d.ts",
427
+ "default": "./src/handlers/messaging-send-recording-logic.ts"
428
+ },
429
+ "./handlers/messaging-send-recording-watch": {
430
+ "types": "./src/handlers/messaging-send-recording-watch.d.ts",
431
+ "default": "./src/handlers/messaging-send-recording-watch.ts"
432
+ },
433
+ "./handlers/messaging-send-recording-watch.ts": {
434
+ "types": "./src/handlers/messaging-send-recording-watch.d.ts",
435
+ "default": "./src/handlers/messaging-send-recording-watch.ts"
436
+ },
421
437
  "./handlers/pane-outbox-drain": {
422
438
  "types": "./src/handlers/pane-outbox-drain.d.ts",
423
439
  "default": "./src/handlers/pane-outbox-drain.ts"
@@ -922,13 +938,29 @@
922
938
  "types": "./src/server-error-page.d.ts",
923
939
  "default": "./src/server-error-page.ts"
924
940
  },
925
- "./two-phase-boot": {
926
- "types": "./src/two-phase-boot.d.ts",
927
- "default": "./src/two-phase-boot.ts"
941
+ "./two-phase-full-app": {
942
+ "types": "./src/two-phase-full-app.d.ts",
943
+ "default": "./src/two-phase-full-app.ts"
944
+ },
945
+ "./two-phase-full-app.ts": {
946
+ "types": "./src/two-phase-full-app.d.ts",
947
+ "default": "./src/two-phase-full-app.ts"
948
+ },
949
+ "./two-phase-minimal-app": {
950
+ "types": "./src/two-phase-minimal-app.d.ts",
951
+ "default": "./src/two-phase-minimal-app.ts"
952
+ },
953
+ "./two-phase-minimal-app.ts": {
954
+ "types": "./src/two-phase-minimal-app.d.ts",
955
+ "default": "./src/two-phase-minimal-app.ts"
956
+ },
957
+ "./two-phase-readiness": {
958
+ "types": "./src/two-phase-readiness.d.ts",
959
+ "default": "./src/two-phase-readiness.ts"
928
960
  },
929
- "./two-phase-boot.ts": {
930
- "types": "./src/two-phase-boot.d.ts",
931
- "default": "./src/two-phase-boot.ts"
961
+ "./two-phase-readiness.ts": {
962
+ "types": "./src/two-phase-readiness.d.ts",
963
+ "default": "./src/two-phase-readiness.ts"
932
964
  },
933
965
  "./types": {
934
966
  "types": "./src/types.d.ts",
@@ -19,7 +19,9 @@
19
19
  import { afterEach, describe, expect, test } from "bun:test";
20
20
  import {
21
21
  getServerClient,
22
+ getServerClientCapabilityFault,
22
23
  isServerClientRegistered,
24
+ recordServerClientCapability,
23
25
  setServerClient,
24
26
  } from "./server-client.ts";
25
27
 
@@ -66,3 +68,45 @@ describe("page-server server client", () => {
66
68
  expect(isServerClientRegistered()).toBe(true);
67
69
  });
68
70
  });
71
+
72
+ describe("declared-vs-present capability probe (/health)", () => {
73
+ test("no fault when every declared model is servable", () => {
74
+ setServerClient({
75
+ assessments: { findFirst: async () => null },
76
+ assessment_responses: { findFirst: async () => null },
77
+ } as never);
78
+ recordServerClientCapability(["assessments", "assessment_responses"]);
79
+ expect(getServerClientCapabilityFault()).toBeNull();
80
+ });
81
+
82
+ test("names the models the client cannot serve", () => {
83
+ // THE OUTAGE, AS A HEALTH ANSWER. A client was registered and carried only
84
+ // the platform routers, so every process-existence signal was green while
85
+ // every data-backed page 500d. /health must be able to say this.
86
+ setServerClient({ puckPages: {}, userPreferences: {} } as never);
87
+ recordServerClientCapability(["assessments", "assessment_responses"]);
88
+ const fault = getServerClientCapabilityFault();
89
+ expect(fault).toContain("assessments");
90
+ expect(fault).toContain("assessment_responses");
91
+ });
92
+
93
+ test("a service declaring no models is never faulted", () => {
94
+ // Daemon/portal shells own no DB schema and are healthy with an empty client.
95
+ setServerClient({ puckPages: {} } as never);
96
+ recordServerClientCapability([]);
97
+ expect(getServerClientCapabilityFault()).toBeNull();
98
+ });
99
+
100
+ test("an unregistered client is itself the fault", () => {
101
+ setServerClient(null as never);
102
+ recordServerClientCapability(["assessments"]);
103
+ expect(getServerClientCapabilityFault()).toContain("no ORPC server client registered");
104
+ });
105
+
106
+ test("a model present without findFirst still counts as missing", () => {
107
+ // The generated router shape is what matters, not mere key presence.
108
+ setServerClient({ assessments: {} } as never);
109
+ recordServerClientCapability(["assessments"]);
110
+ expect(getServerClientCapabilityFault()).toContain("assessments");
111
+ });
112
+ });
@@ -55,3 +55,36 @@ export function setServerClient(
55
55
  ): void {
56
56
  serverClient = client;
57
57
  }
58
+
59
+ /**
60
+ * Declared-vs-present capability, recorded once at boot by
61
+ * registerPageServerClient and read by /health.
62
+ *
63
+ * The outage this exists for: a client WAS registered, so every
64
+ * process-existence signal was green, and it carried no CRUD models. Liveness
65
+ * must be a capability probe, never a registration check
66
+ * (`liveness-by-capability-not-process-existence`).
67
+ */
68
+ let capabilityFault: string | null = null;
69
+
70
+ /**
71
+ * Record whether the registered client can actually serve the models the
72
+ * service declares. Property lookups only — no I/O — so /health stays cheap.
73
+ */
74
+ export function recordServerClientCapability(declaredModels: readonly string[]): void {
75
+ if (!serverClient) {
76
+ capabilityFault = "no ORPC server client registered";
77
+ return;
78
+ }
79
+ const client = serverClient as unknown as Record<string, { findFirst?: unknown } | undefined>;
80
+ const missing = declaredModels.filter((m) => typeof client[m]?.findFirst !== "function");
81
+ capabilityFault =
82
+ missing.length === 0
83
+ ? null
84
+ : `generated client exposes no findFirst for ${missing.length} declared model(s): ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""}`;
85
+ }
86
+
87
+ /** The capability fault, or null when the service can serve its declared models. */
88
+ export function getServerClientCapabilityFault(): string | null {
89
+ return capabilityFault;
90
+ }
@@ -148,6 +148,9 @@ export async function registerPageServerClient(
148
148
  } as never,
149
149
  );
150
150
  mod.setServerClient(client);
151
+ // Record declared-vs-present capability for /health. Property lookups only.
152
+ (mod as unknown as { recordServerClientCapability?: (m: readonly string[]) => void })
153
+ .recordServerClientCapability?.(declaredModels);
151
154
  // State the model count. "registered" alone was true in the outage and told
152
155
  // nobody anything; a zero here is now visible in the boot log.
153
156
  log.info(
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { describe, expect, test } from "bun:test";
19
19
 
20
- const src = await Bun.file(new URL("./two-phase-boot.ts", import.meta.url)).text();
20
+ const src = await Bun.file(new URL("./two-phase-full-app.ts", import.meta.url)).text();
21
21
 
22
22
  /** The body of buildFullApp, where phase-2 ordering lives. */
23
23
  function buildFullAppBody(): string {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Phase 2 of the two-phase boot: the full app. Adds the expensive operations
7
+ * deferred from phase 1 (two-phase-minimal-app.ts) — client asset building
8
+ * (Vite build), pg-boss, the readiness/page-handler flip — and is swapped
9
+ * in atomically once complete.
10
+ */
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
+ /**
29
+ * Build the full app with hydration. This adds the expensive operations that
30
+ * were deferred from the minimal app:
31
+ * - Client asset building (Vite build)
32
+ * - Boot hooks (may have expensive initialization)
33
+ * - Scheduled jobs (can start later)
34
+ * - pg-boss (can start later)
35
+ *
36
+ * This runs in the background after the minimal app is already serving.
37
+ * NOTE: All routes (including page routes) are already registered in Phase 1,
38
+ * so we don't need to modify the app here. The app reference stays the same.
39
+ */
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
+
47
+ // Client asset building (expensive: Vite build).
48
+ //
49
+ // ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
50
+ // This was awaited unguarded, so a throw here aborted buildFullApp before
51
+ // mountPgBoss ran, and the caller's catch logged
52
+ // "Failed to build full app, continuing with minimal app" — which reads as a
53
+ // UI-only degradation while having silently killed every pg-boss handler on
54
+ // the service.
55
+ //
56
+ // Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
57
+ // the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
58
+ // scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
59
+ // across repeated boots, the pane-outbox drain never mounted, and the
60
+ // operator's WhatsApp assistant went silent — because of a stylesheet. The
61
+ // service stayed `online` and healthy throughout, so nothing surfaced it.
62
+ //
63
+ // The steps after this one do not depend on client assets: pg-boss needs the
64
+ // registry and the DB, not a bundle. Ordering it behind a Vite build was
65
+ // incidental, so the coupling was never intended.
66
+ try {
67
+ await buildClientAssets(serviceSlug);
68
+ log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
69
+ } catch (error) {
70
+ // Loud but non-fatal: the tombstone manifest written by buildClientAssets
71
+ // already makes SSR serve the prior bundle, so degrading here costs the
72
+ // new bundle, never the background work.
73
+ log.error(
74
+ `[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
75
+ { error: error instanceof Error ? error.message : String(error) },
76
+ );
77
+ }
78
+
79
+ // pg-boss (mount for UI services - wasn't mounted in minimal app)
80
+ const { jobs: pgBossJobs } = await mountPgBoss({
81
+ databaseUrl: minimalResult.databaseUrl,
82
+ serviceSlug,
83
+ packages: config.server_packages ?? [],
84
+ });
85
+ log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
86
+ if (pgBossJobs.length > 0) {
87
+ log.info(
88
+ `[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
89
+ );
90
+ }
91
+
92
+ log.info(
93
+ `[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
94
+ `${Object.keys(components).length} component(s)`,
95
+ );
96
+
97
+ // Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
98
+ // Client-only services (render_mode=client) have no SSR page handler — skip
99
+ // (they serve via the client bundle, never SSR). Guarded so undefined is a
100
+ // no-op rather than a crash.
101
+ if (deps.createPageHandler) {
102
+ setPageHandler(deps.createPageHandler);
103
+ log.info(
104
+ `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
105
+ );
106
+ } else {
107
+ // The full app IS built — this service simply never renders server-side,
108
+ // so readiness is flipped directly rather than as a side effect of
109
+ // registering an SSR handler that will never exist. Without this a
110
+ // client-only service is permanently un-ready (see markFullAppReady).
111
+ markFullAppReady();
112
+ log.info(
113
+ `[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
114
+ );
115
+ }
116
+
117
+ const resolvedBlocks = loaded.map((slug) => ({
118
+ slug,
119
+ packagePath: "block_packages",
120
+ }));
121
+
122
+ return {
123
+ app: minimalApp, // Same app reference, all routes already registered
124
+ blocks: resolvedBlocks,
125
+ routes: [], // Routes already registered in Phase 1
126
+ pgBossJobs,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Swap the minimal app for the full app. This atomically replaces the
132
+ * app reference that Bun.serve is using.
133
+ */
134
+ export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
135
+ const oldLabel = swapApp(fullApp, "full");
136
+ const log = getAppLogger();
137
+ log.info(`[two-phase-boot:${serviceSlug}] Swapped from ${oldLabel} app to full app`);
138
+ }
@@ -34,7 +34,7 @@ import {
34
34
  getPageHandlerState,
35
35
  healthHandler,
36
36
  markFullAppReady,
37
- } from "./two-phase-boot.ts";
37
+ } from "./two-phase-readiness.ts";
38
38
 
39
39
  /** Mount the real handler exactly as buildMinimalApp does. */
40
40
  const app = new Hono().get("/health", healthHandler);
@@ -34,7 +34,7 @@ import {
34
34
  getPageHandlerState,
35
35
  healthHandler,
36
36
  setPageHandler,
37
- } from "./two-phase-boot.ts";
37
+ } from "./two-phase-readiness.ts";
38
38
 
39
39
  /** Mount the real handler exactly as buildMinimalApp does. */
40
40
  const app = new Hono().get("/health", healthHandler);
@@ -3,21 +3,16 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Two-phase boot for UI services. Splits the boot process into:
7
- * 1. Minimal app (tool-only surface): ORPC, data routes, auth gates
8
- * 2. Full app (with pages and hydration): adds block discovery, page routes,
9
- * client asset building, boot hooks, scheduled jobs
10
- *
11
- * The minimal app starts serving immediately (<10s), then the full app is
12
- * built in the background and atomically swapped in. This eliminates the
13
- * ~25s channel outage window during scala-agents restart.
6
+ * Phase 1 of the two-phase boot: the minimal tool-only app (ORPC, data
7
+ * routes, auth gates) that serves immediately (<10s); phase 2
8
+ * (two-phase-full-app.ts) builds the full app in the background and swaps
9
+ * it in atomically — eliminating the ~25s boot outage window.
14
10
  */
15
11
 
16
12
  import { getAppLogger } from "@teamscala/logger/app-loggers";
17
- import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
18
13
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
14
  import { createTimer } from "@teamscala/timing/create-timer";
20
- import { Hono, type Context } from "hono";
15
+ import { Hono } from "hono";
21
16
  import { bodyLimit } from "hono/body-limit";
22
17
  import { compress } from "hono/compress";
23
18
  import { cors } from "hono/cors";
@@ -37,7 +32,6 @@ import { attachRequestLogger } from "./install-logging.ts";
37
32
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
38
33
  import { mountOrpcRouter } from "./mount-orpc-router.ts";
39
34
  import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
40
- import { mountPgBoss } from "./pg-boss-mount.ts";
41
35
  import { registerApiRoutes } from "./register-api-routes.ts";
42
36
  import { readinessGate } from "./readiness-gate.ts";
43
37
  import { registerStaticAssets } from "./register-static-assets";
@@ -48,129 +42,11 @@ import {
48
42
  resolveErrorPageLogo,
49
43
  } from "./resource-not-found.ts";
50
44
  import { injectSecurityHeaders } from "./security-headers.ts";
51
- import type { PageServerDeps } from "./ui-service.ts";
52
45
  import { registerPageRoutes } from "./register-page-routes.ts";
53
46
  import { runBootHookList } from "./run-boot-hook-list.ts";
54
- import { buildClientAssets } from "./build-client-assets.ts";
55
47
  import { discoverBlocks } from "./discover-blocks.ts";
56
48
  import { registerPageServerClient } from "./register-page-server-client.ts";
57
- import { swapApp } from "./mutable-app-ref.ts";
58
- import type { CreatePageHandler } from "./types.ts";
59
-
60
- // Module-level storage for the real page handler, updated when Phase 2 completes.
61
- // This allows the temporary Phase 1 handler to delegate to the real handler once ready.
62
- let realPageHandler: CreatePageHandler | null = null;
63
- let fullAppReady = false;
64
-
65
- /**
66
- * Set the real page handler (called when Phase 2 completes on an SSR service).
67
- *
68
- * Registering the SSR handler implies the full app is built, so this also flips
69
- * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
70
- * exists separately. See its note.
71
- */
72
- export function setPageHandler(handler: CreatePageHandler): void {
73
- realPageHandler = handler;
74
- markFullAppReady();
75
- }
76
-
77
- /**
78
- * Flip readiness once phase 2 has built the full app.
79
- *
80
- * SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
81
- * means "phase 2 finished"; the page handler means "this service renders pages
82
- * server-side". Every service was SSR, so one flag honestly carried both — until
83
- * `render_mode=client` (`granular runtime`) made a service that completes phase 2
84
- * and never registers an SSR handler.
85
- *
86
- * Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
87
- * un-ready: `/health` answered `{"status":"starting"}` forever while the process
88
- * was healthy and serving. That is the failure `readiness-gated-boot` is written
89
- * against — a service must be "binary: fully-ready or cleanly-unavailable", and
90
- * a never-ready health signal makes the activator's wake gate and every
91
- * health-gated router treat a working service as down.
92
- *
93
- * Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
94
- * built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
95
- * still reported starting — while three untouched UI services reported ok.
96
- */
97
- export function markFullAppReady(): void {
98
- fullAppReady = true;
99
- }
100
-
101
- /**
102
- * Reset both phase-2 flags. TEST-ONLY.
103
- *
104
- * `fullAppReady` is module state that only ever transitions false -> true in
105
- * production, and Bun shares a module instance across every test file in a run —
106
- * so the SSR readiness test and the client-only readiness test contaminate each
107
- * other purely by file order. Splitting them across files does NOT isolate them;
108
- * it only hides the coupling until CI runs them together.
109
- *
110
- * A test that passes alone and fails in a suite is worse than no test, so the
111
- * reset is exported rather than left as an ordering convention nobody can see.
112
- * Never called from production code — `setPageHandler`/`markFullAppReady` are
113
- * the only writers on the boot path.
114
- */
115
- export function __resetPhase2StateForTest(): void {
116
- realPageHandler = null;
117
- fullAppReady = false;
118
- }
119
-
120
- /**
121
- * Get the current page handler state.
122
- */
123
- export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
124
- return { ready: fullAppReady, hasHandler: realPageHandler !== null };
125
- }
126
-
127
- /**
128
- * `/health` for a UI service — 200 ONLY once the full app can actually render
129
- * a page, 503 before that.
130
- *
131
- * This endpoint is not decoration: it is the wake gate for every scale-to-zero
132
- * app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
133
- * connection through a cold wake and pipes it the instant `isHealthy(port)` —
134
- * a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
135
- * on-demand` means by "`/health`-gates the wake (the client never sees a boot
136
- * 503)".
137
- *
138
- * It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
139
- * serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
140
- * surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
141
- * handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
142
- * `/health` reported ready while every page 503'd, the activator believed the
143
- * wake had landed, and it piped the customer's first request straight into that
144
- * window — the customer got the 503.
145
- *
146
- * Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
147
- * scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
148
- * — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
149
- * such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
150
- * placeholder, which does no render work). scala-public logged ZERO, which is
151
- * the whole reason it looked healthy while the other two did not: the gap is a
152
- * RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
153
- * not a config difference between the apps.
154
- *
155
- * Gating this on `fullAppReady` makes the signal honest for every consumer at
156
- * once (the activator's wake gate, `poller-post-restart-health-gate`, any
157
- * health-gated router) rather than teaching each one its own probe, and it is
158
- * what `readiness-gated-boot` already requires: "/health returns 503 until
159
- * ready, 200 after — so it is the real readiness signal", a service being
160
- * "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
161
- * is not ready, per `liveness-by-capability-not-process-existence` — liveness is
162
- * a probe of the capability being REQUESTED, never of process existence or of
163
- * some other surface that happens to answer.
164
- *
165
- * The early readiness-gate flip is deliberately left alone: it gates the ORPC
166
- * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
167
- * one consumer contract is "can you serve me a page", is moved to phase 2.
168
- */
169
- export function healthHandler(c: Context): Response {
170
- return fullAppReady
171
- ? c.json({ status: "ok" })
172
- : c.json({ status: "starting" }, 503, { "Retry-After": "1" });
173
- }
49
+ import { createPhase1PageHandler, healthHandler } from "./two-phase-readiness.ts";
174
50
 
175
51
  export interface MinimalAppResult {
176
52
  app: Hono;
@@ -187,13 +63,6 @@ export interface MinimalAppResult {
187
63
  skipped: string[]; // Skipped block names
188
64
  }
189
65
 
190
- export interface FullAppResult {
191
- app: Hono;
192
- blocks: { slug: string; packagePath: string }[];
193
- routes: string[];
194
- pgBossJobs: Array<{ handlerSlug: string }>;
195
- }
196
-
197
66
  /**
198
67
  * Build the minimal tool-only app. This excludes the expensive operations:
199
68
  * - Client asset building (Vite build)
@@ -437,39 +306,13 @@ export async function buildMinimalApp(
437
306
  // Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
438
307
  // All route paths must be registered before serving to avoid Hono
439
308
  // RegExpRouter compilation issues (matcher becomes immutable).
440
- //
441
- // CRITICAL: During Phase 1 (minimal app), page requests MUST return 503 BEFORE
442
- // attempting to resolve page data. The config object doesn't have proper loadData
443
- // set up until Phase 2, and attempting to resolve undefined loadData crashes
444
- // resolveDataGraph with "undefined is not an object (evaluating 'Object.keys(spec)')".
445
309
  const registeredPageRoutes = await runStep("register-page-routes", () =>
446
310
  registerPageRoutes({
447
311
  app,
448
312
  config,
449
313
  components,
450
314
  serviceSlug,
451
- createPageHandler: (pageConfig) => {
452
- // Return a handler that checks if the full app is ready BEFORE processing
453
- return async (req, extras) => {
454
- // If full app is NOT ready yet, return the branded waking page
455
- // (the same pulsating-logo holding page scale-to-zero uses) —
456
- // never bare plain text (loading-via-logo-loading).
457
- if (!fullAppReady || !realPageHandler) {
458
- const logo = await resolveErrorPageLogo(serviceSlug);
459
- return new Response(buildWakingPageHtml({ logoUrl: logo }), {
460
- status: 503,
461
- headers: {
462
- "content-type": "text/html; charset=utf-8",
463
- "cache-control": "no-store, no-transform",
464
- "retry-after": "5",
465
- },
466
- });
467
- }
468
- // Full app is ready - delegate to the real handler
469
- const handler = realPageHandler(pageConfig);
470
- return handler(req, extras);
471
- };
472
- },
315
+ createPageHandler: createPhase1PageHandler(serviceSlug),
473
316
  }),
474
317
  );
475
318
  const pageRouteCount = registeredPageRoutes.length;
@@ -504,115 +347,3 @@ export async function buildMinimalApp(
504
347
  skipped,
505
348
  };
506
349
  }
507
-
508
- /**
509
- * Build the full app with hydration. This adds the expensive operations that
510
- * were deferred from the minimal app:
511
- * - Client asset building (Vite build)
512
- * - Boot hooks (may have expensive initialization)
513
- * - Scheduled jobs (can start later)
514
- * - pg-boss (can start later)
515
- *
516
- * This runs in the background after the minimal app is already serving.
517
- * NOTE: All routes (including page routes) are already registered in Phase 1,
518
- * so we don't need to modify the app here. The app reference stays the same.
519
- */
520
- export async function buildFullApp(
521
- minimalResult: MinimalAppResult,
522
- deps: PageServerDeps,
523
- ): Promise<FullAppResult> {
524
- const log = getAppLogger();
525
- const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
526
-
527
- // Client asset building (expensive: Vite build).
528
- //
529
- // ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
530
- // This was awaited unguarded, so a throw here aborted buildFullApp before
531
- // mountPgBoss ran, and the caller's catch logged
532
- // "Failed to build full app, continuing with minimal app" — which reads as a
533
- // UI-only degradation while having silently killed every pg-boss handler on
534
- // the service.
535
- //
536
- // Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
537
- // the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
538
- // scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
539
- // across repeated boots, the pane-outbox drain never mounted, and the
540
- // operator's WhatsApp assistant went silent — because of a stylesheet. The
541
- // service stayed `online` and healthy throughout, so nothing surfaced it.
542
- //
543
- // The steps after this one do not depend on client assets: pg-boss needs the
544
- // registry and the DB, not a bundle. Ordering it behind a Vite build was
545
- // incidental, so the coupling was never intended.
546
- try {
547
- await buildClientAssets(serviceSlug);
548
- log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
549
- } catch (error) {
550
- // Loud but non-fatal: the tombstone manifest written by buildClientAssets
551
- // already makes SSR serve the prior bundle, so degrading here costs the
552
- // new bundle, never the background work.
553
- log.error(
554
- `[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
555
- { error: error instanceof Error ? error.message : String(error) },
556
- );
557
- }
558
-
559
- // pg-boss (mount for UI services - wasn't mounted in minimal app)
560
- const { jobs: pgBossJobs } = await mountPgBoss({
561
- databaseUrl: minimalResult.databaseUrl,
562
- serviceSlug,
563
- packages: config.server_packages ?? [],
564
- });
565
- log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
566
- if (pgBossJobs.length > 0) {
567
- log.info(
568
- `[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
569
- );
570
- }
571
-
572
- log.info(
573
- `[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
574
- `${Object.keys(components).length} component(s)`,
575
- );
576
-
577
- // Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
578
- // Client-only services (render_mode=client) have no SSR page handler — skip
579
- // (they serve via the client bundle, never SSR). Guarded so undefined is a
580
- // no-op rather than a crash.
581
- if (deps.createPageHandler) {
582
- setPageHandler(deps.createPageHandler);
583
- log.info(
584
- `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
585
- );
586
- } else {
587
- // The full app IS built — this service simply never renders server-side,
588
- // so readiness is flipped directly rather than as a side effect of
589
- // registering an SSR handler that will never exist. Without this a
590
- // client-only service is permanently un-ready (see markFullAppReady).
591
- markFullAppReady();
592
- log.info(
593
- `[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
594
- );
595
- }
596
-
597
- const resolvedBlocks = loaded.map((slug) => ({
598
- slug,
599
- packagePath: "block_packages",
600
- }));
601
-
602
- return {
603
- app: minimalApp, // Same app reference, all routes already registered
604
- blocks: resolvedBlocks,
605
- routes: [], // Routes already registered in Phase 1
606
- pgBossJobs,
607
- };
608
- }
609
-
610
- /**
611
- * Swap the minimal app for the full app. This atomically replaces the
612
- * app reference that Bun.serve is using.
613
- */
614
- export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
615
- const oldLabel = swapApp(fullApp, "full");
616
- const log = getAppLogger();
617
- log.info(`[two-phase-boot:${serviceSlug}] Swapped from ${oldLabel} app to full app`);
618
- }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Phase-2 readiness state box for the two-phase boot: the real page handler +
7
+ * readiness flags (private lets), the writers/flippers, the `/health` endpoint
8
+ * that gates on them, and the phase-1 page handler that delegates to the real
9
+ * handler once phase 2 completes. Every reader goes through these exports.
10
+ */
11
+
12
+ import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
13
+ import type { Context } from "hono";
14
+ import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
15
+ import { resolveErrorPageLogo } from "./resource-not-found.ts";
16
+ import type { CreatePageHandler } from "./types.ts";
17
+
18
+ // Module-level storage for the real page handler, updated when Phase 2 completes.
19
+ // This allows the temporary Phase 1 handler to delegate to the real handler once ready.
20
+ let realPageHandler: CreatePageHandler | null = null;
21
+ let fullAppReady = false;
22
+
23
+ /**
24
+ * Set the real page handler (called when Phase 2 completes on an SSR service).
25
+ *
26
+ * Registering the SSR handler implies the full app is built, so this also flips
27
+ * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
28
+ * exists separately. See its note.
29
+ */
30
+ export function setPageHandler(handler: CreatePageHandler): void {
31
+ realPageHandler = handler;
32
+ markFullAppReady();
33
+ }
34
+
35
+ /**
36
+ * Flip readiness once phase 2 has built the full app.
37
+ *
38
+ * SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
39
+ * means "phase 2 finished"; the page handler means "this service renders pages
40
+ * server-side". Every service was SSR, so one flag honestly carried both — until
41
+ * `render_mode=client` (`granular runtime`) made a service that completes phase 2
42
+ * and never registers an SSR handler.
43
+ *
44
+ * Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
45
+ * un-ready: `/health` answered `{"status":"starting"}` forever while the process
46
+ * was healthy and serving. That is the failure `readiness-gated-boot` is written
47
+ * against — a service must be "binary: fully-ready or cleanly-unavailable", and
48
+ * a never-ready health signal makes the activator's wake gate and every
49
+ * health-gated router treat a working service as down.
50
+ *
51
+ * Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
52
+ * built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
53
+ * still reported starting — while three untouched UI services reported ok.
54
+ */
55
+ export function markFullAppReady(): void {
56
+ fullAppReady = true;
57
+ }
58
+
59
+ /**
60
+ * Reset both phase-2 flags. TEST-ONLY.
61
+ *
62
+ * `fullAppReady` is module state that only ever transitions false -> true in
63
+ * production, and Bun shares a module instance across every test file in a run —
64
+ * so the SSR readiness test and the client-only readiness test contaminate each
65
+ * other purely by file order. Splitting them across files does NOT isolate them;
66
+ * it only hides the coupling until CI runs them together.
67
+ *
68
+ * A test that passes alone and fails in a suite is worse than no test, so the
69
+ * reset is exported rather than left as an ordering convention nobody can see.
70
+ * Never called from production code — `setPageHandler`/`markFullAppReady` are
71
+ * the only writers on the boot path.
72
+ */
73
+ export function __resetPhase2StateForTest(): void {
74
+ realPageHandler = null;
75
+ fullAppReady = false;
76
+ }
77
+
78
+ /**
79
+ * Get the current page handler state.
80
+ */
81
+ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
82
+ return { ready: fullAppReady, hasHandler: realPageHandler !== null };
83
+ }
84
+
85
+ /**
86
+ * `/health` for a UI service — 200 ONLY once the full app can actually render
87
+ * a page, 503 before that.
88
+ *
89
+ * This endpoint is not decoration: it is the wake gate for every scale-to-zero
90
+ * app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
91
+ * connection through a cold wake and pipes it the instant `isHealthy(port)` —
92
+ * a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
93
+ * on-demand` means by "`/health`-gates the wake (the client never sees a boot
94
+ * 503)".
95
+ *
96
+ * It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
97
+ * serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
98
+ * surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
99
+ * handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
100
+ * `/health` reported ready while every page 503'd, the activator believed the
101
+ * wake had landed, and it piped the customer's first request straight into that
102
+ * window — the customer got the 503.
103
+ *
104
+ * Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
105
+ * scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
106
+ * — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
107
+ * such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
108
+ * placeholder, which does no render work). scala-public logged ZERO, which is
109
+ * the whole reason it looked healthy while the other two did not: the gap is a
110
+ * RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
111
+ * not a config difference between the apps.
112
+ *
113
+ * Gating this on `fullAppReady` makes the signal honest for every consumer at
114
+ * once (the activator's wake gate, `poller-post-restart-health-gate`, any
115
+ * health-gated router) rather than teaching each one its own probe, and it is
116
+ * what `readiness-gated-boot` already requires: "/health returns 503 until
117
+ * ready, 200 after — so it is the real readiness signal", a service being
118
+ * "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
119
+ * is not ready, per `liveness-by-capability-not-process-existence` — liveness is
120
+ * a probe of the capability being REQUESTED, never of process existence or of
121
+ * some other surface that happens to answer.
122
+ *
123
+ * The early readiness-gate flip is deliberately left alone: it gates the ORPC
124
+ * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
125
+ * one consumer contract is "can you serve me a page", is moved to phase 2.
126
+ */
127
+ export function healthHandler(c: Context): Response {
128
+ if (!fullAppReady) return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
129
+ // Assert the CAPABILITY, not just the phase flag. On 2026-08-20 this returned
130
+ // 200 through a total outage: the process was up and phase 2 had completed
131
+ // while the registered client carried no CRUD models, so every data-backed
132
+ // page 500d and nothing alerted
133
+ // (`liveness-by-capability-not-process-existence`).
134
+ const fault = getServerClientCapabilityFault();
135
+ if (fault) return c.json({ status: "degraded", reason: fault }, 503);
136
+ return c.json({ status: "ok" });
137
+ }
138
+
139
+ /**
140
+ * The page handler `buildMinimalApp` registers on every page route during
141
+ * phase 1: 503 + the branded waking page until the full app is ready, then
142
+ * delegation to the real handler. Lives here, beside the state it reads —
143
+ * the flags stay private to this module.
144
+ */
145
+ export function createPhase1PageHandler(serviceSlug: string): CreatePageHandler {
146
+ // CRITICAL: During Phase 1 (minimal app), page requests MUST return 503 BEFORE
147
+ // attempting to resolve page data. The config object doesn't have proper loadData
148
+ // set up until Phase 2, and attempting to resolve undefined loadData crashes
149
+ // resolveDataGraph with "undefined is not an object (evaluating 'Object.keys(spec)')".
150
+ // Return a handler that checks if the full app is ready BEFORE processing
151
+ return (pageConfig) =>
152
+ async (req, extras) => {
153
+ // If full app is NOT ready yet, return the branded waking page
154
+ // (the same pulsating-logo holding page scale-to-zero uses) —
155
+ // never bare plain text (loading-via-logo-loading).
156
+ if (!fullAppReady || !realPageHandler) {
157
+ const logo = await resolveErrorPageLogo(serviceSlug);
158
+ return new Response(buildWakingPageHtml({ logoUrl: logo }), {
159
+ status: 503,
160
+ headers: {
161
+ "content-type": "text/html; charset=utf-8",
162
+ "cache-control": "no-store, no-transform",
163
+ "retry-after": "5",
164
+ },
165
+ });
166
+ }
167
+ // Full app is ready - delegate to the real handler
168
+ const handler = realPageHandler(pageConfig);
169
+ return handler(req, extras);
170
+ };
171
+ }
package/src/ui-service.ts CHANGED
@@ -15,7 +15,8 @@ import { Hono } from "hono";
15
15
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
16
16
  import { setReady } from "./readiness-gate.ts";
17
17
  import { resolveServicePort } from "./resolve-service-port.ts";
18
- import { buildMinimalApp, buildFullApp, swapToFullApp } from "./two-phase-boot.ts";
18
+ import { buildMinimalApp } from "./two-phase-minimal-app.ts";
19
+ import { buildFullApp, swapToFullApp } from "./two-phase-full-app.ts";
19
20
  import type { UpgradeWebSocket } from "./register-ws-routes.ts";
20
21
 
21
22
  export interface ResolvedBlock {