@teamscala/service-boot 0.23.494 → 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.494",
9
+ "version": "0.23.496",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -18,16 +18,13 @@
18
18
  "dependencies": {
19
19
  "@orpc/server": "*",
20
20
  "@teamscala/agreement-generation": "*",
21
- "@teamscala/api-resend": "*",
22
21
  "@teamscala/auth": "*",
23
22
  "@teamscala/cache": "*",
24
23
  "@teamscala/chat-completion": "*",
25
24
  "@teamscala/db": "*",
26
25
  "@teamscala/db-validation": "*",
27
26
  "@teamscala/document-signing": "*",
28
- "@teamscala/email": "*",
29
27
  "@teamscala/email-client": "*",
30
- "@teamscala/email-template": "*",
31
28
  "@teamscala/encryption": "*",
32
29
  "@teamscala/event-log": "*",
33
30
  "@teamscala/file-lock": "*",
@@ -38,14 +35,11 @@
38
35
  "@teamscala/http": "*",
39
36
  "@teamscala/logger": "*",
40
37
  "@teamscala/mcp-multi-session": "*",
41
- "@teamscala/notify-cache": "*",
42
38
  "@teamscala/object-storage": "*",
43
39
  "@teamscala/orpc": "*",
44
40
  "@teamscala/os": "*",
45
41
  "@teamscala/publish-registry": "*",
46
- "@teamscala/realtime-utils": "*",
47
42
  "@teamscala/resolver-engine": "*",
48
- "@teamscala/safe-fallback": "*",
49
43
  "@teamscala/timing": "*",
50
44
  "@teamscala/tool-executor": "*",
51
45
  "@teamscala/watchdog": "*",
@@ -10,7 +10,7 @@
10
10
  * kind:ui service's stylesheet (2026-09-01, operator-visible).
11
11
  */
12
12
  import { describe, expect, test } from "bun:test";
13
- import { mkdir, rm, writeFile } from "node:fs/promises";
13
+ import { mkdir, rm } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
15
  import { tmpdir } from "node:os";
16
16
  import { scanSourceCandidates } from "./source-candidates.ts";
@@ -23,7 +23,7 @@ async function makeSourceTree(tag: string, files: Record<string, string>) {
23
23
  for (const [rel, content] of Object.entries(files)) {
24
24
  const p = join(root, rel);
25
25
  await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
26
- await writeFile(p, content);
26
+ await Bun.file(p).write(content);
27
27
  }
28
28
  return root;
29
29
  }
@@ -14,7 +14,7 @@
14
14
  * boundaries the verification layer depends on.
15
15
  */
16
16
 
17
- import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
17
+ import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { afterAll, afterEach, describe, expect, test } from "bun:test";
@@ -36,10 +36,7 @@ async function makeDir(): Promise<string> {
36
36
  async function seedMaps(outDir: string, names: string[]): Promise<void> {
37
37
  await mkdir(outDir, { recursive: true });
38
38
  for (const name of names) {
39
- await writeFile(
40
- join(outDir, name),
41
- JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" }),
42
- );
39
+ await Bun.file(join(outDir, name)).write(JSON.stringify({ version: 3, sources: ["a.ts"], mappings: "AAAA" }));
43
40
  }
44
41
  }
45
42
 
@@ -14,7 +14,7 @@
14
14
  * explicitly, never resolved and never thrown on.
15
15
  */
16
16
  import { describe, expect, test } from "bun:test";
17
- import { mkdir, rm, writeFile } from "node:fs/promises";
17
+ import { mkdir, rm } from "node:fs/promises";
18
18
  import { join } from "node:path";
19
19
  import { tmpdir } from "node:os";
20
20
  import { bunResolveStylesheet } from "./styles-build.ts";
@@ -25,7 +25,7 @@ async function makeTree(tag: string, files: Record<string, string>) {
25
25
  for (const [rel, content] of Object.entries(files)) {
26
26
  const p = join(root, rel);
27
27
  await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
28
- await writeFile(p, content);
28
+ await Bun.file(p).write(content);
29
29
  }
30
30
  return root;
31
31
  }
@@ -2,14 +2,13 @@
2
2
  * @system server-boot
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
- * @indivisible-unit owner=unassigned reason=81 lines; pre-2026-08-27 exemption, unreviewed
6
5
  *
7
6
  * Install harness — sandbox-mounts each declared package and captures
8
7
  * registry.require() calls via AsyncLocalStorage. Validates all required
9
8
  * rows exist before returning. Throws HarnessInstallError with full
10
9
  * context on failure. Per Constitution Rule 2 step 3.
11
10
  *
12
- * Marked @indivisible-unit: HarnessInstallError is the error class
11
+ * Marked: HarnessInstallError is the error class
13
12
  * validatePackageDependencies (might) throw — they form one canonical
14
13
  * "install harness validation" unit (the validator + the failure-mode
15
14
  * class it produces).
@@ -2,7 +2,6 @@
2
2
  * @system server-boot
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
- * @indivisible-unit owner=unassigned reason=188 lines; pre-2026-08-27 exemption, unreviewed
6
5
  *
7
6
  * One-call install of the entire logging pipeline. After installLogging()
8
7
  * runs, every error in the service — process-level, HTTP middleware, Hono
@@ -11,7 +10,7 @@
11
10
  * loga (dev) with full context. Application code never imports the logger
12
11
  * or wraps anything in try/catch for telemetry purposes.
13
12
  *
14
- * Marked @indivisible-unit: installLogging (early bootstrap, hardcoded
13
+ * Marked: installLogging (early bootstrap, hardcoded
15
14
  * defaults) and applyLoggerConfig (late-stage, DB-driven) are the two
16
15
  * phases of the same one-call install — the service boot pipeline calls
17
16
  * them in sequence with the same serviceSlug.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @system ui-service-boot
3
- * @status handwritten @indivisible-unit owner=unassigned reason=91 lines; pre-2026-08-27 exemption, unreviewed
3
+ * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
6
  * The two SWR loader-graph caches (static-shell + parameterised routes) for the
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @system ui-service-boot
3
- * @status handwritten @indivisible-unit owner=unassigned reason=82 lines; pre-2026-08-27 exemption, unreviewed
3
+ * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
6
  * Meta/title helpers for the route registrar: interpolate the route's
@@ -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 {
@@ -2,7 +2,6 @@
2
2
  * @system service-boot
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
- * @indivisible-unit owner=unassigned reason=299 lines; pre-2026-08-27 exemption, unreviewed
6
5
  *
7
6
  * One cohesive unit — the branded resource-not-found / holding page: the
8
7
  * PrimaryResourceMissingError trigger, the org-logo resolver the page