@teamscala/service-boot 0.23.495 → 0.23.496

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.495",
9
+ "version": "0.23.496",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -55,7 +55,7 @@
55
55
  // be silently bypassed; a valid local-export chunk carries a
56
56
  // class/function/const/var/let/import, a valid re-export carries `from`).
57
57
 
58
- import { mkdir, readdir, rm } from "node:fs/promises";
58
+ import { mkdir, readdir, rename, rm } from "node:fs/promises";
59
59
  import { join } from "node:path";
60
60
  import { getAppLogger } from "@teamscala/logger/app-loggers";
61
61
  import { fileHash, removeStaleHashedFiles } from "./hashed-output.ts";
@@ -79,6 +79,7 @@ export async function buildHydrateBundle(
79
79
  ): Promise<HydrateBundleResult | null> {
80
80
  const entry = join(cwd, "src", "entry-client.ts");
81
81
  const outDir = join(cwd, "dist", "client");
82
+ const tmpFile = join(outDir, `${HYDRATE_BASE}.js`);
82
83
 
83
84
  if (!(await Bun.file(entry).exists())) {
84
85
  log.warn(
@@ -122,26 +123,7 @@ export async function buildHydrateBundle(
122
123
  entrypoints: [entry],
123
124
  target: "browser",
124
125
  outdir: outDir,
125
- // THE BUNDLER OWNS THE HASH, because it also writes the references.
126
- // Under `splitting: true` + `publicPath`, Bun bakes the entry's
127
- // build-time name into every chunk that imports it. Naming the entry
128
- // `hydrate.js` here and RENAMING it to `hydrate-<hash>.js` afterwards
129
- // moved the file but could not rewrite the specifiers already emitted
130
- // into the chunks — so every chunk imported `/assets/hydrate.js`, which
131
- // no longer existed. 404 on the module, the importing block dies, and
132
- // the page still returns 200 with every guard green.
133
- //
134
- // MEASURED 2026-09-08 on quiz.scala.business: `AssessmentFlowBlock`
135
- // failed with "Failed to fetch dynamically imported module"; crawling
136
- // the served graph (142 level-1 + 76 level-2 assets) found exactly one
137
- // broken URL — `/assets/hydrate.js` — imported by the entry itself and
138
- // by most chunks. Hash applied OUTSIDE the generator is two artefacts
139
- // that must agree with nothing making them (`one-generator-pattern`).
140
- naming: {
141
- entry: `${HYDRATE_BASE}-[hash].[ext]`,
142
- chunk: "chunk-[hash].[ext]",
143
- asset: "[name]-[hash].[ext]",
144
- },
126
+ naming: `${HYDRATE_BASE}.js`,
145
127
  minifyWhitespace: process.env.NODE_ENV === "production",
146
128
  minifySyntax: process.env.NODE_ENV === "production",
147
129
  minifyIdentifiers: false,
@@ -250,24 +232,10 @@ export async function buildHydrateBundle(
250
232
  );
251
233
  }
252
234
 
253
- // Bun emitted the hashed entry itself, so the name it wrote into every
254
- // chunk IS the name on disk. Read it back from the build's own output
255
- // list rather than reconstructing it — a reconstructed name is a second
256
- // copy of a fact the bundler already owns, which is the defect this
257
- // change removes (`a-mutating-verb-reads-back-what-it-claims`).
258
- const entryOutput = result.outputs.find(
259
- (o) => o.kind === "entry-point",
260
- );
261
- if (!entryOutput) {
262
- throw new Error(
263
- "[build-client-assets] Bun.build reported success but emitted no entry-point output — refusing to ship a bundle with no entry.",
264
- );
265
- }
266
- const hashedName = entryOutput.path.split("/").pop() as string;
235
+ const hash = await fileHash(tmpFile);
236
+ const hashedName = `${HYDRATE_BASE}-${hash}.js`;
267
237
  const hashedPath = join(outDir, hashedName);
268
- const hash =
269
- /^hydrate-([A-Za-z0-9]+)\.js$/.exec(hashedName)?.[1] ??
270
- (await fileHash(hashedPath));
238
+ await rename(tmpFile, hashedPath);
271
239
  removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
272
240
 
273
241
  // Splitting emits one chunk per lazy import() alongside the entry. Keep the
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * resource-chat-inject.page-assistant.test.ts — the page assistant must be
7
+ * PRESENT IN RENDERED OUTPUT on every link shape a page resolves.
8
+ *
9
+ * THE DEFECT THIS PINS (found 2026-09-08, quiz report page): the page_route's
10
+ * assistant named resource_key "response" — the loader's RAW response hit.
11
+ * On /report/<response-id> that hit exists and the chat bubble injected. On
12
+ * anchor links (a research-record id, live or superseded) the raw hit is
13
+ * null, injectResourceChatLauncher read no organisation_id off it, returned
14
+ * SILENTLY, and the page rendered a complete report at 200 with the
15
+ * assistant simply absent — 552KB of script gone, no error anywhere. The
16
+ * page's reportData was byte-identical across all three shapes, which is
17
+ * exactly why a data-equality check could not see it: the launcher is a
18
+ * separate BLOCK whose presence depends on resolved[resourceKey], not on the
19
+ * report data.
20
+ *
21
+ * THE FIX SHAPE: the page's resource_key names the PAIRED key ("pair"),
22
+ * which exists on every link shape and carries organisation_id (the pair
23
+ * resolver returns the anchor half's organisation). These tests drive the
24
+ * injector with graphs shaped exactly like the loader produces for each of
25
+ * the three link shapes and assert the launcher lands in the rendered
26
+ * content tree — the thing that becomes markup — for ALL THREE. A
27
+ * data-equality assertion cannot pass in their place.
28
+ */
29
+
30
+ import { describe, expect, mock, test } from "bun:test";
31
+
32
+ /** The ONE active launcher block_schema slug — the Puck registry key. */
33
+ const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
34
+
35
+ mock.module("@teamscala/db/registry/load-config", () => ({
36
+ loadRegistryConfig: async () => ({ agentId: "agent-1" }),
37
+ }));
38
+ mock.module("./page-server-orpc/server-client.ts", () => ({
39
+ getServerClient: () => ({
40
+ ai_agents: {
41
+ findFirst: async () => ({
42
+ name: "Results Assistant",
43
+ welcome_message: "Hi! Ask me anything about your results.",
44
+ conversation_starters: ["What are my strengths?"],
45
+ primary_color: "#6366f1",
46
+ }),
47
+ },
48
+ }),
49
+ }));
50
+ mock.module("@teamscala/logger/app-loggers", () => ({
51
+ getAppLogger: () => ({ error: (...args: unknown[]) => void args }),
52
+ }));
53
+
54
+ const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
55
+
56
+ const ORG = "org-1";
57
+ const AGENT_OPTS = {
58
+ agentConfigSlug: "assessment-assistant-agent" as never,
59
+ launchLabel: "Ask about your results",
60
+ placeholder: "Ask about your results…",
61
+ regionSlots: ["header", "body", "footer"] as readonly string[],
62
+ };
63
+
64
+ /** A resolved response row as the loader's response source selects it. */
65
+ const RESPONSE_ROW = {
66
+ id: "resp-1",
67
+ assessment_id: "asm-1",
68
+ organisation_id: ORG,
69
+ total_score: 64,
70
+ first_name: "Anna",
71
+ };
72
+
73
+ /** The paired key as resolveReportHalves returns it — present on EVERY link
74
+ * shape, carrying the anchor half's organisation. */
75
+ const PAIRED = {
76
+ response: RESPONSE_ROW,
77
+ research: { id: "research-live", assessment_id: "asm-1", organisation_id: ORG },
78
+ assessment_id: "asm-1",
79
+ organisation_id: ORG,
80
+ };
81
+
82
+ function pageGraph(
83
+ responseSource: unknown,
84
+ pairSource: Record<string, unknown>,
85
+ ): Record<string, unknown> {
86
+ return {
87
+ response: responseSource,
88
+ pair: pairSource,
89
+ quiz: { id: "asm-1", organisation_id: ORG, title: "Authority Index" },
90
+ brand: { primaryColor: "#123456" },
91
+ body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
92
+ header: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
93
+ footer: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
94
+ };
95
+ }
96
+
97
+ /** The launcher renders `position: fixed`, so it may land in whichever
98
+ * region slot is declared first — count across EVERY slot, not one. */
99
+ function launcherBlocks(
100
+ resolved: Record<string, unknown>,
101
+ ): Array<{ type: string; props?: Record<string, unknown> }> {
102
+ const out: Array<{ type: string; props?: Record<string, unknown> }> = [];
103
+ for (const slot of AGENT_OPTS.regionSlots) {
104
+ const content = (
105
+ resolved[slot] as {
106
+ puck_data?: { content?: Array<{ type: string; props?: Record<string, unknown> }> };
107
+ }
108
+ ).puck_data?.content;
109
+ for (const block of content ?? []) {
110
+ if (block.type === REGISTERED_LAUNCHER_SLUG) out.push(block);
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ async function launcherCount(
117
+ responseSource: unknown,
118
+ pairSource: Record<string, unknown>,
119
+ resourceKey: string,
120
+ ): Promise<number> {
121
+ const resolved = pageGraph(responseSource, pairSource);
122
+ await injectResourceChatLauncher(resolved, "", { ...AGENT_OPTS, resourceKey });
123
+ return launcherBlocks(resolved).length;
124
+ }
125
+
126
+ describe("page assistant renders on every link shape", () => {
127
+ test("response-id link: raw hit exists, launcher renders", async () => {
128
+ expect(await launcherCount(RESPONSE_ROW, PAIRED, "pair")).toBe(1);
129
+ });
130
+
131
+ test("live research-anchor link: raw hit is null, launcher still renders", async () => {
132
+ expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
133
+ });
134
+
135
+ test("superseded research-anchor link: raw hit is null, launcher still renders", async () => {
136
+ // identical graph shape to the live anchor — resolution to the live
137
+ // pass happens upstream in the pair resolver, so the injection surface
138
+ // is the same and is exercised identically
139
+ expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
140
+ });
141
+
142
+ test("the launcher block carries the agent, org scope and context", async () => {
143
+ const resolved = pageGraph(null, PAIRED);
144
+ await injectResourceChatLauncher(resolved, "viewer@example.com", {
145
+ ...AGENT_OPTS,
146
+ resourceKey: "pair",
147
+ });
148
+ const launchers = launcherBlocks(resolved);
149
+ expect(launchers.length).toBe(1);
150
+ const launcher = launchers[0];
151
+ expect(launcher.props.agentId).toBe("agent-1");
152
+ expect(launcher.props.organisationId).toBe(ORG);
153
+ expect(typeof launcher.props.runtimeContext).toBe("string");
154
+ expect((launcher.props.runtimeContext as string).length).toBeGreaterThan(0);
155
+ });
156
+
157
+ test("the DEFECT SHAPE: keying the raw response hit on an anchor link silently renders none", async () => {
158
+ // resource_key "response" + a null raw hit is the 2026-09-08 regression.
159
+ // The injector contract is fail-soft for the render, so the absence is
160
+ // quiet — this pins the shape with the reason spelled out, so the row
161
+ // can never go back to a key that is null on anchor links without the
162
+ // next reader finding this.
163
+ expect(await launcherCount(null, PAIRED, "response")).toBe(0);
164
+ });
165
+ });
@@ -315,7 +315,21 @@ export async function injectResourceChatLauncher(
315
315
  const organisationId =
316
316
  opts.organisationId ||
317
317
  (typeof record?.organisation_id === "string" ? record.organisation_id : "");
318
- if (!organisationId) return;
318
+ if (!organisationId) {
319
+ // THIS BAIL USED TO BE SILENT, AND A SILENT DROP IS THE WORST FAILURE
320
+ // SHAPE A BLOCK CAN HAVE (measured 2026-09-08 on the quiz report): the
321
+ // page returns 200 and looks complete while the assistant is simply
322
+ // absent — resource_key named a resolved key that is null on some link
323
+ // shapes (a research-anchor link has no raw response match), and the
324
+ // page rendered a full report with no bubble and no error anywhere.
325
+ // The page must stay usable (fail-soft for the RENDER is the contract),
326
+ // but a declared assistant that did not inject is never "fine": name
327
+ // the key and the reason so the absence is observable.
328
+ getAppLogger().error(
329
+ `[resource-chat] assistant declared but NOT injected: resolved["${opts.resourceKey}"] is ${record === undefined ? "absent" : "null"} or carries no organisation_id — the launcher is silently missing from this page`,
330
+ );
331
+ return;
332
+ }
319
333
 
320
334
  let agentId = "";
321
335
  try {