@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 +1 -7
- package/src/build-client-assets/source-candidates.test.ts +2 -2
- package/src/build-client-assets/sourcemaps-upload.test.ts +2 -5
- package/src/build-client-assets/styles-build.test.ts +2 -2
- package/src/install-harness.ts +1 -2
- package/src/install-logging.ts +1 -2
- package/src/register-registry-page-route/graph-cache.ts +1 -1
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/resource-chat-inject.page-assistant.test.ts +165 -0
- package/src/resource-chat-inject.ts +15 -1
- package/src/resource-not-found.ts +0 -1
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
28
|
+
await Bun.file(p).write(content);
|
|
29
29
|
}
|
|
30
30
|
return root;
|
|
31
31
|
}
|
package/src/install-harness.ts
CHANGED
|
@@ -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
|
|
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).
|
package/src/install-logging.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
|
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
|