@teamscala/service-boot 0.23.431 → 0.23.433

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +12 -19
  2. package/src/agreement-gate/signing-shell.ts +19 -15
  3. package/src/attach-proposal-accept.ts +5 -2
  4. package/src/auth-gate/public-paths.ts +7 -16
  5. package/src/auth-gate.ts +45 -42
  6. package/src/bind-page-handler.ts +17 -32
  7. package/src/build-client-assets/build-lock.ts +15 -6
  8. package/src/build-client-assets/hashed-output.ts +3 -1
  9. package/src/build-client-assets/hydrate-bundle.ts +50 -57
  10. package/src/build-client-assets/input-fingerprint.ts +41 -57
  11. package/src/build-client-assets/input-hashing.ts +2 -4
  12. package/src/build-client-assets/manifest.ts +8 -11
  13. package/src/build-client-assets/source-candidates.ts +8 -16
  14. package/src/build-client-assets/sourcemaps-upload.ts +14 -10
  15. package/src/build-client-assets/styles-build.ts +20 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +13 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +20 -18
  20. package/src/content-gate/gate.ts +4 -3
  21. package/src/discover-blocks.ts +5 -7
  22. package/src/fn-routers/agreement-certificate.ts +1 -1
  23. package/src/fn-routers/agreement-send-router.ts +5 -4
  24. package/src/fn-routers/agreement-sign-side-effects.ts +24 -22
  25. package/src/fn-routers/assessment-report-email.ts +3 -2
  26. package/src/fn-routers/auth-router.ts +20 -21
  27. package/src/fn-routers/chat-session-router-shared.ts +12 -3
  28. package/src/fn-routers/chat-session-router.ts +8 -6
  29. package/src/fn-routers/conversation-ownership.ts +3 -2
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
  31. package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
  32. package/src/fn-routers/persist-chat-messages.ts +6 -4
  33. package/src/fn-routers/proposal-send-router.ts +4 -3
  34. package/src/fn-routers/public-agreement-router.ts +5 -4
  35. package/src/fn-routers/public-assessment-router.ts +5 -4
  36. package/src/fn-routers/public-gpt-session-router.ts +17 -12
  37. package/src/fn-routers/user-preferences-router.ts +6 -15
  38. package/src/fn-routers/work-items-router.ts +18 -17
  39. package/src/handlers/platform-fault-escalation/orpc-client.ts +35 -18
  40. package/src/handlers/projects-digest.ts +1 -1
  41. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  42. package/src/handlers/scala-tools-currency-watch.ts +20 -32
  43. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  44. package/src/handlers/singleton-tools-currency-watch.ts +21 -19
  45. package/src/handlers/upstream-issue-sync.ts +11 -4
  46. package/src/handlers/whatsapp-liveness.ts +14 -4
  47. package/src/install-harness.ts +1 -1
  48. package/src/install-logging.ts +1 -1
  49. package/src/mount-orpc-router.test.ts +56 -10
  50. package/src/mount-orpc-router.ts +23 -9
  51. package/src/mount-server-packages.ts +14 -15
  52. package/src/pg-boss-mount.ts +5 -12
  53. package/src/proposal-gate.ts +7 -2
  54. package/src/read-html-template.test.ts +12 -15
  55. package/src/read-html-template.ts +2 -1
  56. package/src/readiness-gate.ts +2 -1
  57. package/src/register-crash-handlers.ts +4 -8
  58. package/src/register-page-server-client.ts +12 -19
  59. package/src/register-registry-page-route/ai-chat-prefetch.ts +13 -12
  60. package/src/register-registry-page-route/apply-page-branding.ts +59 -56
  61. package/src/register-registry-page-route/graph-cache.ts +19 -18
  62. package/src/register-registry-page-route/meta-title.ts +21 -20
  63. package/src/register-registry-page-route/prefetch-org-id.ts +3 -2
  64. package/src/register-registry-page-route/proposal-approval-email-token.ts +2 -3
  65. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  66. package/src/register-registry-page-route.ts +1 -2
  67. package/src/register-static-assets.ts +6 -5
  68. package/src/resolve-app-base-url.ts +12 -5
  69. package/src/resolvers/portal-route-paths.ts +12 -11
  70. package/src/resource-chat-inject.ts +75 -72
  71. package/src/resource-not-found.ts +20 -19
  72. package/src/scala-tools-currency-watchdog.ts +41 -12
  73. package/src/two-phase-full-app.ts +52 -29
  74. package/src/two-phase-minimal-app.ts +33 -31
  75. package/src/two-phase-readiness.ts +41 -38
  76. package/src/ui-service.ts +41 -38
  77. package/src/handlers/pane-outbox-drain.ts +0 -39
  78. package/src/handlers/prune-stale-data.ts +0 -110
