@k2b/cloud 0.29.1 → 0.30.0

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 (77) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +45 -9
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/message-utils.ts +3 -0
  17. package/src/ai/chat/messages.ts +8 -2
  18. package/src/ai/chat/tool-names.ts +2 -0
  19. package/src/ai/chat/turn-layout.ts +2 -2
  20. package/src/ai/chat/turn-view.tsx +29 -11
  21. package/src/ai/code-runtime-tools.ts +59 -44
  22. package/src/ai/default-tool-contracts.ts +198 -0
  23. package/src/ai/default-tools.ts +25 -1
  24. package/src/ai/executor.ts +19 -21
  25. package/src/ai/index.ts +9 -1
  26. package/src/ai/migrate.ts +59 -12
  27. package/src/ai/runtime-tools.ts +1 -0
  28. package/src/ai/skill-content.ts +54 -0
  29. package/src/ai/skills-routes.ts +22 -13
  30. package/src/ai/skills.ts +177 -231
  31. package/src/ai/tool-sentences.ts +38 -0
  32. package/src/ai/types.ts +8 -1
  33. package/src/api/admin-ai-skills.ts +35 -16
  34. package/src/api/fanout.ts +128 -0
  35. package/src/api/help.ts +8 -3
  36. package/src/api/mcp.ts +4 -1
  37. package/src/api/search.ts +31 -106
  38. package/src/api/widgets.ts +259 -100
  39. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  40. package/src/browser/ndjson.ts +34 -0
  41. package/src/browser/reload.ts +46 -0
  42. package/src/browser/search-stream.ts +4 -26
  43. package/src/browser/widgets.ts +28 -0
  44. package/src/capabilities/server.ts +20 -2
  45. package/src/cli/access.ts +48 -14
  46. package/src/cli/admin/ai-skills.ts +59 -39
  47. package/src/contracts/announcement-cookie.ts +62 -0
  48. package/src/contracts/announcements.ts +14 -52
  49. package/src/contracts/capabilities.ts +24 -0
  50. package/src/contracts/pwa-paths.ts +7 -2
  51. package/src/contracts/pwa.ts +2 -7
  52. package/src/contracts/registry.ts +1 -0
  53. package/src/contracts/shared.ts +5 -0
  54. package/src/contracts/widgets.ts +20 -0
  55. package/src/index.ts +1 -0
  56. package/src/server/help.ts +8 -1
  57. package/src/services/accounts/users.ts +15 -0
  58. package/src/services/help/index.ts +18 -5
  59. package/src/services/help/types.ts +3 -2
  60. package/src/services/identity/constants.ts +2 -0
  61. package/src/services/identity/key-ring.ts +123 -42
  62. package/src/services/identity/runtime-config.ts +13 -2
  63. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  64. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  65. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  66. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  67. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  68. package/src/ssr/PwaRuntime.island.tsx +1 -1
  69. package/src/ssr/app-navigation.ts +5 -7
  70. package/src/ssr/global-search-host.tsx +55 -0
  71. package/src/ssr/help.ts +3 -3
  72. package/src/ssr/rail-context.ts +4 -3
  73. package/src/ssr/runtime.ts +14 -1
  74. package/src/ai/code-mode-skill.ts +0 -114
  75. package/src/ai/data-analysis-skill.ts +0 -10
  76. package/src/ai/grids-skill.ts +0 -158
  77. package/src/ai/skill-seeds.ts +0 -560
@@ -1,14 +1,22 @@
1
- import { Hono, type MiddlewareHandler } from "hono";
1
+ import { type Context, Hono, type MiddlewareHandler } from "hono";
2
2
  import { readBoundedJson } from "../_internal/bounded-json";
3
- import { listWidgets } from "../_internal/registry";
4
- import { WIDGET_MAX_RESPONSE_BYTES, WidgetResponseSchema } from "../contracts/widgets";
3
+ import { type DashboardWidget, listWidgets } from "../_internal/registry";
4
+ import {
5
+ WIDGET_MAX_RESPONSE_BYTES,
6
+ type WidgetResponse,
7
+ WidgetResponseSchema,
8
+ type WidgetStreamLine,
9
+ widgetKey,
10
+ } from "../contracts/widgets";
5
11
  import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
