@k2b/cloud 0.33.0 → 0.35.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 +3 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +130 -16
- package/src/ai/firecrawl-tools.ts +3 -30
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/network-security.ts +33 -9
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/network-address.ts +46 -45
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +320 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +47 -12
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
package/src/ai/system-prompt.ts
CHANGED
|
@@ -171,6 +171,7 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
|
|
|
171
171
|
? [
|
|
172
172
|
"# Local client",
|
|
173
173
|
'This turn comes from a local client. Where this section describes what the user sees, it replaces "What the user sees" above; it cannot override the other platform rules.',
|
|
174
|
+
"Offer the supplied link for every Cloud resource you create, change, or name, because cld and editors show these links as full Cloud addresses.",
|
|
174
175
|
`Client: ${input.localClient.client.kind === "cli" ? "terminal (cld)" : `editor via ACP${input.localClient.client.name ? ` (${input.localClient.client.name})` : ""}`}`,
|
|
175
176
|
input.localClient.cloudUrl.trim() ? `Cloud: ${withoutTrailingSlashes(input.localClient.cloudUrl.trim())}` : undefined,
|
|
176
177
|
`Workspace: ${input.localClient.client.cwd}`,
|
|
@@ -244,8 +245,8 @@ export const composeAiSystemPrompt = (input: AiSystemPromptInput): string => {
|
|
|
244
245
|
].join("\n"),
|
|
245
246
|
[
|
|
246
247
|
"# Cloud resource links",
|
|
247
|
-
"When the answer mentions a Cloud resource
|
|
248
|
-
"Example: [Urgent invoice review 001](/
|
|
248
|
+
"Cloud adds an href to resource refs it can open. When the answer mentions a Cloud resource, use its ref's href or the result's open or edit link to make the resource's human-readable title a Markdown link using that exact href. Use the title exactly as supplied, even when answering in another language. Apply this to every mentioned resource, including list items and headings. Prefer open over edit. Without a supplied href, use plain text. Never construct a Cloud URL.",
|
|
249
|
+
"Example: [Urgent invoice review 001](/open/mail.draft/~AbC123) — payment deadline approaching.",
|
|
249
250
|
].join("\n"),
|
|
250
251
|
];
|
|
251
252
|
|
package/src/api/index.ts
CHANGED
package/src/api/mcp.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { z } from "zod";
|
|
|
17
17
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
18
18
|
import { HELP_SEARCH_MAX_LIMIT, helpResourceUri, parseHelpResourceUri, readHelpArticle } from "../_internal/help-catalog";
|
|
19
19
|
import { getCapability, listApps } from "../_internal/registry";
|
|
20
|
+
import { addCloudResourceHrefs } from "../ai/resource-refs";
|
|
20
21
|
import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
|
|
21
22
|
import {
|
|
22
23
|
CAPABILITY_FRAMEWORK_ERROR_CODES,
|
|
@@ -26,9 +27,11 @@ import {
|
|
|
26
27
|
type CapabilityActionManifest,
|
|
27
28
|
type CapabilityQueryManifest,
|
|
28
29
|
CapabilitySemanticLinkSchema,
|
|
29
|
-
|
|
30
|
+
type CloudResourceRef,
|
|
30
31
|
capabilityResultJsonSchema,
|
|
32
|
+
capabilityResultSchema,
|
|
31
33
|
cloudResourceRefAppId,
|
|
34
|
+
LinkedCloudResourceReferenceSchema,
|
|
32
35
|
resolveCapabilityResourceReader,
|
|
33
36
|
} from "../contracts/capabilities";
|
|
34
37
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
@@ -121,7 +124,7 @@ const platformTools: Tool[] = [
|
|
|
121
124
|
title: "Read Cloud resource",
|
|
122
125
|
description:
|
|
123
126
|
"Read one Cloud resource through its current canonical reader. Pass a typed ref returned by search, list, lookup, or another capability unchanged; never guess a reader name or substitute an id from another resource type.",
|
|
124
|
-
inputSchema: structuredClone(z.toJSONSchema(
|
|
127
|
+
inputSchema: structuredClone(z.toJSONSchema(LinkedCloudResourceReferenceSchema)) as Tool["inputSchema"],
|
|
125
128
|
annotations: { title: "Read Cloud resource", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
126
129
|
_meta: { "cloud/kind": "resource-read" },
|
|
127
130
|
},
|
|
@@ -206,7 +209,7 @@ const projectTool = (
|
|
|
206
209
|
title: `${app.appName}: ${operation.title}`,
|
|
207
210
|
description: operation.description,
|
|
208
211
|
inputSchema: action ? actionInputSchema(action) : cloneObjectSchema(operation.inputSchema),
|
|
209
|
-
outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema)),
|
|
212
|
+
outputSchema: cloneObjectSchema(capabilityResultJsonSchema(operation.dataSchema, { consumer: true })),
|
|
210
213
|
annotations: {
|
|
211
214
|
title: operation.title,
|
|
212
215
|
readOnlyHint: !isAction,
|
|
@@ -300,7 +303,7 @@ const findCapabilityTool = async (
|
|
|
300
303
|
};
|
|
301
304
|
|
|
302
305
|
const findResourceReaderTool = async (
|
|
303
|
-
ref:
|
|
306
|
+
ref: CloudResourceRef,
|
|
304
307
|
lookup: (appId: string) => Promise<CapabilityRegistryEntry | null>,
|
|
305
308
|
registry: () => Promise<AppRegistryEntry[]>,
|
|
306
309
|
): Promise<CapabilityTool | null> => {
|
|
@@ -333,15 +336,20 @@ const responseTooLargeToolResult = (): CallToolResult => {
|
|
|
333
336
|
return { isError: true, structuredContent: error, content: [{ type: "text", text: JSON.stringify(error) }] };
|
|
334
337
|
};
|
|
335
338
|
|
|
339
|
+
const toolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult => ({
|
|
340
|
+
...(isError ? { isError: true } : {}),
|
|
341
|
+
structuredContent: body,
|
|
342
|
+
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const fitsToolResult = (result: CallToolResult): boolean =>
|
|
346
|
+
new TextEncoder().encode(JSON.stringify(result)).byteLength <= CAPABILITY_MAX_RESULT_BYTES;
|
|
347
|
+
|
|
336
348
|
const ensureBoundedToolResult = (result: CallToolResult): CallToolResult =>
|
|
337
|
-
|
|
349
|
+
fitsToolResult(result) ? result : responseTooLargeToolResult();
|
|
338
350
|
|
|
339
351
|
const boundedToolResult = (body: Record<string, unknown>, isError: boolean): CallToolResult =>
|
|
340
|
-
ensureBoundedToolResult(
|
|
341
|
-
...(isError ? { isError: true } : {}),
|
|
342
|
-
structuredContent: body,
|
|
343
|
-
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
344
|
-
});
|
|
352
|
+
ensureBoundedToolResult(toolResult(body, isError));
|
|
345
353
|
|
|
346
354
|
const semanticResourceLinks = (body: Record<string, unknown>, origin: string): CallToolResult["content"] => {
|
|
347
355
|
const found = new Map<string, z.infer<typeof CapabilitySemanticLinkSchema>>();
|
|
@@ -573,14 +581,14 @@ const createMcpServer = (
|
|
|
573
581
|
let args: Record<string, unknown>;
|
|
574
582
|
try {
|
|
575
583
|
if (message.params.name === RESOURCE_READ_TOOL) {
|
|
576
|
-
const parsed =
|
|
584
|
+
const parsed = LinkedCloudResourceReferenceSchema.safeParse(message.params.arguments);
|
|
577
585
|
if (!parsed.success) {
|
|
578
586
|
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`).join("; ");
|
|
579
587
|
return validationToolError(
|
|
580
588
|
`cloud__resource__read requires one exact typed ref returned by a current Cloud capability (${issues})`,
|
|
581
589
|
);
|
|
582
590
|
}
|
|
583
|
-
selected = await findResourceReaderTool(parsed.data, capabilityLookup, registry);
|
|
591
|
+
selected = await findResourceReaderTool({ type: parsed.data.type, id: parsed.data.id }, capabilityLookup, registry);
|
|
584
592
|
args = { id: parsed.data.id };
|
|
585
593
|
if (!selected) {
|
|
586
594
|
return boundedToolResult(
|
|
@@ -646,12 +654,35 @@ const createMcpServer = (
|
|
|
646
654
|
authority,
|
|
647
655
|
dependencies,
|
|
648
656
|
});
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
657
|
+
const providerBody = await parseResponse(response);
|
|
658
|
+
let body = providerBody;
|
|
659
|
+
const origin = response.ok ? publicCloudOrigin(await (dependencies.getAppUrl ?? defaultAppUrl)()) : undefined;
|
|
660
|
+
if (origin) {
|
|
661
|
+
const parsed = capabilityResultSchema(z.unknown()).safeParse(body);
|
|
662
|
+
const appIds = new Set(parsed.success ? parsed.data.refs?.map(cloudResourceRefAppId) : []);
|
|
663
|
+
const apps = appIds.size ? await registry().catch(() => []) : [];
|
|
664
|
+
const capabilities = await Promise.all(
|
|
665
|
+
apps
|
|
666
|
+
.filter((app) => appIds.has(app.id) && app.capabilities?.protocolVersion === CAPABILITY_PROTOCOL_VERSION)
|
|
667
|
+
.map(async (app) => {
|
|
668
|
+
const entry = await capabilityLookup(app.id).catch(() => null);
|
|
669
|
+
return entry?.appId === app.id && entry.manifest.manifestHash === app.capabilities?.manifestHash ? entry : null;
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
body = addCloudResourceHrefs(
|
|
673
|
+
body,
|
|
674
|
+
capabilities.filter((entry) => entry !== null),
|
|
675
|
+
origin,
|
|
676
|
+
);
|
|
653
677
|
}
|
|
654
|
-
|
|
678
|
+
const resultFor = (value: Record<string, unknown>): CallToolResult => {
|
|
679
|
+
const result = toolResult(value, !response.ok);
|
|
680
|
+
if (origin) result.content.push(...semanticResourceLinks(value, origin));
|
|
681
|
+
return result;
|
|
682
|
+
};
|
|
683
|
+
// Added hrefs are optional: they never turn a result that fits into RESPONSE_TOO_LARGE.
|
|
684
|
+
const result = resultFor(body);
|
|
685
|
+
return ensureBoundedToolResult(fitsToolResult(result) || body === providerBody ? result : resultFor(providerBody));
|
|
655
686
|
});
|
|
656
687
|
|
|
657
688
|
return server;
|
package/src/api/pwa.ts
CHANGED
|
@@ -134,11 +134,16 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
134
134
|
describeRoute({
|
|
135
135
|
tags,
|
|
136
136
|
summary: "Read a pairing",
|
|
137
|
-
description:
|
|
137
|
+
description:
|
|
138
|
+
"Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the phone claims the link.",
|
|
138
139
|
responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
|
|
139
140
|
}),
|
|
140
141
|
v("param", IdParamSchema, pwaInvalidRequest),
|
|
141
|
-
async (c) =>
|
|
142
|
+
async (c) => {
|
|
143
|
+
const actor = await webActor(c);
|
|
144
|
+
const hold = { ms: PWA_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
|
|
145
|
+
return c.json(await service.inspectPairing(actor, c.req.valid("param").id, hold));
|
|
146
|
+
},
|
|
142
147
|
)
|
|
143
148
|
.post(
|
|
144
149
|
"/pairings/:id/confirm",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getCapability } from "../_internal/registry";
|
|
3
|
+
import {
|
|
4
|
+
CapabilitySemanticLinkSchema,
|
|
5
|
+
type CloudResourceRef,
|
|
6
|
+
CloudResourceRefSchema,
|
|
7
|
+
capabilityResultSchema,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
resolveCapabilityResourceReader,
|
|
10
|
+
} from "../contracts/capabilities";
|
|
11
|
+
import type { RequestAuthority } from "../server";
|
|
12
|
+
import { dispatchCapability } from "./capabilities";
|
|
13
|
+
|
|
14
|
+
export const resolveCloudResourceHref = async (
|
|
15
|
+
params: { ref: CloudResourceRef; request: Request; authority: RequestAuthority; locale?: string },
|
|
16
|
+
dependencies: { getCapability?: typeof getCapability; dispatch?: typeof dispatchCapability } = {},
|
|
17
|
+
): Promise<string | null> => {
|
|
18
|
+
const parsed = CloudResourceRefSchema.safeParse(params.ref);
|
|
19
|
+
if (!parsed.success) return null;
|
|
20
|
+
const ref = parsed.data;
|
|
21
|
+
const appId = cloudResourceRefAppId(ref);
|
|
22
|
+
const app = await (dependencies.getCapability ?? getCapability)(appId);
|
|
23
|
+
if (!app || app.appId !== appId) return null;
|
|
24
|
+
const reader = resolveCapabilityResourceReader(app.manifest, ref);
|
|
25
|
+
if (!reader) return null;
|
|
26
|
+
const response = await (dependencies.dispatch ?? dispatchCapability)({
|
|
27
|
+
request: params.request,
|
|
28
|
+
authority: params.authority,
|
|
29
|
+
locale: params.locale,
|
|
30
|
+
origin: "http",
|
|
31
|
+
kind: "queries",
|
|
32
|
+
appId,
|
|
33
|
+
capabilityId: reader.localId,
|
|
34
|
+
input: { id: ref.id },
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) return null;
|
|
37
|
+
const result = capabilityResultSchema(z.unknown(), { consumer: true }).safeParse(await response.json());
|
|
38
|
+
if (!result.success) return null;
|
|
39
|
+
const link = CapabilitySemanticLinkSchema.safeParse(
|
|
40
|
+
result.data.links?.find((link) => link.rel === "open") ?? result.data.links?.find((link) => link.rel === "edit"),
|
|
41
|
+
);
|
|
42
|
+
return link.success ? link.data.href : null;
|
|
43
|
+
};
|
package/src/api/widgets.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { z } from "zod";
|
|
2
4
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
3
5
|
import { type DashboardWidget, listWidgets } from "../_internal/registry";
|
|
6
|
+
import { parseWidgetSettings } from "../_internal/widget-settings";
|
|
4
7
|
import {
|
|
5
8
|
type DashboardWidgetSize,
|
|
6
9
|
fitWidgetSize,
|
|
7
10
|
isDashboardWidgetSize,
|
|
11
|
+
WIDGET_KEY_MAX_LENGTH,
|
|
8
12
|
WIDGET_MAX_RESPONSE_BYTES,
|
|
13
|
+
WIDGET_REQUEST_MAX_BYTES,
|
|
14
|
+
WIDGET_REQUEST_MAX_ENTRIES,
|
|
15
|
+
WIDGET_REQUEST_MAX_ID_LENGTH,
|
|
16
|
+
WIDGET_SETTINGS_MAX_FIELDS,
|
|
9
17
|
type WidgetResponse,
|
|
10
18
|
WidgetResponseSchema,
|
|
11
19
|
type WidgetStreamLine,
|
|
12
20
|
widgetKey,
|
|
13
21
|
} from "../contracts/widgets";
|
|
14
22
|
import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
|
|
15
|
-
import { WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
23
|
+
import { WIDGET_SETTINGS_PARAM, WIDGET_SETTINGS_PARAM_MAX_LENGTH, WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
16
24
|
import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
|
|
17
25
|
import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
|
|
18
26
|
import { widgetInvocationOperation } from "../services/identity/invocation-operations";
|
|
@@ -49,6 +57,7 @@ type WidgetRejectionReason =
|
|
|
49
57
|
| "upstream_timeout"
|
|
50
58
|
| "upstream_status"
|
|
51
59
|
| "too_large"
|
|
60
|
+
| "settings_too_large"
|
|
52
61
|
| "invalid_json"
|
|
53
62
|
| "invalid_schema"
|
|
54
63
|
| "deadline_exceeded"
|
|
@@ -84,6 +93,7 @@ const callProvider = async (options: {
|
|
|
84
93
|
widget: DashboardWidget;
|
|
85
94
|
/** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
|
|
86
95
|
size: unknown;
|
|
96
|
+
settings?: unknown;
|
|
87
97
|
token: string;
|
|
88
98
|
requestId: string | undefined;
|
|
89
99
|
signal: AbortSignal;
|
|
@@ -105,6 +115,12 @@ const callProvider = async (options: {
|
|
|
105
115
|
|
|
106
116
|
const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
|
|
107
117
|
targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
|
|
118
|
+
if (widget.settings) {
|
|
119
|
+
const json = JSON.stringify(parseWidgetSettings(widget.settings, options.settings));
|
|
120
|
+
const encodedLength = new URLSearchParams([[WIDGET_SETTINGS_PARAM, json]]).toString().length - WIDGET_SETTINGS_PARAM.length - 1;
|
|
121
|
+
if (encodedLength > WIDGET_SETTINGS_PARAM_MAX_LENGTH) return { status: "error", phase: "provider", reason: "settings_too_large" };
|
|
122
|
+
targetUrl.searchParams.set(WIDGET_SETTINGS_PARAM, json);
|
|
123
|
+
}
|
|
108
124
|
signal.throwIfAborted();
|
|
109
125
|
const response = await options.fetch(targetUrl, { headers, signal });
|
|
110
126
|
if (response.status === 204 || response.status === 403) {
|
|
@@ -143,6 +159,30 @@ const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [st
|
|
|
143
159
|
return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
|
|
144
160
|
};
|
|
145
161
|
|
|
162
|
+
const WidgetEntriesSchema = z
|
|
163
|
+
.object({
|
|
164
|
+
widgets: z
|
|
165
|
+
.array(
|
|
166
|
+
z
|
|
167
|
+
.object({
|
|
168
|
+
id: z.string().min(1).max(WIDGET_REQUEST_MAX_ID_LENGTH),
|
|
169
|
+
key: z.string().min(1).max(WIDGET_KEY_MAX_LENGTH),
|
|
170
|
+
size: z.string().optional(),
|
|
171
|
+
settings: z
|
|
172
|
+
.record(z.string(), z.unknown())
|
|
173
|
+
.refine((values) => Object.keys(values).length <= WIDGET_SETTINGS_MAX_FIELDS)
|
|
174
|
+
.optional(),
|
|
175
|
+
})
|
|
176
|
+
.strict(),
|
|
177
|
+
)
|
|
178
|
+
.min(1)
|
|
179
|
+
.max(WIDGET_REQUEST_MAX_ENTRIES)
|
|
180
|
+
.refine((entries) => new Set(entries.map((entry) => entry.id)).size === entries.length, "Widget entry ids must be unique"),
|
|
181
|
+
})
|
|
182
|
+
.strict();
|
|
183
|
+
|
|
184
|
+
type WidgetEntry = { widget: DashboardWidget; id?: string; size?: string; settings?: unknown };
|
|
185
|
+
|
|
146
186
|
export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
|
|
147
187
|
const registry = dependencies.listWidgets ?? listWidgets;
|
|
148
188
|
const fetchWidget = dependencies.fetch ?? globalThis.fetch;
|
|
@@ -167,6 +207,96 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
167
207
|
issuer: signer.issuer,
|
|
168
208
|
});
|
|
169
209
|
|
|
210
|
+
const stream = async (c: Context<AuthContext>, entries: WidgetEntry[], requestId: string | undefined) => {
|
|
211
|
+
const streamDeadline = AbortSignal.timeout(WIDGET_STREAM_DEADLINE_MS);
|
|
212
|
+
// A client that stops reading, such as a browser leaving the dashboard, ends every widget it still waits for.
|
|
213
|
+
const streamCancelled = new AbortController();
|
|
214
|
+
const fanoutSignal = AbortSignal.any([c.req.raw.signal, streamDeadline, streamCancelled.signal]);
|
|
215
|
+
// One signing guard for the whole dashboard, not one Postgres check per widget.
|
|
216
|
+
let tokens: PromiseSettledResult<Awaited<ReturnType<typeof signInvocationToken>>>[] = [];
|
|
217
|
+
try {
|
|
218
|
+
if (entries.length > 0) {
|
|
219
|
+
const signing = AbortSignal.any([fanoutSignal, AbortSignal.timeout(timeoutMs)]);
|
|
220
|
+
tokens = await waitWithin(
|
|
221
|
+
withActiveSigner(
|
|
222
|
+
"invocation",
|
|
223
|
+
(signer) =>
|
|
224
|
+
Promise.all(startBounded(entries, WIDGET_CONCURRENCY, async (entry) => sign(c, entry.widget, requestId, signer), signing)),
|
|
225
|
+
{ signal: signing, timeoutMs },
|
|
226
|
+
),
|
|
227
|
+
signing,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
log.warn("Widget invocation authority unavailable", { reason: fanoutSignal.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
232
|
+
return c.json({ message: "Widget invocation authority is currently unavailable" }, 503);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fanoutStarted = performance.now();
|
|
236
|
+
const settled = startBounded(
|
|
237
|
+
entries,
|
|
238
|
+
WIDGET_CONCURRENCY,
|
|
239
|
+
async (entry, index): Promise<WidgetStreamLine> => {
|
|
240
|
+
const { widget } = entry;
|
|
241
|
+
const identity = entry.id === undefined ? {} : { id: entry.id };
|
|
242
|
+
const key = widgetKey(widget.appId, widget.widgetId);
|
|
243
|
+
const started = performance.now();
|
|
244
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
245
|
+
const token = tokens[index];
|
|
246
|
+
if (token?.status !== "fulfilled") {
|
|
247
|
+
logRejection(widget, "signing", "operation_failed");
|
|
248
|
+
return { type: "widget", ...identity, key, status: "error", ms: elapsed() };
|
|
249
|
+
}
|
|
250
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
251
|
+
const outcome = await callProvider({
|
|
252
|
+
c,
|
|
253
|
+
widget,
|
|
254
|
+
token: token.value.token,
|
|
255
|
+
requestId,
|
|
256
|
+
signal: AbortSignal.any([fanoutSignal, timeout]),
|
|
257
|
+
timeout,
|
|
258
|
+
fetch: fetchWidget,
|
|
259
|
+
size: entry.size,
|
|
260
|
+
settings: entry.settings,
|
|
261
|
+
});
|
|
262
|
+
if (outcome.status === "ok") return { type: "widget", ...identity, key, status: "ok", widget: outcome.widget, ms: elapsed() };
|
|
263
|
+
// A widget the stream itself stopped has already been settled, and is logged once, below.
|
|
264
|
+
if ("reason" in outcome && !fanoutSignal.aborted) logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
|
|
265
|
+
return { type: "widget", ...identity, key, status: outcome.status, ms: elapsed() };
|
|
266
|
+
},
|
|
267
|
+
fanoutSignal,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return ndjsonStream<WidgetStreamLine>({
|
|
271
|
+
first: { type: "start", widgets: entries.map((entry) => entry.id ?? widgetKey(entry.widget.appId, entry.widget.widgetId)) },
|
|
272
|
+
pending: settled.map((pending, index) =>
|
|
273
|
+
pending.then((result): WidgetStreamLine => {
|
|
274
|
+
if (result.status === "fulfilled") return result.value;
|
|
275
|
+
// Only the stream's own deadline or the browser leaving settles a widget this way. Leaving is not a
|
|
276
|
+
// failure of the widget, so only the deadline is logged.
|
|
277
|
+
const entry = entries[index]!;
|
|
278
|
+
const { widget } = entry;
|
|
279
|
+
const status = streamDeadline.aborted ? "timeout" : "error";
|
|
280
|
+
if (streamDeadline.aborted) logRejection(widget, "provider", "deadline_exceeded");
|
|
281
|
+
return {
|
|
282
|
+
type: "widget",
|
|
283
|
+
...(entry.id === undefined ? {} : { id: entry.id }),
|
|
284
|
+
key: widgetKey(widget.appId, widget.widgetId),
|
|
285
|
+
status,
|
|
286
|
+
ms: Math.round(performance.now() - fanoutStarted),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
),
|
|
290
|
+
last: (lines) => ({
|
|
291
|
+
type: "done",
|
|
292
|
+
status: lines.some((line) => line.type === "widget" && (line.status === "timeout" || line.status === "error"))
|
|
293
|
+
? "partial"
|
|
294
|
+
: "complete",
|
|
295
|
+
}),
|
|
296
|
+
onCancel: () => streamCancelled.abort(),
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
170
300
|
return (
|
|
171
301
|
new Hono<AuthContext>()
|
|
172
302
|
.use(dependencies.authenticate ?? auth.requireRole("authenticated"))
|
|
@@ -197,89 +327,40 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
197
327
|
return c.json({ message: "Widget registry is currently unavailable" }, 503);
|
|
198
328
|
}
|
|
199
329
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// One signing guard for the whole dashboard, not one Postgres check per widget.
|
|
205
|
-
let tokens: PromiseSettledResult<Awaited<ReturnType<typeof signInvocationToken>>>[] = [];
|
|
206
|
-
try {
|
|
207
|
-
if (widgets.length > 0) {
|
|
208
|
-
const signing = AbortSignal.any([fanoutSignal, AbortSignal.timeout(timeoutMs)]);
|
|
209
|
-
tokens = await waitWithin(
|
|
210
|
-
withActiveSigner(
|
|
211
|
-
"invocation",
|
|
212
|
-
(signer) =>
|
|
213
|
-
Promise.all(startBounded(widgets, WIDGET_CONCURRENCY, async (widget) => sign(c, widget, requestId, signer), signing)),
|
|
214
|
-
{ signal: signing, timeoutMs },
|
|
215
|
-
),
|
|
216
|
-
signing,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
} catch {
|
|
220
|
-
log.warn("Widget invocation authority unavailable", { reason: fanoutSignal.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
221
|
-
return c.json({ message: "Widget invocation authority is currently unavailable" }, 503);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const fanoutStarted = performance.now();
|
|
225
|
-
const settled = startBounded(
|
|
226
|
-
widgets,
|
|
227
|
-
WIDGET_CONCURRENCY,
|
|
228
|
-
async (widget, index): Promise<WidgetStreamLine> => {
|
|
229
|
-
const key = widgetKey(widget.appId, widget.widgetId);
|
|
230
|
-
const started = performance.now();
|
|
231
|
-
const elapsed = () => Math.round(performance.now() - started);
|
|
232
|
-
const token = tokens[index];
|
|
233
|
-
if (token?.status !== "fulfilled") {
|
|
234
|
-
logRejection(widget, "signing", "operation_failed");
|
|
235
|
-
return { type: "widget", key, status: "error", ms: elapsed() };
|
|
236
|
-
}
|
|
237
|
-
const timeout = AbortSignal.timeout(timeoutMs);
|
|
238
|
-
const outcome = await callProvider({
|
|
239
|
-
c,
|
|
240
|
-
widget,
|
|
241
|
-
token: token.value.token,
|
|
242
|
-
requestId,
|
|
243
|
-
signal: AbortSignal.any([fanoutSignal, timeout]),
|
|
244
|
-
timeout,
|
|
245
|
-
fetch: fetchWidget,
|
|
246
|
-
size: requested.get(key),
|
|
247
|
-
});
|
|
248
|
-
if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
|
|
249
|
-
// A widget the stream itself stopped has already been settled, and is logged once, below.
|
|
250
|
-
if ("reason" in outcome && !fanoutSignal.aborted) logRejection(widget, outcome.phase, outcome.reason, outcome.upstreamStatus);
|
|
251
|
-
return { type: "widget", key, status: outcome.status, ms: elapsed() };
|
|
252
|
-
},
|
|
253
|
-
fanoutSignal,
|
|
330
|
+
return stream(
|
|
331
|
+
c,
|
|
332
|
+
widgets.map((widget) => ({ widget, size: requested.get(widgetKey(widget.appId, widget.widgetId)) })),
|
|
333
|
+
requestId,
|
|
254
334
|
);
|
|
255
|
-
|
|
256
|
-
return ndjsonStream<WidgetStreamLine>({
|
|
257
|
-
first: { type: "start", widgets: widgets.map((widget) => widgetKey(widget.appId, widget.widgetId)) },
|
|
258
|
-
pending: settled.map((pending, index) =>
|
|
259
|
-
pending.then((result): WidgetStreamLine => {
|
|
260
|
-
if (result.status === "fulfilled") return result.value;
|
|
261
|
-
// Only the stream's own deadline or the browser leaving settles a widget this way. Leaving is not a
|
|
262
|
-
// failure of the widget, so only the deadline is logged.
|
|
263
|
-
const widget = widgets[index]!;
|
|
264
|
-
const status = streamDeadline.aborted ? "timeout" : "error";
|
|
265
|
-
if (streamDeadline.aborted) logRejection(widget, "provider", "deadline_exceeded");
|
|
266
|
-
return {
|
|
267
|
-
type: "widget",
|
|
268
|
-
key: widgetKey(widget.appId, widget.widgetId),
|
|
269
|
-
status,
|
|
270
|
-
ms: Math.round(performance.now() - fanoutStarted),
|
|
271
|
-
};
|
|
272
|
-
}),
|
|
273
|
-
),
|
|
274
|
-
last: (lines) => ({
|
|
275
|
-
type: "done",
|
|
276
|
-
status: lines.some((line) => line.type === "widget" && (line.status === "timeout" || line.status === "error"))
|
|
277
|
-
? "partial"
|
|
278
|
-
: "complete",
|
|
279
|
-
}),
|
|
280
|
-
onCancel: () => streamCancelled.abort(),
|
|
281
|
-
});
|
|
282
335
|
})
|
|
336
|
+
.post(
|
|
337
|
+
"/widgets/v1",
|
|
338
|
+
bodyLimit({ maxSize: WIDGET_REQUEST_MAX_BYTES, onError: (c) => c.json({ message: "Widget request is too large" }, 400) }),
|
|
339
|
+
async (c) => {
|
|
340
|
+
let raw: unknown;
|
|
341
|
+
try {
|
|
342
|
+
raw = await c.req.json();
|
|
343
|
+
} catch {
|
|
344
|
+
return c.json({ message: "Invalid widget request JSON" }, 400);
|
|
345
|
+
}
|
|
346
|
+
const parsed = WidgetEntriesSchema.safeParse(raw);
|
|
347
|
+
if (!parsed.success) return c.json({ message: "Invalid widget request" }, 400);
|
|
348
|
+
const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
|
|
349
|
+
let declared: DashboardWidget[];
|
|
350
|
+
try {
|
|
351
|
+
declared = await waitWithin(registry(), setup);
|
|
352
|
+
} catch {
|
|
353
|
+
log.warn("Widget registry unavailable", { reason: setup.aborted ? "deadline_exceeded" : "operation_failed" });
|
|
354
|
+
return c.json({ message: "Widget registry is currently unavailable" }, 503);
|
|
355
|
+
}
|
|
356
|
+
const byKey = new Map(declared.map((widget) => [widgetKey(widget.appId, widget.widgetId), widget]));
|
|
357
|
+
const entries = parsed.data.widgets.flatMap((entry) => {
|
|
358
|
+
const widget = byKey.get(entry.key);
|
|
359
|
+
return widget ? [{ ...entry, widget }] : [];
|
|
360
|
+
});
|
|
361
|
+
return stream(c, entries, normalizeInvocationRequestId(c.req.header("x-request-id")));
|
|
362
|
+
},
|
|
363
|
+
)
|
|
283
364
|
.get("/widgets/v1/:appId/:widgetId", async (c) => {
|
|
284
365
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
285
366
|
const signal = AbortSignal.any([c.req.raw.signal, timeout]);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { timed } from "@k2b/stdlib/solid";
|
|
2
|
-
import { announce, Button, IconButton, ScrollArea, useLocale } from "@k2b/ui";
|
|
2
|
+
import { announce, Button, IconButton, Kbd, ScrollArea, useLocale } from "@k2b/ui";
|
|
3
3
|
import { createEffect, createMemo, createSignal, createUniqueId, For, on, onCleanup, onMount, Show } from "solid-js";
|
|
4
4
|
import type { SearchApp, SearchItem } from "../api/search/schemas";
|
|
5
5
|
import type { CloudResourceRef } from "../contracts";
|
|
6
|
-
import { shortcutLabel } from "./command-shortcuts";
|
|
7
6
|
import { matchNavigationSearchItems, type NavigationSearchItem } from "./navigation-search";
|
|
8
7
|
import { cloudResourceSearchUrl, filterCloudResourceSearchItems } from "./resource-search";
|
|
9
8
|
import { commitTypedTags, matchingSearchTags, searchTags, tagAtCursor } from "./resource-search-input";
|
|
@@ -39,6 +38,7 @@ export type CloudResourceSearchProps = {
|
|
|
39
38
|
navigationError?: string;
|
|
40
39
|
placeholder?: string;
|
|
41
40
|
requireReader?: boolean;
|
|
41
|
+
resourceType?: string;
|
|
42
42
|
navigationItems?: readonly NavigationSearchItem[];
|
|
43
43
|
searchResources?: boolean;
|
|
44
44
|
excludeRefs?: readonly CloudResourceRef[];
|
|
@@ -668,9 +668,7 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
668
668
|
<span>{item.title}</span>
|
|
669
669
|
<small>{item.preview ?? item.appName}</small>
|
|
670
670
|
</span>
|
|
671
|
-
<Show when={commandFor(item)?.shortcut}>
|
|
672
|
-
{(shortcut) => <kbd class="shrink-0 text-xs text-dimmed">{shortcutLabel(shortcut())}</kbd>}
|
|
673
|
-
</Show>
|
|
671
|
+
<Show when={commandFor(item)?.shortcut}>{(shortcut) => <Kbd keys={shortcut()} />}</Show>
|
|
674
672
|
<Show when={props.selectionMode && selectedKey() === itemKey(item)}>
|
|
675
673
|
<i class="ti ti-check" aria-hidden="true" />
|
|
676
674
|
</Show>
|
|
@@ -792,7 +790,9 @@ export default function CloudResourceSearch(props: CloudResourceSearchProps) {
|
|
|
792
790
|
(!activeItem() || !commandFor(activeItem()!) || isLinkableCommand(commandFor(activeItem()!)!))
|
|
793
791
|
}
|
|
794
792
|
>
|
|
795
|
-
<span
|
|
793
|
+
<span>
|
|
794
|
+
<Kbd keys="mod+enter" /> {t().newTab}
|
|
795
|
+
</span>
|
|
796
796
|
</Show>
|
|
797
797
|
</div>
|
|
798
798
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { keyboardPlatform } from "@k2b/ui";
|
|
1
2
|
import { COMMANDS_CHANGED, type ContextAwareCommand, collectContextAwareCommands } from "./command-bridge";
|
|
2
3
|
|
|
3
|
-
const isMac = () =>
|
|
4
|
+
const isMac = () => keyboardPlatform() === "apple";
|
|
4
5
|
const modifiers = ["ctrl", "alt", "shift", "meta"] as const;
|
|
5
6
|
type Combination = { key: string; ctrl: boolean; alt: boolean; shift: boolean; meta: boolean };
|
|
6
7
|
|
|
@@ -26,16 +27,6 @@ export const contextCommandsWithShortcuts = (): ContextAwareCommand[] => {
|
|
|
26
27
|
}));
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
export const shortcutLabel = (shortcut: string): string => {
|
|
30
|
-
const combination = parse(shortcut);
|
|
31
|
-
if (!combination) return "";
|
|
32
|
-
const mac = isMac();
|
|
33
|
-
const names = { ctrl: mac ? "⌃" : "Ctrl", alt: mac ? "⌥" : "Alt", shift: mac ? "⇧" : "Shift", meta: mac ? "⌘" : "Meta" };
|
|
34
|
-
const arrows: Record<string, string> = { arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
|
|
35
|
-
const key = arrows[combination.key] ?? combination.key.toUpperCase();
|
|
36
|
-
return [...modifiers.filter((name) => combination[name]).map((name) => names[name]), key].join(mac ? "" : "+");
|
|
37
|
-
};
|
|
38
|
-
|
|
39
30
|
const ARROWS = new Set(["arrowup", "arrowdown", "arrowleft", "arrowright"]);
|
|
40
31
|
|
|
41
32
|
/**
|
|
@@ -12,6 +12,7 @@ export type CloudResourcePickerOptions = {
|
|
|
12
12
|
initialAppId?: string;
|
|
13
13
|
excludeRefs?: readonly CloudResourceRef[];
|
|
14
14
|
requireReader?: boolean;
|
|
15
|
+
resourceType?: string;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export const openCloudResourcePicker = (options: CloudResourcePickerOptions = {}): Promise<CloudResourcePickerItem | undefined> => {
|
|
@@ -27,8 +27,13 @@ export const cloudResourceSearchUrl = (request: {
|
|
|
27
27
|
|
|
28
28
|
export const filterCloudResourceSearchItems = (
|
|
29
29
|
items: readonly SearchItem[],
|
|
30
|
-
options: { excludeRefs?: readonly CloudResourceRef[]; requireReader?: boolean },
|
|
30
|
+
options: { excludeRefs?: readonly CloudResourceRef[]; requireReader?: boolean; resourceType?: string },
|
|
31
31
|
): SearchItem[] => {
|
|
32
32
|
const excluded = new Set((options.excludeRefs ?? []).map(refKey));
|
|
33
|
-
return items.filter(
|
|
33
|
+
return items.filter(
|
|
34
|
+
(item) =>
|
|
35
|
+
(options.resourceType === undefined || item.ref.type === options.resourceType) &&
|
|
36
|
+
(!options.requireReader || item.readable) &&
|
|
37
|
+
!excluded.has(refKey(item.ref)),
|
|
38
|
+
);
|
|
34
39
|
};
|