@@ -20,6 +20,81 @@
20
20
  * (fail-soft).
21
21
  */
22
22
 
23
+ /**
24
+ * THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
25
+ *
26
+ * `ai_agents` already carries a column for every one of them -- name,
27
+ * welcome_message, conversation_starters, primary_color -- and on 2026-08-19
28
+ * both live agents had welcome_message NULL and conversation_starters empty
29
+ * while the copy sat spelled in TypeScript here and in proposal-chat-inject.
30
+ * The intended home existed, sat blank, and the hardcoded copy won, so nobody
31
+ * could change a word the assistant says without a code change and a deploy.
32
+ * Two copies of one fact with the stale one winning silently is exactly
33
+ * `information-exists-in-one-location`.
34
+ *
35
+ * `launchLabel` and `placeholder` remain caller arguments only because no
36
+ * column holds them yet.
37
+ */
38
+
39
+ /**
40
+ * The rendered content tree to append the launcher to.
41
+ *
42
+ * WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
43
+ * sources and its region slots are declared on the `resource_loader` row, and
44
+ * the caller has already read that row — so it passes `regionSlots` in. An
45
+ * earlier draft of this function named "page", "body", "header" and "footer" as
46
+ * literals, which is a loader row copied into TypeScript, free to drift from it
47
+ * the moment a loader is edited (`identity-is-imported-never-spelled`).
48
+ *
49
+ * A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
50
+ * Proposals and agreements resolve one `page` source, so the original injector
51
+ * read `resolved.page.puck_data.content` and returned early when it was absent.
52
+ * A generated report resolves region slots instead, so that early return fired
53
+ * and the assistant was silently missing — the same failure shape as the dead
54
+ * block name, since fail-soft injection logs nothing and the page looks fine.
55
+ *
56
+ * ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
57
+ * renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
58
+ * to its container, so it appears identically whichever region carries it. That
59
+ * is what makes "first declared region holding a content tree" a derivation
60
+ * rather than a guess dressed up as one: there is no better region to prefer.
61
+ *
62
+ * `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
63
+ * source — the same convention `decideContentPageFloor` already keys on in the
64
+ * caller. One shared convention, not a per-resource list.
65
+ */
66
+
67
+ /**
68
+ * Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
69
+ *
70
+ * BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
71
+ * The original serialiser sent the resource record plus the page's content
72
+ * tree, which is exactly right for a proposal — the proposal's substance IS in
73
+ * its blocks. It is wrong for anything the platform GENERATES a document from.
74
+ * A report's body page is a shared template (one `builda_pages` row serves
75
+ * every respondent) so the content tree holds headings and layout and not one
76
+ * personal fact, while the substance — the answers, the category scores, the
77
+ * computed narrative and bands — sits in sibling resolved keys the serialiser
78
+ * never looked at. Sending only the page would give the assistant a form with
79
+ * no answers; sending only the record would leave it unable to discuss what the
80
+ * reader is actually looking at. An assessment result GENERATES a report, so
81
+ * the assistant needs both halves of that.
82
+ *
83
+ * NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
84
+ * resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
85
+ * exclusion list dropping brand, website, header and footer — every one of them
86
+ * a loader source key, which is to say database facts hardcoded here to save a
87
+ * few hundred tokens of colours and navigation markup. Not a trade worth a
88
+ * literal that goes stale in silence. A loader resolving a new source reaches
89
+ * the assistant with no edit here (`offered-is-derived`).
90
+ *
91
+ * IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
92
+ * gate, and a gate withholds by REMOVING content from this same graph. So
93
+ * "everything in the graph" and "everything this viewer may see" are the same
94
+ * set, and they cannot drift — which is what makes serialising the whole graph
95
+ * safe rather than reckless.
96
+ */
97
+
23
98
  import { getAppLogger } from "@teamscala/logger/app-loggers";
24
99
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
25
100
  import { getServerClient } from "./page-server-orpc/server-client.ts";
@@ -61,21 +136,6 @@ export interface ResourceChatOptions {
61
136
  regionSlots?: readonly string[];
62
137
  }
63
138
 