12
+ import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
6
13
  import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
7
14
  import { widgetInvocationOperation } from "../services/identity/invocation-operations";
8
15
  import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
9
16
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
10
17
  import { logger } from "../services/logging";
11
18
  import { LOCALE_HEADER } from "../shared/locale";
19
+ import { ndjsonStream, startBounded, waitWithin } from "./fanout";
12
20
 
13
21
  type WidgetRouteDependencies = {
14
22
  listWidgets?: typeof listWidgets;
@@ -19,7 +27,19 @@ type WidgetRouteDependencies = {
19
27
  timeoutMs?: number;
20
28
  };
21
29
 
22
- export const WIDGET_PROXY_TIMEOUT_MS = 500;
30
+ /**
31
+ * Each widget's own budget, counted from the moment Core starts it: the same interactive budget Universal Search
32
+ * gives its providers. Widgets stream in as they finish, so a slow one delays only itself.
33
+ */
34
+ export const WIDGET_TIMEOUT_MS = 8_000;
35
+ /** Widgets Core asks at the same time for one dashboard, as many as Universal Search asks providers. */
36
+ export const WIDGET_CONCURRENCY = 8;
37
+ /**
38
+ * A stream signs every invocation up front; no widget may still start after those invocations expire. Widgets that
39
+ * have not answered by then end as `timeout`.
40
+ */
41
+ const WIDGET_STREAM_DEADLINE_MS = CLOUD_INVOCATION_TOKEN_TTL_SECONDS * 1_000;
42
+
23
43
  const log = logger("widgets");
24
44
  type WidgetRejectionReason =
25
45
  | "upstream_timeout"
@@ -30,114 +50,253 @@ type WidgetRejectionReason =
30
50
  | "deadline_exceeded"
31
51
  | "request_cancelled"
32
52
  | "operation_failed";
53
+ type WidgetPhase = "registry" | "signing" | "provider" | "response";
33
54
 
34
- const jsonError = (message: string, status: 502 | 504): Response =>
35
- Response.json({ message }, { status, headers: { "content-type": "application/json" } });
36
-
37
- const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
38
- new Promise<T>((resolve, reject) => {
39
- const aborted = () => reject(signal.reason);
40
- if (signal.aborted) return aborted();
41
- signal.addEventListener("abort", aborted, { once: true });
42
- value.then(
43
- (result) => {
44
- signal.removeEventListener("abort", aborted);
45
- resolve(result);
46
- },
47
- (error) => {
48
- signal.removeEventListener("abort", aborted);
49
- reject(error);
50
- },
51
- );
55
+ const logRejection = (
56
+ widget: { appId: string; widgetId: string },
57
+ phase: WidgetPhase,
58
+ reason: WidgetRejectionReason,
59
+ upstreamStatus?: number,
60
+ ) =>
61
+ log.warn("Widget proxy rejected response", {
62
+ appId: widget.appId.slice(0, 80),
63
+ widgetId: widget.widgetId.slice(0, 100),
64
+ phase,
65
+ reason,
66
+ ...(upstreamStatus === undefined ? {} : { upstreamStatus }),
52
67
  });
53
68
 
69
+ type ProviderOutcome =
70
+ | { status: "ok"; widget: WidgetResponse }
71
+ | { status: "empty" | "forbidden" }
72
+ | { status: "timeout" | "error"; phase: WidgetPhase; reason: WidgetRejectionReason; upstreamStatus?: number };
73
+
74
+ /**
75
+ * Calls one provider with its exact invocation and validates the answer. Never throws: every failure becomes a
76
+ * bounded outcome that the caller logs and reports.
77
+ */
78
+ const callProvider = async (options: {
79
+ c: Context<AuthContext>;
80
+ widget: DashboardWidget;
81
+ token: string;
82
+ requestId: string | undefined;
83
+ signal: AbortSignal;
84
+ timeout: AbortSignal;
85
+ fetch: NonNullable<WidgetRouteDependencies["fetch"]>;
86
+ }): Promise<ProviderOutcome> => {
87
+ const { c, widget, signal, timeout } = options;
88
+ let phase: WidgetPhase = "provider";
89
+ try {
90
+ const headers = new Headers({ authorization: `Bearer ${options.token}` });
91
+ if (options.requestId) headers.set("x-request-id", options.requestId);
92
+ for (const name of ["traceparent", "tracestate"] as const) {
93
+ const value = c.req.header(name);
94
+ if (value) headers.set(name, value);
95
+ }
96
+ const locale = preferredLocale(c.req.raw.headers);
97
+ if (locale) headers.set(LOCALE_HEADER, locale);
98
+ headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
99
+
100
+ const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
101
+ signal.throwIfAborted();
102
+ const response = await options.fetch(targetUrl, { headers, signal });
103
+ if (response.status === 204 || response.status === 403) {
104
+ await response.body?.cancel();
105
+ return { status: response.status === 204 ? "empty" : "forbidden" };
106
+ }
107
+ if (!response.ok) {
108
+ await response.body?.cancel();
109
+ return response.status === 504
110
+ ? { status: "timeout", phase, reason: "upstream_timeout", upstreamStatus: response.status }
111
+ : { status: "error", phase, reason: "upstream_status", upstreamStatus: response.status };
112
+ }
113
+ phase = "response";
114
+ const body = await readBoundedJson(response, WIDGET_MAX_RESPONSE_BYTES);
115
+ signal.throwIfAborted();
116
+ if (!body.ok) return { status: "error", phase, reason: body.reason };
117
+ const parsed = WidgetResponseSchema.safeParse(body.data);
118
+ if (!parsed.success) return { status: "error", phase, reason: "invalid_schema" };
119
+ return { status: "ok", widget: parsed.data };
120
+ } catch (error) {
121
+ const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
122
+ return isTimeout
123
+ ? { status: "timeout", phase, reason: "deadline_exceeded" }
124
+ : { status: "error", phase, reason: signal.aborted ? "request_cancelled" : "operation_failed" };
125
+ }
126
+ };
127
+
54
128
  export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
55
129
  const registry = dependencies.listWidgets ?? listWidgets;
56
130
  const fetchWidget = dependencies.fetch ?? globalThis.fetch;
131
+ const timeoutMs = dependencies.timeoutMs ?? WIDGET_TIMEOUT_MS;
132
+ const withActiveSigner = dependencies.withActiveSigner ?? withActiveIdentitySigner;
133
+ const signInvocation = dependencies.signInvocation ?? signInvocationToken;
134
+
135
+ const sign = (
136
+ c: Context<AuthContext>,
137
+ widget: DashboardWidget,
138
+ requestId: string | undefined,
139
+ signer: Parameters<Parameters<typeof withActiveIdentitySigner>[1]>[0],
140
+ ) =>
141
+ signInvocation({
142
+ targetAppId: widget.appId,
143
+ callingAppId: "core",
144
+ operation: widgetInvocationOperation(widget.widgetId),
145
+ schemaHash: null,
146
+ authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
147
+ requestId,
148
+ signer,
149
+ issuer: signer.issuer,
150
+ });
57
151
 
58
- return new Hono<AuthContext>()
59
- .use(dependencies.authenticate ?? auth.requireRole("authenticated"))
60
- .use(rejectReservedWorkloadCredential)
61
- .use(auth.requireOAuthScope("read", "admin"))
62
- .get("/widgets/v1/:appId/:widgetId", async (c) => {
63
- const timeout = AbortSignal.timeout(dependencies.timeoutMs ?? WIDGET_PROXY_TIMEOUT_MS);
64
- const signal = AbortSignal.any([c.req.raw.signal, timeout]);
65
- const appId = c.req.param("appId");
66
- const widgetId = c.req.param("widgetId");
67
- const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
68
- let phase: "registry" | "signing" | "provider" | "response" = "registry";
69
- const reject = (reason: WidgetRejectionReason, message: string, status: 502 | 504, upstreamStatus?: number): Response => {
70
- log.warn("Widget proxy rejected response", {
71
- appId: appId.slice(0, 80),
72
- widgetId: widgetId.slice(0, 100),
73
- phase,
74
- reason,
75
- ...(upstreamStatus === undefined ? {} : { upstreamStatus }),
152
+ return (
153
+ new Hono<AuthContext>()
154
+ .use(dependencies.authenticate ?? auth.requireRole("authenticated"))
155
+ .use(rejectReservedWorkloadCredential)
156
+ .use(auth.requireOAuthScope("read", "admin"))
157
+ /**
158
+ * Every requested widget (`?widget=<appId>/<widgetId>`, repeatable; all declared widgets without one) as an
159
+ * NDJSON stream: one line per widget as soon as it answers, each within its own budget.
160
+ */
161
+ .get("/widgets/v1", async (c) => {
162
+ const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
163
+ const requested = new Set(c.req.queries("widget") ?? []);
164
+ const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
165
+ let widgets: DashboardWidget[];
166
+ try {
167
+ const keys = new Set<string>();
168
+ widgets = (await waitWithin(registry(), setup)).filter((widget) => {
169
+ const key = widgetKey(widget.appId, widget.widgetId);
170
+ if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
171
+ keys.add(key);
172
+ return true;
173
+ });
174
+ } catch {
175
+ log.warn("Widget registry unavailable", { reason: setup.aborted ? "deadline_exceeded" : "operation_failed" });
176
+ return c.json({ message: "Widget registry is currently unavailable" }, 503);
177
+ }
178
+
179
+ const streamDeadline = AbortSignal.timeout(WIDGET_STREAM_DEADLINE_MS);
180
+ // A client that stops reading, such as a browser leaving the dashboard, ends every widget it still waits for.
181
+ const streamCancelled = new AbortController();
182
+ const fanoutSignal = AbortSignal.any([c.req.raw.signal, streamDeadline, streamCancelled.signal]);
183
+ // One signing guard for the whole dashboard, not one Postgres check per widget.
184
+ let tokens: PromiseSettledResult<Awaited<ReturnType<typeof signInvocationToken>>>[] = [];
185
+ try {
186
+ if (widgets.length > 0) {
187
+ const signing = AbortSignal.any([fanoutSignal, AbortSignal.timeout(timeoutMs)]);
188
+ tokens = await waitWithin(
189
+ withActiveSigner(
190
+ "invocation",
191
+ (signer) =>
192
+ Promise.all(startBounded(widgets, WIDGET_CONCURRENCY, async (widget) => sign(c, widget, requestId, signer), signing)),
193
+ { signal: signing, timeoutMs },
194
+ ),
195
+ signing,
196
+ );
197
+ }
198
+ } catch {
199
+ log.warn("Widget invocation authority unavailable", { reason: fanoutSignal.aborted ? "deadline_exceeded" : "operation_failed" });
200
+ return c.json({ message: "Widget invocation authority is currently unavailable" }, 503);
201
+ }
202
+
203
+ const fanoutStarted = performance.now();
204
+ const settled = startBounded(
205
+ widgets,
206
+ WIDGET_CONCURRENCY,
207
+ async (widget, index): Promise<WidgetStreamLine> => {
208
+ const key = widgetKey(widget.appId, widget.widgetId);
209
+ const started = performance.now();
210
+ const elapsed = () => Math.round(performance.now() - started);
211
+ const token = tokens[index];
212
+ if (token?.status !== "fulfilled") {
213
+ logRejection(widget, "signing", "operation_failed");
214
+ return { type: "widget", key, status: "error", ms: elapsed() };
215
+ }
216
+ const timeout = AbortSignal.timeout(timeoutMs);
217
+ const outcome = await callProvider({
218
+ c,
219
+ widget,
220
+ token: token.value.token,
221
+ requestId,
222
+ signal: AbortSignal.any([fanoutSignal, timeout]),
223
+ timeout,
224
+ fetch: fetchWidget,
225
+ });
226
+ if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
227
+ // A widget the stream itself stopped has already been settled, and is logged once, below.
228
+ if ("reason" in outcome && !fanoutSignal.aborted) logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
229
+ return { type: "widget", key, status: outcome.status, ms: elapsed() };
230
+ },
231
+ fanoutSignal,
232
+ );
233
+
234
+ return ndjsonStream<WidgetStreamLine>({
235
+ first: { type: "start", widgets: widgets.map((widget) => widgetKey(widget.appId, widget.widgetId)) },
236
+ pending: settled.map((pending, index) =>
237
+ pending.then((result): WidgetStreamLine => {
238
+ if (result.status === "fulfilled") return result.value;
239
+ // Only the stream's own deadline or the browser leaving settles a widget this way. Leaving is not a
240
+ // failure of the widget, so only the deadline is logged.
241
+ const widget = widgets[index]!;
242
+ const status = streamDeadline.aborted ? "timeout" : "error";
243
+ if (streamDeadline.aborted) logRejection(widget, "provider", "deadline_exceeded");
244
+ return {
245
+ type: "widget",
246
+ key: widgetKey(widget.appId, widget.widgetId),
247
+ status,
248
+ ms: Math.round(performance.now() - fanoutStarted),
249
+ };
250
+ }),
251
+ ),
252
+ last: (lines) => ({
253
+ type: "done",
254
+ status: lines.some((line) => line.type === "widget" && (line.status === "timeout" || line.status === "error"))
255
+ ? "partial"
256
+ : "complete",
257
+ }),
258
+ onCancel: () => streamCancelled.abort(),
76
259
  });
77
- return jsonError(message, status);
78
- };
79
- try {
80
- const widget = (await waitWithin(registry(), signal)).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
81
- if (!widget) return c.json({ message: "Widget not found" }, 404);
260
+ })
261
+ .get("/widgets/v1/:appId/:widgetId", async (c) => {
262
+ const timeout = AbortSignal.timeout(timeoutMs);
263
+ const signal = AbortSignal.any([c.req.raw.signal, timeout]);
264
+ const appId = c.req.param("appId");
265
+ const widgetId = c.req.param("widgetId");
266
+ const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
267
+ let phase: WidgetPhase = "registry";
268
+ try {
269
+ const widget = (await waitWithin(registry(), signal)).find((entry) => entry.appId === appId && entry.widgetId === widgetId);
270
+ if (!widget) return c.json({ message: "Widget not found" }, 404);
82
271
 
83
- phase = "signing";
84
- const headers = await (async () => {
272
+ phase = "signing";
85
273
  const signed = await waitWithin(
86
- (dependencies.withActiveSigner ?? withActiveIdentitySigner)(
87
- "invocation",
88
- (signer) =>
89
- (dependencies.signInvocation ?? signInvocationToken)({
90
- targetAppId: widget.appId,
91
- callingAppId: "core",
92
- operation: widgetInvocationOperation(widget.widgetId),
93
- schemaHash: null,
94
- authority: invocationAuthorityFromRequest(auth.getAuthority(c)),
95
- requestId,
96
- signer,
97
- issuer: signer.issuer,
98
- }),
99
- { signal, timeoutMs: dependencies.timeoutMs ?? WIDGET_PROXY_TIMEOUT_MS },
100
- ),
274
+ withActiveSigner("invocation", (signer) => sign(c, widget, requestId, signer), { signal, timeoutMs }),
101
275
  signal,
102
276
  );
103
- return new Headers({ authorization: `Bearer ${signed.token}` });
104
- })();
105
277
 
106
- if (requestId) headers.set("x-request-id", requestId);
107
- for (const name of ["traceparent", "tracestate"] as const) {
108
- const value = c.req.header(name);
109
- if (value) headers.set(name, value);
278
+ phase = "provider";
279
+ const outcome = await callProvider({ c, widget, token: signed.token, requestId, signal, timeout, fetch: fetchWidget });
280
+ if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
281
+ if (!("reason" in outcome))
282
+ return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
283
+ logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
284
+ if (outcome.status === "timeout") return Response.json({ message: "Widget deadline exceeded" }, { status: 504 });
285
+ const message =
286
+ outcome.reason === "invalid_schema"
287
+ ? "Widget returned an invalid response"
288
+ : outcome.reason === "too_large" || outcome.reason === "invalid_json"
289
+ ? "Widget returned invalid or oversized JSON"
290
+ : "Widget is unavailable";
291
+ return Response.json({ message }, { status: 502 });
292
+ } catch (error) {
293
+ const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
294
+ const reason: WidgetRejectionReason = isTimeout ? "deadline_exceeded" : signal.aborted ? "request_cancelled" : "operation_failed";
295
+ logRejection({ appId, widgetId }, phase, reason);
296
+ return isTimeout
297
+ ? Response.json({ message: "Widget deadline exceeded" }, { status: 504 })
298
+ : Response.json({ message: "Widget is unavailable" }, { status: 502 });
110
299
  }
111
- const locale = preferredLocale(c.req.raw.headers);
112
- if (locale) headers.set(LOCALE_HEADER, locale);
113
- headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
114
-
115
- const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
116
- phase = "provider";
117
- signal.throwIfAborted();
118
- const response = await fetchWidget(targetUrl, { headers, signal });
119
- if (response.status === 204 || response.status === 403) {
120
- await response.body?.cancel();
121
- return new Response(null, { status: response.status, headers: { "content-type": "application/json" } });
122
- }
123
- if (!response.ok) {
124
- await response.body?.cancel();
125
- return response.status === 504
126
- ? reject("upstream_timeout", "Widget deadline exceeded", 504, response.status)
127
- : reject("upstream_status", "Widget is unavailable", 502, response.status);
128
- }
129
- phase = "response";
130
- const body = await readBoundedJson(response, WIDGET_MAX_RESPONSE_BYTES);
131
- signal.throwIfAborted();
132
- if (!body.ok) return reject(body.reason, "Widget returned invalid or oversized JSON", 502);
133
- const parsed = WidgetResponseSchema.safeParse(body.data);
134
- if (!parsed.success) return reject("invalid_schema", "Widget returned an invalid response", 502);
135
- return Response.json(parsed.data, { headers: { "content-type": "application/json" } });
136
- } catch (error) {
137
- const isTimeout = timeout.aborted || (error instanceof Error && error.name === "TimeoutError");
138
- return isTimeout
139
- ? reject("deadline_exceeded", "Widget deadline exceeded", 504)
140
- : reject(signal.aborted ? "request_cancelled" : "operation_failed", "Widget is unavailable", 502);
141
- }
142
- });
300
+ })
301
+ );
143
302
  };
@@ -1,4 +1,4 @@
1
- import { createGlobalSearchHost } from "../ssr/GlobalSearchDialog";
1
+ import { createGlobalSearchHost } from "../ssr/global-search-host";
2
2
  import { openCloudResourcePicker } from "./resource-picker";
3
3
 
4
4
  /**
@@ -0,0 +1,34 @@
1
+ /** Media type Core streams per-app results in; kept local so browser bundles do not load server schemas. */
2
+ export const NDJSON_CONTENT_TYPE = "application/x-ndjson";
3
+
4
+ /**
5
+ * Hands over each line of an NDJSON response as it arrives, until a line of type `done`. Rejects when the stream ends
6
+ * before that line, so the caller can tell a finished stream from one that broke off.
7
+ */
8
+ export const readNdjsonLines = async <Line extends { type: string }>(
9
+ response: Response,
10
+ onLine: (line: Line) => void,
11
+ name: string,
12
+ ): Promise<void> => {
13
+ if (!response.body) throw new Error(`${name} returned no body`);
14
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
15
+ let buffer = "";
16
+ let closed = false;
17
+ while (!closed) {
18
+ const chunk = await reader.read();
19
+ if (chunk.done) break;
20
+ buffer += chunk.value;
21
+ let end = buffer.indexOf("\n");
22
+ while (end >= 0) {
23
+ const text = buffer.slice(0, end).trim();
24
+ buffer = buffer.slice(end + 1);
25
+ if (text) {
26
+ const line: Line = JSON.parse(text);
27
+ onLine(line);
28
+ if (line.type === "done") closed = true;
29
+ }
30
+ end = buffer.indexOf("\n");
31
+ }
32
+ }
33
+ if (!closed) throw new Error(`${name} stream ended early`);
34
+ };
@@ -1,3 +1,6 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import { type ToastHandle, toast } from "@k2b/ui";
3
+
1
4
  /**
2
5
  * Default window in which a second automatic reload for the same key is
3
6
  * suppressed. A page load, hydration, and the first live subscription finish
@@ -32,3 +35,46 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
32
35
  window.location.reload();
33
36
  return true;
34
37
  };
38
+
39
+ const reloadMessages = i18n.define({
40
+ baseLocale: "en",
41
+ messages: {
42
+ en: {
43
+ codeUnavailable: "Part of this page could not load. Reload the page and try again.",
44
+ reload: "Reload",
45
+ },
46
+ de: {
47
+ codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
48
+ reload: "Neu laden",
49
+ },
50
+ },
51
+ });
52
+
53
+ /** One notice at a time: every failed load in the tab has the same remedy. */
54
+ let codeUnavailable: ToastHandle | undefined;
55
+
56
+ /**
57
+ * Loads code that one action needs when the action runs, for example
58
+ * `importOnDemand(() => import("./SettingsDialog"))`, so the page does not
59
+ * download it up front.
60
+ *
61
+ * Resolves `undefined` when the code cannot load. A release replaces the
62
+ * page's code files, so a page that was open before it cannot load code it
63
+ * has not loaded yet; a dropped connection fails the same way. The helper
64
+ * then shows a toast that offers a reload, and the caller only ends its
65
+ * action.
66
+ */
67
+ export const importOnDemand = async <T>(load: () => Promise<T>): Promise<T | undefined> => {
68
+ try {
69
+ return await load();
70
+ } catch (error) {
71
+ console.warn("[cloud] Could not load code on demand", error);
72
+ const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
73
+ codeUnavailable?.dismiss();
74
+ codeUnavailable = toast.error(t.codeUnavailable, {
75
+ duration: 0,
76
+ action: { label: t.reload, onClick: () => window.location.reload() },
77
+ });
78
+ return undefined;
79
+ }
80
+ };
@@ -1,8 +1,6 @@
1
1
  import type { SearchApp, SearchItem, SearchProviderStatus, SearchResponse, SearchStreamLine } from "../api/search/schemas";
2
2
  import { LOCALE_HEADER } from "../shared/locale";
3
-
4
- /** Kept local so the browser bundle does not load the server-side search schemas. */
5
- const STREAM_CONTENT_TYPE = "application/x-ndjson";
3
+ import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
6
4
 
7
5
  export type SearchAppStatus = SearchProviderStatus | "searching";
8
6
 
@@ -119,33 +117,13 @@ export const streamCloudResourceSearch = async (
119
117
  ): Promise<void> => {
120
118
  const response = await fetch(url, {
121
119
  signal: options.signal,
122
- headers: { accept: STREAM_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
120
+ headers: { accept: NDJSON_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
123
121
  });
124
122
  if (!response.ok) throw new Error(`Search failed with ${response.status}`);
125
- if (!response.headers.get("content-type")?.startsWith(STREAM_CONTENT_TYPE)) {
123
+ if (!response.headers.get("content-type")?.startsWith(NDJSON_CONTENT_TYPE)) {
126
124
  const body: SearchResponse = await response.json();
127
125
  for (const line of linesFromResponse(body)) options.onLine(line);
128
126
  return;
129
127
  }
130
- if (!response.body) throw new Error("Search returned no body");
131
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
132
- let buffer = "";
133
- let closed = false;
134
- while (!closed) {
135
- const chunk = await reader.read();
136
- if (chunk.done) break;
137
- buffer += chunk.value;
138
- let end = buffer.indexOf("\n");
139
- while (end >= 0) {
140
- const text = buffer.slice(0, end).trim();
141
- buffer = buffer.slice(end + 1);
142
- if (text) {
143
- const line: SearchStreamLine = JSON.parse(text);
144
- options.onLine(line);
145
- if (line.type === "done") closed = true;
146
- }
147
- end = buffer.indexOf("\n");
148
- }
149
- }
150
- if (!closed) throw new Error("Search stream ended early");
128
+ await readNdjsonLines(response, options.onLine, "Search");
151
129
  };
@@ -0,0 +1,28 @@
1
+ import type { WidgetStreamLine } from "../contracts/widgets";
2
+ import { LOCALE_HEADER } from "../shared/locale";
3
+ import { NDJSON_CONTENT_TYPE, readNdjsonLines } from "./ndjson";
4
+
5
+ export type { WidgetResponse, WidgetStreamLine, WidgetStreamStatus } from "../contracts/widgets";
6
+
7
+ /**
8
+ * Asks Core for dashboard widgets and hands over each one as soon as it answers: `start` names the widgets, one
9
+ * `widget` line follows per widget in the order they finish, then `done`. `keys` (`<appId>/<widgetId>`) limits the
10
+ * request, for example to retry one widget; without it Core asks every declared widget. Aborting `signal` stops every
11
+ * widget Core still waits for. Rejects when the request fails or the stream breaks off.
12
+ */
13
+ export const streamWidgets = async (options: {
14
+ keys?: readonly string[];
15
+ signal: AbortSignal;
16
+ locale: string;
17
+ onLine: (line: WidgetStreamLine) => void;
18
+ }): Promise<void> => {
19
+ const query = new URLSearchParams();
20
+ for (const key of options.keys ?? []) query.append("widget", key);
21
+ const search = query.toString();
22
+ const response = await fetch(`/api/widgets/v1${search ? `?${search}` : ""}`, {
23
+ signal: options.signal,
24
+ headers: { accept: NDJSON_CONTENT_TYPE, [LOCALE_HEADER]: options.locale },
25
+ });
26
+ if (!response.ok) throw new Error(`Widgets failed with ${response.status}`);
27
+ await readNdjsonLines(response, options.onLine, "Widgets");
28
+ };
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
2
+ import { resolveCapabilityActionWording, resolveCapabilityManifestPresentation } from "../_internal/capabilities";
3
+ import type { CapabilityActionWording } from "../_internal/capability-sentences";
3
4
  import { getApp, getCapability } from "../_internal/registry";
4
5
  import { dispatchCapability, loadCapabilityCatalogPage } from "../api/capabilities";
5
6
  import { env } from "../config/env";
@@ -14,7 +15,7 @@ import { get } from "../services/settings";
14
15
  import { resolveAppPresentation } from "../shared/app-presentation";
15
16
  import { publicCloudOrigin } from "../shared/app-url";
16
17
  import { capabilityMessages } from "../shared/capability-messages";
17
- import { LOCALE_HEADER } from "../shared/locale";
18
+ import { DEFAULT_LOCALE, LOCALE_HEADER } from "../shared/locale";
18
19
  import { readCapabilityResponse } from "./response";
19
20
  import { combineCapabilitySignals } from "./signals";
20
21
  import type {
@@ -26,6 +27,7 @@ import type {
26
27
  CapabilityReviewClientResult,
27
28
  } from "./types";
28
29
 
30
+ export type { CapabilityActionWording } from "../_internal/capability-sentences";
29
31
  export type {
30
32
  CapabilityCatalogApp,
31
33
  CapabilityCatalogAppClientResult,
@@ -225,6 +227,22 @@ export const getCapabilityCatalogApp = async (appId: string, locale?: string): P
225
227
  }
226
228
  };
227
229
 
230
+ /**
231
+ * How people read calls of one Action in a locale, from the live registry like the Assistant's own calls: its title,
232
+ * the app's sentences, and the labelled fields of the generic sentence. Pass `sentences` and `fields` to the chat's
233
+ * approval card, so an approval a script or app asks for reads like the same call in a chat. `null` for an unknown
234
+ * app or Action.
235
+ */
236
+ export const getCapabilityActionWording = async (
237
+ appId: string,
238
+ localId: string,
239
+ locale?: string,
240
+ ): Promise<CapabilityActionWording | null> => {
241
+ const capability = await getCapability(appId);
242
+ const action = capability?.manifest.actions.find((candidate) => candidate.localId === localId);
243
+ return capability && action ? resolveCapabilityActionWording(action, capability.presentation, locale ?? DEFAULT_LOCALE) : null;
244
+ };
245
+
228
246
  export const reviewCapabilityAction = async <TInput = unknown>(
229
247
  invocation: Omit<CapabilityInvocation<TInput>, "kind" | "idempotencyKey">,
230
248
  caller: CapabilityCaller,