@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/ai/store.ts
CHANGED
|
@@ -788,9 +788,10 @@ const messageSearchText = (message: Message): string => {
|
|
|
788
788
|
* Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
|
|
789
789
|
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
790
790
|
* never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
|
|
791
|
-
* limit never looks like a user stop. A call the user approved that never returned keeps its approval in history
|
|
792
|
-
*
|
|
793
|
-
* turn
|
|
791
|
+
* limit never looks like a user stop. A call the user approved that never returned keeps its approval in history, and
|
|
792
|
+
* one whose approval still waited records that the approval expired, so history shows it as not run. A failure's
|
|
793
|
+
* reason goes to the turn's last message, also when that is the user's own, so history can say why the turn failed and
|
|
794
|
+
* what to do next.
|
|
794
795
|
*/
|
|
795
796
|
const recordTurnEnd = async (
|
|
796
797
|
db: typeof sql,
|
|
@@ -829,8 +830,9 @@ const recordTurnEnd = async (
|
|
|
829
830
|
)
|
|
830
831
|
AND (loop_done_reason IS NULL OR loop_done_reason = ${replaces}::text)
|
|
831
832
|
`;
|
|
832
|
-
// An approved call without a result keeps the decision on the message that holds the call
|
|
833
|
-
// approval belongs to the call that asked for it
|
|
833
|
+
// An approved call without a result keeps the decision on the message that holds the call; an approval nobody
|
|
834
|
+
// answered is recorded as expired. A custom approval belongs to the call that asked for it, and one approved request
|
|
835
|
+
// of that call outweighs a later one that expired.
|
|
834
836
|
await db`
|
|
835
837
|
UPDATE ${db(table)} target
|
|
836
838
|
SET meta = jsonb_set(
|
|
@@ -839,30 +841,35 @@ const recordTurnEnd = async (
|
|
|
839
841
|
COALESCE(target.meta->'toolOutcomes', '{}'::jsonb) || outcomes.value
|
|
840
842
|
)
|
|
841
843
|
FROM (
|
|
842
|
-
SELECT message.id, jsonb_object_agg(
|
|
844
|
+
SELECT message.id, jsonb_object_agg(decided.call_id, decided.outcome) AS value
|
|
843
845
|
FROM (
|
|
844
|
-
SELECT
|
|
846
|
+
SELECT
|
|
845
847
|
CASE
|
|
846
848
|
WHEN action.kind = 'custom_approval' THEN COALESCE(substring(action.call_id FROM '^(.*)-approval-[0-9]+$'), action.call_id)
|
|
847
849
|
ELSE action.call_id
|
|
848
|
-
END AS call_id
|
|
850
|
+
END AS call_id,
|
|
851
|
+
CASE WHEN bool_or(action.resolved_event IS NOT NULL) THEN 'approved' ELSE 'expired' END AS outcome
|
|
849
852
|
FROM ai.pending_actions action
|
|
850
853
|
WHERE action.turn_id = ${input.turnId}
|
|
851
|
-
AND action.
|
|
852
|
-
AND
|
|
853
|
-
|
|
854
|
+
AND action.kind IN ('approval', 'custom_approval')
|
|
855
|
+
AND (
|
|
856
|
+
action.resolved_event IS NULL
|
|
857
|
+
OR (action.resolved_event->>'type' = 'approval_response' AND action.resolved_event->>'approved' = 'true')
|
|
858
|
+
)
|
|
859
|
+
GROUP BY 1
|
|
860
|
+
) decided
|
|
854
861
|
JOIN ${db(table)} message
|
|
855
862
|
ON message.conversation_id = ${input.conversationId}
|
|
856
863
|
AND message.loop_id = ${input.turnId}::text
|
|
857
864
|
AND message.role = 'assistant'
|
|
858
|
-
AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id',
|
|
865
|
+
AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', decided.call_id))
|
|
859
866
|
WHERE NOT EXISTS (
|
|
860
867
|
SELECT 1
|
|
861
868
|
FROM ${db(table)} result
|
|
862
869
|
WHERE result.conversation_id = ${input.conversationId}
|
|
863
870
|
AND result.loop_id = ${input.turnId}::text
|
|
864
871
|
AND result.role = 'tool_result'
|
|
865
|
-
AND result.message->>'callId' =
|
|
872
|
+
AND result.message->>'callId' = decided.call_id
|
|
866
873
|
)
|
|
867
874
|
GROUP BY message.id
|
|
868
875
|
) outcomes
|
|
@@ -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
|
|
|
@@ -300,7 +301,8 @@ export type AiStoredMessage = {
|
|
|
300
301
|
trigger: "scheduled" | "manual";
|
|
301
302
|
};
|
|
302
303
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
303
|
-
|
|
304
|
+
/** How the person decided an approval; `expired` when the turn ended while it still waited. */
|
|
305
|
+
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
304
306
|
/** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
|
|
305
307
|
turnError?: AiTurnError;
|
|
306
308
|
} | null;
|
|
@@ -1024,6 +1026,12 @@ export type AiCapabilityToolPresentation = {
|
|
|
1024
1026
|
appAccent?: string;
|
|
1025
1027
|
title: string;
|
|
1026
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;
|
|
1027
1035
|
};
|
|
1028
1036
|
|
|
1029
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");
|
|
@@ -3,8 +3,11 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
|
3
3
|
import { describeRoute, validator } from "hono-openapi";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import {
|
|
6
|
+
AdminMailAppSchema,
|
|
6
7
|
AdminMailRecordSchema,
|
|
7
8
|
MailAppAccessSchema,
|
|
9
|
+
MailLogAccessSchema,
|
|
10
|
+
MailLogAppIdSchema,
|
|
8
11
|
MailProfileInputSchema,
|
|
9
12
|
MailProfileKeySchema,
|
|
10
13
|
MailRetentionSchema,
|
|
@@ -235,6 +238,21 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
|
|
|
235
238
|
await outgoingMailTest.send(c.req.param("key")!, c.req.valid("json").recipient, context(c));
|
|
236
239
|
return c.json({ ok: true as const });
|
|
237
240
|
})
|
|
241
|
+
.put(
|
|
242
|
+
"/apps/:appId/log-access",
|
|
243
|
+
describeRoute({
|
|
244
|
+
summary: "Set application mail log access",
|
|
245
|
+
tags: ["Outgoing mail"],
|
|
246
|
+
...requiresAdmin,
|
|
247
|
+
responses: {
|
|
248
|
+
200: jsonResponse(AdminMailAppSchema, "Updated application access"),
|
|
249
|
+
400: jsonResponse(z.object({ code: z.literal("bad_input"), message: z.string() }), "Invalid log access"),
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
validate("param", z.object({ appId: MailLogAppIdSchema }), "bad_input"),
|
|
253
|
+
validate("json", MailLogAccessSchema, "bad_input"),
|
|
254
|
+
async (c) => c.json(await outgoingMailStore.setAppLogAccess(c.req.valid("param").appId, c.req.valid("json"), context(c))),
|
|
255
|
+
)
|
|
238
256
|
.get("/apps", async (c) => c.json(await outgoingMailStore.apps()))
|
|
239
257
|
.put("/apps/:appId", validate("json", MailAppAccessSchema), async (c) =>
|
|
240
258
|
c.json(await outgoingMailStore.setAppAccess(c.req.param("appId")!, c.req.valid("json"), context(c))),
|
|
@@ -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);
|