64
- /**
65
- * THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
66
- *
67
- * `ai_agents` already carries a column for every one of them -- name,
68
- * welcome_message, conversation_starters, primary_color -- and on 2026-08-19
69
- * both live agents had welcome_message NULL and conversation_starters empty
70
- * while the copy sat spelled in TypeScript here and in proposal-chat-inject.
71
- * The intended home existed, sat blank, and the hardcoded copy won, so nobody
72
- * could change a word the assistant says without a code change and a deploy.
73
- * Two copies of one fact with the stale one winning silently is exactly
74
- * `information-exists-in-one-location`.
75
- *
76
- * `launchLabel` and `placeholder` remain caller arguments only because no
77
- * column holds them yet.
78
- */
79
139
  export interface AgentPresentation {
80
140
  title?: string;
81
141
  welcomeMessage?: string;
@@ -147,33 +207,6 @@ function puckContentOf(value: unknown): unknown[] | null {
147
207
  return Array.isArray(content) ? content : null;
148
208
  }
149
209
 
150
- /**
151
- * The rendered content tree to append the launcher to.
152
- *
153
- * WHICH KEYS EXIST IS A ROW FACT, SO IT IS NOT SPELLED HERE. A page's resolved
154
- * sources and its region slots are declared on the `resource_loader` row, and
155
- * the caller has already read that row — so it passes `regionSlots` in. An
156
- * earlier draft of this function named "page", "body", "header" and "footer" as
157
- * literals, which is a loader row copied into TypeScript, free to drift from it
158
- * the moment a loader is edited (`identity-is-imported-never-spelled`).
159
- *
160
- * A FLAT PAGE KEEPS ITS CONTENT UNDER `page`; A REGION-SLOT PAGE DOES NOT.
161
- * Proposals and agreements resolve one `page` source, so the original injector
162
- * read `resolved.page.puck_data.content` and returned early when it was absent.
163
- * A generated report resolves region slots instead, so that early return fired
164
- * and the assistant was silently missing — the same failure shape as the dead
165
- * block name, since fail-soft injection logs nothing and the page looks fine.
166
- *
167
- * ANY RENDERED REGION WILL DO, SO THE FIRST DECLARED ONE IS TAKEN. The launcher
168
- * renders `position: fixed` at `zIndex: 50`, anchored to the viewport and not
169
- * to its container, so it appears identically whichever region carries it. That
170
- * is what makes "first declared region holding a content tree" a derivation
171
- * rather than a guess dressed up as one: there is no better region to prefer.
172
- *
173
- * `PAGE_SOURCE_KEY` is the platform's existing name for a flat page's body
174
- * source — the same convention `decideContentPageFloor` already keys on in the
175
- * caller. One shared convention, not a per-resource list.
176
- */
177
210
  const PAGE_SOURCE_KEY = "page";
178
211
 
179
212
  function findContentHost(
@@ -190,36 +223,6 @@ function findContentHost(
190
223
  return puckContentOf(resolved[PAGE_SOURCE_KEY]);
191
224
  }
192
225
 
193
- /**
194
- * Serialise EVERYTHING THIS VIEWER MAY SEE as text for the assistant.
195
- *
196
- * BOTH THE RESULT AND THE RENDERED DOCUMENT, BECAUSE NEITHER IS SUFFICIENT.
197
- * The original serialiser sent the resource record plus the page's content
198
- * tree, which is exactly right for a proposal — the proposal's substance IS in
199
- * its blocks. It is wrong for anything the platform GENERATES a document from.
200
- * A report's body page is a shared template (one `builda_pages` row serves
201
- * every respondent) so the content tree holds headings and layout and not one
202
- * personal fact, while the substance — the answers, the category scores, the
203
- * computed narrative and bands — sits in sibling resolved keys the serialiser
204
- * never looked at. Sending only the page would give the assistant a form with
205
- * no answers; sending only the record would leave it unable to discuss what the
206
- * reader is actually looking at. An assessment result GENERATES a report, so
207
- * the assistant needs both halves of that.
208
- *
209
- * NO KEY IS NAMED, INCLUDED OR EXCLUDED. The loader row decides what a page
210
- * resolves; this walks whatever that produced. A draft carried a `CHROME_KEYS`
211
- * exclusion list dropping brand, website, header and footer — every one of them
212
- * a loader source key, which is to say database facts hardcoded here to save a
213
- * few hundred tokens of colours and navigation markup. Not a trade worth a
214
- * literal that goes stale in silence. A loader resolving a new source reaches
215
- * the assistant with no edit here (`offered-is-derived`).
216
- *
217
- * IT CANNOT OUTRUN THE GATE, BY CONSTRUCTION. This runs AFTER every content
218
- * gate, and a gate withholds by REMOVING content from this same graph. So
219
- * "everything in the graph" and "everything this viewer may see" are the same
220
- * set, and they cannot drift — which is what makes serialising the whole graph
221
- * safe rather than reckless.
222
- */
223
226
  async function buildResourceContext(
224
227
  resolved: Record<string, unknown>,
225
228
  resourceKey: string,
@@ -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,25 @@
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
+
31
50
  import { getClient } from "@teamscala/object-storage/create/client/get-client";
32
51
  import { createCache } from "@teamscala/cache/create-cache";
33
52
  import { registerCacheInvalidation } from "@teamscala/db/notify-listener/index";
@@ -50,24 +69,6 @@ const logoCache = createCache<string | null>("service-boot:error-page-logo", {
50
69
  maxSize: 8,
51
70
  });
52
71
 
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
72
  export async function resolveErrorPageLogo(
72
73
  serviceSlug: string,
73
74
  ): 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
- let cachedCurrencyConfig: { command_timeout_ms: number; fetch_timeout_ms: number } | null = null;
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
- if (cachedCurrencyConfig) return cachedCurrencyConfig;
42
+ const hit = currencyConfigCache.get("default");
43
+ if (hit) return hit;
36
44
  const cfg = await loadRegistryConfig("config", "scala-tools-currency", CurrencyConfigSchema);
37
- cachedCurrencyConfig = {
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
- return cachedCurrencyConfig;
49
+ currencyConfigCache.set("default", config);
50
+ return config;
42
51
  }
43
52
 
44
- let cachedGithubToken: string | null | undefined;
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
- if (cachedGithubToken !== undefined) return cachedGithubToken;
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
- cachedGithubToken = null;
63
+ githubTokenCache.set("default", null);
50
64
  return null;
51
65
  }
52
66
  const decrypted = decryptSecretConfig(raw as never) as Record<string, unknown>;
53
- cachedGithubToken = typeof decrypted.token === "string" ? decrypted.token : null;
54
- return cachedGithubToken;
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 { registry, name } = registryFromPackageUrl(PACKAGE_URL);
90
- return getGithubPackagesLatestVersion(name, token, registry);
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,12 +21,6 @@ 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
 
47
25
  // Client asset building (expensive: Vite build).
48
26
  //
@@ -63,6 +41,44 @@ export async function buildFullApp(
63
41
  // The steps after this one do not depend on client assets: pg-boss needs the
64
42
  // registry and the DB, not a bundle. Ordering it behind a Vite build was
65
43
  // incidental, so the coupling was never intended.
44
+
45
+ // pg-boss (mount for UI services - wasn't mounted in minimal app)
46
+ //
47
+ // Wrapped for the SAME reason buildClientAssets above is, in the other
48
+ // direction: a background-work failure must not cost the page. Unwrapped,
49
+ // anything mountPgBoss throws propagates out of the full-app build, the
50
+ // caller falls back to the MINIMAL app, and the service serves 0 api routes
51
+ // with /health 503 — so the tunnel health check fails and the site is
52
+ // unreachable from outside while localhost answers in 0.1s.
53
+ //
54
+ // Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
55
+ // build expecting a newer shared pgboss schema than the database carried, its
56
+ // migration threw, and an outage of every page followed from a scheduled-job
57
+ // dependency. Jobs are the degradable half; pages are not.
58
+
59
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
60
+ import type { Hono } from "hono";
61
+ import { buildClientAssets } from "./build-client-assets.ts";
62
+ import { mountPgBoss } from "./pg-boss-mount.ts";
63
+ import { swapApp } from "./mutable-app-ref.ts";
64
+ import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
65
+ import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
66
+ import type { PageServerDeps } from "./ui-service.ts";
67
+
68
+ export interface FullAppResult {
69
+ app: Hono;
70
+ blocks: { slug: string; packagePath: string }[];
71
+ routes: string[];
72
+ pgBossJobs: Array<{ handlerSlug: string }>;
73
+ }
74
+
75
+ export async function buildFullApp(
76
+ minimalResult: MinimalAppResult,
77
+ deps: PageServerDeps,
78
+ ): Promise<FullAppResult> {
79
+ const log = getAppLogger();
80
+ const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
81
+
66
82
  try {
67
83
  await buildClientAssets(serviceSlug);
68
84
  log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
@@ -76,13 +92,20 @@ export async function buildFullApp(
76
92
  );
77
93
  }
78
94
 
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)`);
95
+ let pgBossJobs: Awaited<ReturnType<typeof mountPgBoss>>["jobs"] = [];
96
+ try {
97
+ ({ jobs: pgBossJobs } = await mountPgBoss({
98
+ databaseUrl: minimalResult.databaseUrl,
99
+ serviceSlug,
100
+ packages: config.server_packages ?? [],
101
+ }));
102
+ log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
103
+ } catch (error) {
104
+ log.error(
105
+ `[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss FAILED — background jobs are DOWN for this service, pages continue`,
106
+ { error: error instanceof Error ? error.message : String(error) },
107
+ );
108
+ }
86
109
  if (pgBossJobs.length > 0) {
87
110
  log.info(
88
111
  `[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
@@ -9,6 +9,39 @@
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
+ // Fire independent I/O + compilation NOW, overlapping with the gate chain
33
+ // below. These three touch NO app route and have no ordering dependency on
34
+ // the gate/mount sequence, so they run concurrently with it:
35
+ // - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
36
+ // that overlaps the gate chain's cross-internet DB I/O — same speculative
37
+ // pattern as create-boot-pipeline's preloads). mountServerPackages later
38
+ // hits the cache (instant imports), paying only the ~33ms of mount work.
39
+ // - initGateTtls: loads config rows into module-level vars. The TTL getters
40
+ // are read at REQUEST time (inside route-handler closures), not at boot,
41
+ // so this only needs to finish before serving — safe to run in parallel.
42
+ // - discoverBlocks: loads the page-manifest (no app mutation, no gate
43
+ // dependency). Its result is consumed by registerPageRoutes much later.
44
+
12
45
  import { getAppLogger } from "@teamscala/logger/app-loggers";
13
46
  import { BootFatalError } from "@teamscala/os/errors/boot-error";
14
47
  import { createTimer } from "@teamscala/timing/create-timer";
@@ -63,25 +96,6 @@ export interface MinimalAppResult {
63
96
  skipped: string[]; // Skipped block names
64
97
  }
65
98
 
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
99
  export async function buildMinimalApp(
86
100
  config: ServiceInstallConfig,
87
101
  serviceSlug: string,
@@ -167,18 +181,6 @@ export async function buildMinimalApp(
167
181
  );
168
182
  attachRequestLogger(app, serviceSlug);
169
183
 
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
184
  const serverPackages = ((config as { server_packages?: string[] })
183
185
  .server_packages ?? []) as string[];
184
186
  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,9 +29,6 @@ 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
 
60
33
  /**
61
34
  * Reset both phase-2 flags. TEST-ONLY.
@@ -71,17 +44,6 @@ export function markFullAppReady(): void {
71
44
  * Never called from production code — `setPageHandler`/`markFullAppReady` are
72
45
  * the only writers on the boot path.
73
46
  */
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
47
 
86
48
  /**
87
49
  * `/health` for a UI service — 200 ONLY once the full app can actually render
@@ -125,6 +87,47 @@ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
125
87
  * tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
126
88
  * one consumer contract is "can you serve me a page", is moved to phase 2.
127
89
  */
90
+
91
+ import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
92
+ import { WAKING_RETRY_SECONDS } from "@teamscala/os/brand/waking-page-budget.generated";
93
+ import type { Context } from "hono";
94
+ import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
95
+ import { resolveErrorPageLogo } from "./resource-not-found.ts";
96
+ import type { CreatePageHandler } from "./types.ts";
97
+
98
+ // Module-level storage for the real page handler, updated when Phase 2 completes.
99
+ // This allows the temporary Phase 1 handler to delegate to the real handler once ready.
100
+ let realPageHandler: CreatePageHandler | null = null;
101
+ let fullAppReady = false;
102
+
103
+ /**
104
+ * Set the real page handler (called when Phase 2 completes on an SSR service).
105
+ *
106
+ * Registering the SSR handler implies the full app is built, so this also flips
107
+ * readiness — but the reverse does NOT hold, which is why `markFullAppReady`
108
+ * exists separately. See its note.
109
+ */
110
+ export function setPageHandler(handler: CreatePageHandler): void {
111
+ realPageHandler = handler;
112
+ markFullAppReady();
113
+ }
114
+
115
+ export function markFullAppReady(): void {
116
+ fullAppReady = true;
117
+ }
118
+
119
+ export function __resetPhase2StateForTest(): void {
120
+ realPageHandler = null;
121
+ fullAppReady = false;
122
+ }
123
+
124
+ /**
125
+ * Get the current page handler state.
126
+ */
127
+ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
128
+ return { ready: fullAppReady, hasHandler: realPageHandler !== null };
129
+ }
130
+
128
131
  export function healthHandler(c: Context): Response {
129
132
  if (!fullAppReady) return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
130
133
  // Assert the CAPABILITY, not just the phase flag. On 2026-08-20 this returned