@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.
- package/package.json +3 -2
- 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 +45 -9
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -2
- package/src/ai/chat/turn-view.tsx +29 -11
- 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/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/tool-sentences.ts +38 -0
- package/src/ai/types.ts +8 -1
- package/src/api/admin-ai-skills.ts +35 -16
- 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/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- 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/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/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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sentences for Cloud's own Assistant tools that act on something, in the catalog shape every app uses for
|
|
5
|
+
* its Actions and keyed by tool name. The chat words these calls through the same renderer as app Actions;
|
|
6
|
+
* a test checks every placeholder against the tool's input schema. A tool that only reads or does several
|
|
7
|
+
* things, such as `memory` searching, adding, or forgetting, keeps its title with what it acted on instead.
|
|
8
|
+
*/
|
|
9
|
+
export const CLOUD_AI_TOOL_PRESENTATION = {
|
|
10
|
+
baseLocale: "en",
|
|
11
|
+
sentences: {
|
|
12
|
+
// Code asks for approvals of what it does inside the run, so a rejection or a stop does not mean the code did not run.
|
|
13
|
+
code_run: { approval: "Run code", done: "Ran code" },
|
|
14
|
+
code_action: { approval: "Run app action {input.action}", done: "Ran app action {input.action}" },
|
|
15
|
+
web_extract: { approval: "Read {input.url}", done: "Read {input.url}", notRun: "{input.url} not read" },
|
|
16
|
+
fetch_file: { approval: "Download {input.url}", done: "Downloaded {input.url}", notRun: "{input.url} not downloaded" },
|
|
17
|
+
write_file: { approval: "Write {input.path}", done: "Wrote {input.path}", notRun: "{input.path} not written" },
|
|
18
|
+
},
|
|
19
|
+
translations: {
|
|
20
|
+
de: {
|
|
21
|
+
actions: {
|
|
22
|
+
code_run: { sentences: { approval: "Code ausführen", done: "Code ausgeführt" } },
|
|
23
|
+
code_action: { sentences: { approval: "App-Aktion {input.action} ausführen", done: "App-Aktion {input.action} ausgeführt" } },
|
|
24
|
+
web_extract: { sentences: { approval: "{input.url} lesen", done: "{input.url} gelesen", notRun: "{input.url} nicht gelesen" } },
|
|
25
|
+
fetch_file: {
|
|
26
|
+
sentences: {
|
|
27
|
+
approval: "{input.url} herunterladen",
|
|
28
|
+
done: "{input.url} heruntergeladen",
|
|
29
|
+
notRun: "{input.url} nicht heruntergeladen",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
write_file: {
|
|
33
|
+
sentences: { approval: "{input.path} schreiben", done: "{input.path} geschrieben", notRun: "{input.path} nicht geschrieben" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
} as const satisfies CapabilityPresentationCatalog;
|
package/src/ai/types.ts
CHANGED
|
@@ -12,7 +12,8 @@ import type {
|
|
|
12
12
|
} from "@k2b/nessi";
|
|
13
13
|
import type { Usage } from "@k2b/nessi/ai";
|
|
14
14
|
import type { z } from "zod";
|
|
15
|
-
import type {
|
|
15
|
+
import type { CapabilitySentenceField } from "../_internal/capability-sentences";
|
|
16
|
+
import type { CapabilityActionReview, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
|
|
16
17
|
import type { RequestActor } from "../server";
|
|
17
18
|
import type { AiTurnBlock } from "./protocol";
|
|
18
19
|
|
|
@@ -1025,6 +1026,12 @@ export type AiCapabilityToolPresentation = {
|
|
|
1025
1026
|
appAccent?: string;
|
|
1026
1027
|
title: string;
|
|
1027
1028
|
capabilityKind: "query" | "action";
|
|
1029
|
+
/** Actions: the app's sentences in the locale of the turn, worded for each call by the chat. */
|
|
1030
|
+
sentences?: CapabilityActionSentences;
|
|
1031
|
+
/** Actions: field formats for the sentences and labelled fields for the generic sentence, in schema order. */
|
|
1032
|
+
fields?: CapabilitySentenceField[];
|
|
1033
|
+
/** Actions: Cloud offered the model `approvalReason` for this call, so the chat may show it as the model's reason. */
|
|
1034
|
+
approvalReason?: true;
|
|
1028
1035
|
};
|
|
1029
1036
|
|
|
1030
1037
|
export type AiToolPresentation = AiCapabilityToolPresentation;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { aiAppSkills } from "../ai/app-skill-store";
|
|
4
4
|
import { AiSkillInputError, AiSkillLastAdminError, AiSkillRevisionConflictError, aiSkills } from "../ai/skills";
|
|
5
5
|
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
6
6
|
import { type AuthContext, auth, err, fail, ok, respond, v } from "../server";
|
|
@@ -18,34 +18,52 @@ const SkillAccessUpdateSchema = z.object({ permission: z.enum(["read", "write",
|
|
|
18
18
|
const notFound = (c: Parameters<typeof respond>[0], noun: string) => respond(c, fail(err.notFound(noun)));
|
|
19
19
|
const conflict = (c: Parameters<typeof respond>[0], error: Error) => respond(c, { ok: false, error: error.message, status: 409 as const });
|
|
20
20
|
|
|
21
|
+
const ConfirmedSchema = z.object({ confirmed: z.literal(true) }).strict();
|
|
22
|
+
|
|
23
|
+
const installAppSkill = async (c: Parameters<typeof respond>[0], install: () => Promise<boolean>) => {
|
|
24
|
+
try {
|
|
25
|
+
return (await install()) ? respond(c, ok({ installed: true })) : notFound(c, "App Skill");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error instanceof AiSkillRevisionConflictError) return conflict(c, error);
|
|
28
|
+
if (error instanceof AiSkillInputError) return respond(c, fail(err.badInput(error.message)));
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
21
33
|
export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
|
|
22
34
|
new Hono<AuthContext>()
|
|
23
35
|
.use("*", authenticate)
|
|
24
36
|
.get("/", v("query", ListSkillsSchema), async (c) => {
|
|
25
37
|
const query = c.req.valid("query");
|
|
26
|
-
const [skills, summary] = await Promise.all([
|
|
38
|
+
const [skills, summary, appSkillIssues] = await Promise.all([
|
|
39
|
+
aiSkills.admin.list(query),
|
|
40
|
+
aiSkills.admin.summary({ search: query.search }),
|
|
41
|
+
aiAppSkills.appSkillIssues(),
|
|
42
|
+
]);
|
|
27
43
|
return respond(
|
|
28
44
|
c,
|
|
29
45
|
ok({
|
|
30
46
|
...skills,
|
|
31
47
|
items: skills.items.map((skill) => ({ ...skill, id: skill.shortId })),
|
|
32
48
|
summary,
|
|
49
|
+
appSkillIssues,
|
|
33
50
|
}),
|
|
34
51
|
);
|
|
35
52
|
})
|
|
36
|
-
.get("/
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
.get("/:skillId/app-version", async (c) => {
|
|
54
|
+
const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
|
|
55
|
+
if (!skill) return notFound(c, "Skill");
|
|
56
|
+
const version = await aiAppSkills.appVersion(skill.id);
|
|
57
|
+
return version ? respond(c, ok(version)) : notFound(c, "Skill");
|
|
58
|
+
})
|
|
39
59
|
.post(
|
|
40
|
-
"/:skillId/
|
|
60
|
+
"/:skillId/reset",
|
|
41
61
|
v(
|
|
42
62
|
"json",
|
|
43
63
|
z
|
|
44
64
|
.object({
|
|
45
|
-
templateId: z.string().min(1),
|
|
46
|
-
templateVersion: z.number().int().positive(),
|
|
47
65
|
expectedRevision: z.number().int().positive(),
|
|
48
|
-
|
|
66
|
+
expectedAppVersion: z.string().regex(/^[a-f0-9]{64}$/),
|
|
49
67
|
confirmed: z.literal(true),
|
|
50
68
|
})
|
|
51
69
|
.strict(),
|
|
@@ -54,22 +72,23 @@ export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthCo
|
|
|
54
72
|
const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
|
|
55
73
|
if (!skill) return notFound(c, "Skill");
|
|
56
74
|
try {
|
|
57
|
-
|
|
75
|
+
const input = c.req.valid("json");
|
|
76
|
+
return (await aiAppSkills.reset(skill.id, input.expectedRevision, input.expectedAppVersion))
|
|
58
77
|
? respond(c, ok({ updated: true }))
|
|
59
78
|
: notFound(c, "Skill");
|
|
60
79
|
} catch (error) {
|
|
61
80
|
if (error instanceof AiSkillRevisionConflictError) return conflict(c, error);
|
|
62
81
|
if (error instanceof AiSkillInputError) return respond(c, fail(err.badInput(error.message)));
|
|
63
|
-
if (
|
|
64
|
-
typeof error === "object" &&
|
|
65
|
-
error !== null &&
|
|
66
|
-
(("code" in error && error.code === "23505") || ("errno" in error && error.errno === "23505"))
|
|
67
|
-
)
|
|
68
|
-
return conflict(c, new Error("A Skill with this name already exists."));
|
|
69
82
|
throw error;
|
|
70
83
|
}
|
|
71
84
|
},
|
|
72
85
|
)
|
|
86
|
+
.post("/apps/:appId/skills/:name/restore", v("json", ConfirmedSchema), (c) =>
|
|
87
|
+
installAppSkill(c, () => aiAppSkills.restore(c.req.param("appId")!, c.req.param("name")!)),
|
|
88
|
+
)
|
|
89
|
+
.post("/apps/:appId/skills/:name/adopt", v("json", ConfirmedSchema), (c) =>
|
|
90
|
+
installAppSkill(c, () => aiAppSkills.adopt(c.req.param("appId")!, c.req.param("name")!)),
|
|
91
|
+
)
|
|
73
92
|
.get("/:skillId/projects", async (c) => {
|
|
74
93
|
const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
|
|
75
94
|
if (!skill) return notFound(c, "Skill");
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core's fan-out to many applications for one browser request, shared by Universal Search and dashboard widgets:
|
|
3
|
+
* bounded concurrency, one NDJSON line per application as soon as it finishes, and cancellation when the browser
|
|
4
|
+
* stops reading.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Media type a client sends in `Accept` and receives back for a stream of JSON lines. */
|
|
8
|
+
export const NDJSON_CONTENT_TYPE = "application/x-ndjson";
|
|
9
|
+
|
|
10
|
+
export const ndjsonStreamHeaders = {
|
|
11
|
+
"content-type": `${NDJSON_CONTENT_TYPE}; charset=utf-8`,
|
|
12
|
+
// Each line must reach the browser when it is written: no cache, no transformation, no proxy buffering.
|
|
13
|
+
"cache-control": "no-store, no-transform",
|
|
14
|
+
"x-accel-buffering": "no",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder();
|
|
18
|
+
export const ndjsonLine = (line: unknown) => `${JSON.stringify(line)}\n`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Starts `run` for every item with at most `concurrency` running at once, in item order. Each item's promise settles
|
|
22
|
+
* on its own, so a caller can act on fast items while slow ones still run. Aborting `signal` settles every item that
|
|
23
|
+
* has not finished as rejected and starts no further item.
|
|
24
|
+
*/
|
|
25
|
+
export const startBounded = <T, R>(
|
|
26
|
+
items: readonly T[],
|
|
27
|
+
concurrency: number,
|
|
28
|
+
run: (item: T, index: number) => Promise<R>,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
) => {
|
|
31
|
+
const deferred = items.map(() => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
let resolve!: (result: PromiseSettledResult<R>) => void;
|
|
34
|
+
const promise = new Promise<PromiseSettledResult<R>>((done) => {
|
|
35
|
+
resolve = (result) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
done(result);
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return { promise, resolve };
|
|
42
|
+
});
|
|
43
|
+
const abort = () => {
|
|
44
|
+
const reason = signal?.reason ?? new Error("Fan-out deadline exceeded");
|
|
45
|
+
for (const entry of deferred) entry.resolve({ status: "rejected", reason });
|
|
46
|
+
};
|
|
47
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
48
|
+
if (signal?.aborted) abort();
|
|
49
|
+
let next = 0;
|
|
50
|
+
void Promise.all(
|
|
51
|
+
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
52
|
+
while (true) {
|
|
53
|
+
if (signal?.aborted) return;
|
|
54
|
+
const index = next;
|
|
55
|
+
next += 1;
|
|
56
|
+
const item = items[index];
|
|
57
|
+
if (item === undefined) return;
|
|
58
|
+
try {
|
|
59
|
+
deferred[index]?.resolve({
|
|
60
|
+
status: "fulfilled",
|
|
61
|
+
value: await run(item, index),
|
|
62
|
+
});
|
|
63
|
+
} catch (reason) {
|
|
64
|
+
deferred[index]?.resolve({ status: "rejected", reason });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
).then(() => signal?.removeEventListener("abort", abort));
|
|
69
|
+
return deferred.map((entry) => entry.promise);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Waits for work that has already started, but no longer than `signal` allows. */
|
|
73
|
+
export const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
|
|
74
|
+
new Promise<T>((resolve, reject) => {
|
|
75
|
+
const aborted = () => reject(signal.reason);
|
|
76
|
+
// The work has already started; observe its rejection even if the client
|
|
77
|
+
// disconnected before we began waiting.
|
|
78
|
+
value.then(
|
|
79
|
+
(result) => {
|
|
80
|
+
signal.removeEventListener("abort", aborted);
|
|
81
|
+
resolve(result);
|
|
82
|
+
},
|
|
83
|
+
(error) => {
|
|
84
|
+
signal.removeEventListener("abort", aborted);
|
|
85
|
+
reject(error);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
if (signal.aborted) return aborted();
|
|
89
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Answers with `first`, then one line per entry of `pending` in the order they finish, then `last(lines)`. A slow
|
|
94
|
+
* entry never holds back the others. When the client stops reading, `onCancel` runs so the caller can stop its work.
|
|
95
|
+
*/
|
|
96
|
+
export const ndjsonStream = <Line>(options: {
|
|
97
|
+
first: Line;
|
|
98
|
+
pending: readonly Promise<Line>[];
|
|
99
|
+
last: (lines: Line[]) => Line;
|
|
100
|
+
onCancel: () => void;
|
|
101
|
+
}): Response => {
|
|
102
|
+
let cancelled = false;
|
|
103
|
+
const body = new ReadableStream<Uint8Array>({
|
|
104
|
+
start(controller) {
|
|
105
|
+
const write = (line: Line) => {
|
|
106
|
+
if (!cancelled) controller.enqueue(encoder.encode(ndjsonLine(line)));
|
|
107
|
+
};
|
|
108
|
+
write(options.first);
|
|
109
|
+
const lines: Line[] = [];
|
|
110
|
+
void Promise.all(
|
|
111
|
+
options.pending.map((pending) =>
|
|
112
|
+
pending.then((line) => {
|
|
113
|
+
lines.push(line);
|
|
114
|
+
write(line);
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
).then(() => {
|
|
118
|
+
write(options.last(lines));
|
|
119
|
+
if (!cancelled) controller.close();
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
cancel() {
|
|
123
|
+
cancelled = true;
|
|
124
|
+
options.onCancel();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return new Response(body, { headers: ndjsonStreamHeaders });
|
|
128
|
+
};
|
package/src/api/help.ts
CHANGED
|
@@ -13,21 +13,26 @@ export type HelpRouteDependencies = {
|
|
|
13
13
|
|
|
14
14
|
export const createHelpRoutes = (dependencies: HelpRouteDependencies = {}) => {
|
|
15
15
|
const reader = dependencies.help ?? createHelpReader;
|
|
16
|
+
// Help is for signed-in people; the reader then hides apps the viewer may not see, like missing ones.
|
|
17
|
+
// Scoped to Help's paths: Core mounts these routes at its API root, before MCP and the other APIs.
|
|
16
18
|
return new Hono<AuthContext>()
|
|
17
|
-
.use(dependencies.authenticate ?? auth.requireRole("
|
|
19
|
+
.use("/help/v1/*", dependencies.authenticate ?? auth.requireRole("authenticated"))
|
|
20
|
+
.use("/help/v1/*", auth.requireOAuthScope("read", "admin"))
|
|
18
21
|
.get("/help/v1/:appId/search", async (c) => {
|
|
19
22
|
const locale = getLocale(c);
|
|
20
23
|
const query = c.req.query("q")?.trim().slice(0, 200) ?? "";
|
|
21
24
|
const payload: HelpSearchPayload = {
|
|
22
25
|
locale,
|
|
23
|
-
ids: (await reader(locale).search({ query, appId: c.req.param("appId"), limit: 25 })).map(
|
|
26
|
+
ids: (await reader(locale, c.get("actor")).search({ query, appId: c.req.param("appId"), limit: 25 })).map(
|
|
27
|
+
(document) => document.documentId,
|
|
28
|
+
),
|
|
24
29
|
};
|
|
25
30
|
return c.json(payload);
|
|
26
31
|
})
|
|
27
32
|
.get("/help/v1/:appId/documents/:documentId", async (c) => {
|
|
28
33
|
const locale = getLocale(c);
|
|
29
34
|
const { t } = helpApiMessages.resolve([locale]);
|
|
30
|
-
const document = await reader(locale).read({ appId: c.req.param("appId"), documentId: c.req.param("documentId") });
|
|
35
|
+
const document = await reader(locale, c.get("actor")).read({ appId: c.req.param("appId"), documentId: c.req.param("documentId") });
|
|
31
36
|
if (!document) return c.json({ code: "HELP_NOT_FOUND", message: t.notFound }, 404);
|
|
32
37
|
const payload: HelpDocumentPayload = {
|
|
33
38
|
locale: document.locale,
|
package/src/api/mcp.ts
CHANGED
|
@@ -481,7 +481,10 @@ const createMcpServer = (
|
|
|
481
481
|
resolveLocale(request.headers, operatorDefault),
|
|
482
482
|
));
|
|
483
483
|
const helpReader = async () =>
|
|
484
|
-
(dependencies.help ?? ((locale) => createHelpReader(locale, { listApps: registry })))(
|
|
484
|
+
(dependencies.help ?? ((locale, viewer) => createHelpReader(locale, viewer, { listApps: registry })))(
|
|
485
|
+
await resolveHelpLocale(),
|
|
486
|
+
authority?.actor,
|
|
487
|
+
);
|
|
485
488
|
const hasScope = (scope: "read" | "write"): boolean =>
|
|
486
489
|
oauthScopes === null || oauthScopes.includes(scope) || oauthScopes.includes("admin");
|
|
487
490
|
const requireScope = (scope: "read" | "write"): void => {
|
package/src/api/search.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { searchInvocationOperation } from "../services/identity/invocation-opera
|
|
|
18
18
|
import { normalizeInvocationRequestId, signInvocationToken } from "../services/identity/invocation-token";
|
|
19
19
|
import { withActiveIdentitySigner } from "../services/identity/key-ring";
|
|
20
20
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
21
|
+
import { ndjsonLine, ndjsonStream, ndjsonStreamHeaders, startBounded, waitWithin } from "./fanout";
|
|
21
22
|
import {
|
|
22
23
|
SEARCH_STREAM_CONTENT_TYPE,
|
|
23
24
|
type SearchItem,
|
|
@@ -85,53 +86,6 @@ type SearchRouteDependencies = {
|
|
|
85
86
|
withActiveSigner?: typeof withActiveIdentitySigner;
|
|
86
87
|
};
|
|
87
88
|
|
|
88
|
-
const startBounded = <T, R>(
|
|
89
|
-
items: readonly T[],
|
|
90
|
-
concurrency: number,
|
|
91
|
-
run: (item: T, index: number) => Promise<R>,
|
|
92
|
-
signal?: AbortSignal,
|
|
93
|
-
) => {
|
|
94
|
-
const deferred = items.map(() => {
|
|
95
|
-
let settled = false;
|
|
96
|
-
let resolve!: (result: PromiseSettledResult<R>) => void;
|
|
97
|
-
const promise = new Promise<PromiseSettledResult<R>>((done) => {
|
|
98
|
-
resolve = (result) => {
|
|
99
|
-
if (settled) return;
|
|
100
|
-
settled = true;
|
|
101
|
-
done(result);
|
|
102
|
-
};
|
|
103
|
-
});
|
|
104
|
-
return { promise, resolve };
|
|
105
|
-
});
|
|
106
|
-
const abort = () => {
|
|
107
|
-
const reason = signal?.reason ?? new Error("Search fan-out deadline exceeded");
|
|
108
|
-
for (const entry of deferred) entry.resolve({ status: "rejected", reason });
|
|
109
|
-
};
|
|
110
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
111
|
-
if (signal?.aborted) abort();
|
|
112
|
-
let next = 0;
|
|
113
|
-
void Promise.all(
|
|
114
|
-
Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
115
|
-
while (true) {
|
|
116
|
-
if (signal?.aborted) return;
|
|
117
|
-
const index = next;
|
|
118
|
-
next += 1;
|
|
119
|
-
const item = items[index];
|
|
120
|
-
if (item === undefined) return;
|
|
121
|
-
try {
|
|
122
|
-
deferred[index]?.resolve({
|
|
123
|
-
status: "fulfilled",
|
|
124
|
-
value: await run(item, index),
|
|
125
|
-
});
|
|
126
|
-
} catch (reason) {
|
|
127
|
-
deferred[index]?.resolve({ status: "rejected", reason });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}),
|
|
131
|
-
).then(() => signal?.removeEventListener("abort", abort));
|
|
132
|
-
return deferred.map((entry) => entry.promise);
|
|
133
|
-
};
|
|
134
|
-
|
|
135
89
|
/** Highest app-provided priority first, then by title. */
|
|
136
90
|
const rankItems = (items: SearchItem[]) => items.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.title.localeCompare(b.title));
|
|
137
91
|
|
|
@@ -139,38 +93,8 @@ const rankItems = (items: SearchItem[]) => items.sort((a, b) => (b.priority ?? 0
|
|
|
139
93
|
const acceptsStream = (accept: string | undefined) =>
|
|
140
94
|
accept?.split(",").some((type) => type.split(";")[0]?.trim().toLowerCase() === SEARCH_STREAM_CONTENT_TYPE) ?? false;
|
|
141
95
|
|
|
142
|
-
const streamHeaders = {
|
|
143
|
-
"content-type": `${SEARCH_STREAM_CONTENT_TYPE}; charset=utf-8`,
|
|
144
|
-
// Each line must reach the browser when it is written: no cache, no transformation, no proxy buffering.
|
|
145
|
-
"cache-control": "no-store, no-transform",
|
|
146
|
-
"x-accel-buffering": "no",
|
|
147
|
-
} as const;
|
|
148
|
-
|
|
149
|
-
const encoder = new TextEncoder();
|
|
150
|
-
const lineText = (line: SearchStreamLine) => `${JSON.stringify(line)}\n`;
|
|
151
|
-
const encodeLine = (line: SearchStreamLine) => encoder.encode(lineText(line));
|
|
152
|
-
|
|
153
96
|
type AppOutcome = { appId: string; status: SearchProviderStatus; items: SearchItem[]; ms: number };
|
|
154
97
|
|
|
155
|
-
const waitWithin = <T>(value: Promise<T>, signal: AbortSignal): Promise<T> =>
|
|
156
|
-
new Promise<T>((resolve, reject) => {
|
|
157
|
-
const aborted = () => reject(signal.reason);
|
|
158
|
-
// The work has already started; observe its rejection even if the client
|
|
159
|
-
// disconnected before we began waiting.
|
|
160
|
-
value.then(
|
|
161
|
-
(result) => {
|
|
162
|
-
signal.removeEventListener("abort", aborted);
|
|
163
|
-
resolve(result);
|
|
164
|
-
},
|
|
165
|
-
(error) => {
|
|
166
|
-
signal.removeEventListener("abort", aborted);
|
|
167
|
-
reject(error);
|
|
168
|
-
},
|
|
169
|
-
);
|
|
170
|
-
if (signal.aborted) return aborted();
|
|
171
|
-
signal.addEventListener("abort", aborted, { once: true });
|
|
172
|
-
});
|
|
173
|
-
|
|
174
98
|
/**
|
|
175
99
|
* Creates the global search route.
|
|
176
100
|
* Discovers search providers from the capability registry and fetches results via HTTP,
|
|
@@ -271,10 +195,16 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
271
195
|
stream
|
|
272
196
|
? new Response(
|
|
273
197
|
[
|
|
274
|
-
|
|
275
|
-
|
|
198
|
+
ndjsonLine({
|
|
199
|
+
type: "start",
|
|
200
|
+
query: body.query,
|
|
201
|
+
apps,
|
|
202
|
+
providers: [],
|
|
203
|
+
...(body.unsupportedTags ? { unsupportedTags } : {}),
|
|
204
|
+
} satisfies SearchStreamLine),
|
|
205
|
+
ndjsonLine({ type: "done", status: "complete", count: 0 } satisfies SearchStreamLine),
|
|
276
206
|
].join(""),
|
|
277
|
-
{ headers:
|
|
207
|
+
{ headers: ndjsonStreamHeaders },
|
|
278
208
|
)
|
|
279
209
|
: c.json(body);
|
|
280
210
|
|
|
@@ -462,35 +392,30 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
462
392
|
const failure = (outcome: AppOutcome) => outcome.status === "timeout" || outcome.status === "error";
|
|
463
393
|
|
|
464
394
|
if (stream) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
start(
|
|
468
|
-
|
|
469
|
-
|
|
395
|
+
// A slow app never holds back the others: each line is written when its app finishes.
|
|
396
|
+
return ndjsonStream<SearchStreamLine>({
|
|
397
|
+
first: { type: "start", query: query.q, apps, providers: appIds, ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}) },
|
|
398
|
+
pending: outcomes.map((pending) =>
|
|
399
|
+
pending.then(
|
|
400
|
+
(outcome): SearchStreamLine => ({
|
|
401
|
+
type: "provider",
|
|
402
|
+
provider: outcome.appId,
|
|
403
|
+
status: outcome.status,
|
|
404
|
+
results: outcome.items,
|
|
405
|
+
ms: outcome.ms,
|
|
406
|
+
}),
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
last: (lines) => {
|
|
410
|
+
const providerLines = lines.filter((line) => line.type === "provider");
|
|
411
|
+
return {
|
|
412
|
+
type: "done",
|
|
413
|
+
status: providerLines.some((line) => line.status === "timeout" || line.status === "error") ? "partial" : "complete",
|
|
414
|
+
count: providerLines.reduce((count, line) => count + line.results.length, 0),
|
|
470
415
|
};
|
|
471
|
-
write({ type: "start", query: query.q, apps, providers: appIds, ...(unsupportedTags.length > 0 ? { unsupportedTags } : {}) });
|
|
472
|
-
let count = 0;
|
|
473
|
-
let partial = false;
|
|
474
|
-
// A slow app never holds back the others: each line is written when its app finishes.
|
|
475
|
-
void Promise.all(
|
|
476
|
-
outcomes.map((pending) =>
|
|
477
|
-
pending.then((outcome) => {
|
|
478
|
-
count += outcome.items.length;
|
|
479
|
-
partial ||= failure(outcome);
|
|
480
|
-
write({ type: "provider", provider: outcome.appId, status: outcome.status, results: outcome.items, ms: outcome.ms });
|
|
481
|
-
}),
|
|
482
|
-
),
|
|
483
|
-
).then(() => {
|
|
484
|
-
write({ type: "done", status: partial ? "partial" : "complete", count });
|
|
485
|
-
if (!cancelled) controller.close();
|
|
486
|
-
});
|
|
487
|
-
},
|
|
488
|
-
cancel() {
|
|
489
|
-
cancelled = true;
|
|
490
|
-
streamCancelled.abort();
|
|
491
416
|
},
|
|
417
|
+
onCancel: () => streamCancelled.abort(),
|
|
492
418
|
});
|
|
493
|
-
return new Response(body, { headers: streamHeaders });
|
|
494
419
|
}
|
|
495
420
|
|
|
496
421
|
const finished = await Promise.all(outcomes);
|