@k2b/cloud 0.29.0 → 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.
- package/package.json +5 -3
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +111 -3
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +10 -1
- package/src/_internal/registry-validation.ts +5 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +203 -185
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +12 -26
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +4 -4
- package/src/ai/chat/turn-view.tsx +141 -69
- package/src/ai/code-runtime-tools.ts +59 -44
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +19 -21
- package/src/ai/index.ts +9 -1
- package/src/ai/migrate.ts +59 -12
- package/src/ai/protocol.ts +9 -4
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +20 -13
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/types.ts +10 -2
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +259 -100
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/widgets.ts +28 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +20 -0
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/services/accounts/users.ts +15 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/shared/network-address.ts +1 -0
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +142 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
package/src/api/widgets.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
const headers = await (async () => {
|
|
272
|
+
phase = "signing";
|
|
85
273
|
const signed = await waitWithin(
|
|
86
|
-
(
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
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
|
-
|
|
112
|
-
|
|
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
|
};
|
|
@@ -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
|
+
};
|
package/src/browser/reload.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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
|
-
|
|
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,
|