@k2b/cloud 0.40.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,168 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import { APPROVALS_PAGE_SIZE } from "../contracts/approvals";
4
+ import { CapabilityActionReviewPresentationSchema, CapabilitySemanticLinkSchema } from "../contracts/capabilities";
5
+ import { openRequests } from "../services/approvals/content";
6
+ import { logger } from "../services/logging";
7
+ import { reviewShortText, toolActionCard, toolBlockReview } from "./chat/action-card";
8
+ import { aiToolIcon } from "./chat/message-utils";
9
+ import { legacyOutcomeWords } from "./chat/tool-action-words";
10
+ import { type AiTurnBlock, toolBlockId } from "./protocol";
11
+
12
+ export type AiOpenQuestion = {
13
+ /** The approval request id (approvals.requests.id). */
14
+ id: string;
15
+ /** Short id of the chat the question waits in. */
16
+ conversationId: string;
17
+ /** The chat's current title, at most 200 code points; chat content, never Cloud's words. */
18
+ conversationTitle: string | null;
19
+ /** The question's data-chat-anchor: the id of the tool block the chat renders it in. */
20
+ anchor: string;
21
+ /** The question's short form, plain text, at most 160 code points. */
22
+ short: string;
23
+ /** The app the question is about, as the card shows it. */
24
+ app: { name: string; icon: string; accent?: string };
25
+ createdAt: string;
26
+ };
27
+
28
+ export type AiOpenQuestions = {
29
+ items: AiOpenQuestion[];
30
+ /** All open questions of the person, across pages. */
31
+ count: number;
32
+ };
33
+
34
+ type QuestionRow = {
35
+ id: string;
36
+ conversationId: string;
37
+ conversationTitle: string | null;
38
+ displayCallId: string;
39
+ appId: string;
40
+ sentence: string;
41
+ toolName: string;
42
+ createdAt: Date;
43
+ block: Extract<AiTurnBlock, { kind: "tool" }> | null;
44
+ };
45
+
46
+ const log = logger("ai:open-questions");
47
+ const legacyReviewSchema = z.object({
48
+ question: z.string().min(1),
49
+ rows: z.array(z.object({ label: z.string(), value: z.string(), format: z.enum(["date", "date-time"]).optional() })),
50
+ markdown: z.string().optional(),
51
+ summary: z.string().optional(),
52
+ links: z.array(CapabilitySemanticLinkSchema).optional(),
53
+ });
54
+
55
+ const ownedRequests = (ownerUserId: string) => sql`
56
+ SELECT r.* FROM (${openRequests(ownerUserId)}) r
57
+ WHERE EXISTS (
58
+ SELECT 1 FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id
59
+ WHERE p.approval_id=r.id AND p.status='pending' AND p.kind IN ('approval','custom_approval')
60
+ AND c.created_by_user_id=${ownerUserId}::uuid
61
+ )`;
62
+
63
+ /** One bounded, owner-scoped snapshot, using exactly the chat's stored question card. */
64
+ export const listAiOpenQuestions = async (input: { ownerUserId: string; locale: string; timeZone: string }): Promise<AiOpenQuestions> => {
65
+ const [counts] = await sql<{ count: number }[]>`
66
+ SELECT count(*)::int AS count FROM (${ownedRequests(input.ownerUserId)}) r`;
67
+ const rows = await sql<QuestionRow[]>`
68
+ WITH page AS (
69
+ SELECT * FROM (${ownedRequests(input.ownerUserId)}) r
70
+ ORDER BY r.created_at DESC, r.id DESC LIMIT ${APPROVALS_PAGE_SIZE}
71
+ )
72
+ SELECT r.id, r.app_id AS "appId", r.sentence, r.created_at AS "createdAt",
73
+ question.short_id AS "conversationId", question.title AS "conversationTitle",
74
+ question.display_call_id AS "displayCallId", question.tool_name AS "toolName", question.block
75
+ FROM page r JOIN LATERAL (
76
+ SELECT c.short_id, c.title, p.tool_name,
77
+ CASE WHEN p.kind='custom_approval'
78
+ THEN COALESCE(substring(p.call_id FROM '^(.*)-approval-[0-9]+$'), p.call_id)
79
+ ELSE p.call_id END AS display_call_id,
80
+ (SELECT block FROM jsonb_array_elements(
81
+ CASE WHEN jsonb_typeof(t.live_blocks)='array' THEN t.live_blocks ELSE '[]'::jsonb END
82
+ ) AS blocks(block)
83
+ WHERE block->>'kind'='tool' AND block->>'callId'=p.call_id LIMIT 1) AS block
84
+ FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id
85
+ JOIN ai.turns t ON t.id=p.turn_id
86
+ WHERE p.approval_id=r.id AND p.status='pending' AND p.kind IN ('approval','custom_approval')
87
+ AND c.created_by_user_id=${input.ownerUserId}::uuid
88
+ ORDER BY p.created_at DESC, p.id DESC LIMIT 1
89
+ ) question ON true
90
+ ORDER BY r.created_at DESC, r.id DESC`;
91
+
92
+ return {
93
+ count: counts?.count ?? 0,
94
+ items: rows.map((row) => openQuestionFromRow(row, input)),
95
+ };
96
+ };
97
+
98
+ /** Stop at the payload bound without splitting a Unicode code point or copying the rest of a legacy message. */
99
+ const boundedText = (text: string, max: number): string => {
100
+ const points: string[] = [];
101
+ for (const point of text) {
102
+ if (points.length === max) return `${points.slice(0, max - 1).join("")}…`;
103
+ points.push(point);
104
+ }
105
+ return text;
106
+ };
107
+
108
+ /** Present one stored question without database access. */
109
+ export const openQuestionFromRow = (row: QuestionRow, input: { locale: string; timeZone: string }): AiOpenQuestion => {
110
+ const block = row.block;
111
+ const presentation = block?.presentation;
112
+ const question: AiOpenQuestion = {
113
+ id: row.id,
114
+ conversationId: row.conversationId,
115
+ conversationTitle: row.conversationTitle === null ? null : boundedText(row.conversationTitle, 200),
116
+ // The id the chat renders the card under: a custom approval's card carries its parent call, also when a stored
117
+ // block still has the approval's own id (see normalizeCustomApprovalBlocks in client/projection.ts).
118
+ anchor: toolBlockId(row.displayCallId),
119
+ short: boundedText(row.sentence.split(/\r?\n/, 1)[0] ?? "", 160),
120
+ app: {
121
+ name: typeof presentation?.appName === "string" ? presentation.appName : block ? "Cloud" : row.appId,
122
+ icon: aiToolIcon(
123
+ typeof block?.name === "string" ? block.name : row.toolName,
124
+ typeof presentation?.appIcon === "string" ? presentation.appIcon : undefined,
125
+ ),
126
+ ...(typeof presentation?.appAccent === "string" ? { accent: presentation.appAccent } : {}),
127
+ },
128
+ createdAt: row.createdAt.toISOString(),
129
+ };
130
+ if (!block) return question;
131
+ try {
132
+ if (
133
+ typeof block.id !== "string" ||
134
+ !block.id ||
135
+ typeof block.name !== "string" ||
136
+ !block.name ||
137
+ typeof block.callId !== "string" ||
138
+ !["running", "awaiting_approval", "awaiting_client", "completed", "failed", "rejected"].includes(block.status)
139
+ )
140
+ throw new Error("Malformed tool block");
141
+ if (block.review == null && block.approval?.review != null) CapabilityActionReviewPresentationSchema.parse(block.approval.review);
142
+ const review = toolBlockReview(block);
143
+ if (review !== null) {
144
+ if ("legacy" in review) legacyReviewSchema.parse(review.legacy);
145
+ else CapabilityActionReviewPresentationSchema.parse(review);
146
+ }
147
+ const card = toolActionCard(block, {
148
+ locale: input.locale,
149
+ timeZone: input.timeZone,
150
+ outcome: legacyOutcomeWords(block, input.locale, input.timeZone),
151
+ });
152
+ const short =
153
+ review && !("legacy" in review)
154
+ ? reviewShortText(review, { locale: input.locale, timeZone: input.timeZone, app: card.app })
155
+ : card.questionText;
156
+ if (
157
+ typeof short !== "string" ||
158
+ typeof card.app.name !== "string" ||
159
+ typeof card.app.icon !== "string" ||
160
+ (card.app.accent !== undefined && typeof card.app.accent !== "string")
161
+ )
162
+ throw new Error("Malformed question card");
163
+ return { ...question, short: boundedText(short, 160), app: card.app };
164
+ } catch {
165
+ log.warn("Failed to present open question", { approvalId: row.id, conversationId: row.conversationId });
166
+ return question;
167
+ }
168
+ };
@@ -295,8 +295,7 @@ const listProjectAccess = async (projectId: string, db: SQL = sql): Promise<AiPr
295
295
  const rows = await db<ProjectAccessRow[]>`
296
296
  SELECT project_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
297
297
  access.permission, access.created_at,
298
- COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
299
- CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
298
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name) AS display_name,
300
299
  users.avatar_hash, service_accounts.kind AS service_account_kind
301
300
  FROM ai.project_access project_access
302
301
  JOIN auth.access access ON access.id = project_access.access_id
@@ -1,5 +1,5 @@
1
1
  import type { CompactResult, InboundEvent, Message } from "@k2b/nessi";
2
- import type { CapabilityActionReviewData } from "../contracts/capabilities";
2
+ import type { CapabilityActionReviewPresentation } from "../contracts/capabilities";
3
3
  import type { AiActionCheck, AiStoredActionReview } from "./action-review";
4
4
  import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresentation, AiTurnStatus } from "./types";
5
5
 
@@ -49,7 +49,7 @@ export type AiTurnBlock =
49
49
  /** Present while status is awaiting_approval. */
50
50
  approval?: {
51
51
  message?: string;
52
- review?: CapabilityActionReviewData;
52
+ review?: CapabilityActionReviewPresentation;
53
53
  allowAlways: boolean;
54
54
  /** Whether the approval can be remembered for this chat. */
55
55
  allowChat?: boolean;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The plain-text form of an Assistant question, the same one the chat card shows: for `cld`, notifications, and Cloud
3
+ * Login. Pure and browser-safe, in its own entry so neither the CLI bundle nor browser bundles that use
4
+ * `@k2b/cloud/ai/browser` pull it in by accident.
5
+ */
6
+ export { type ApprovalCardInput, approvalCard } from "../approvals/approval-card";
7
+ export type { ActionCard } from "./chat/action-card";
8
+ export { type ActionQuestionText, type ActionTextRow, actionCardText, actionQuestionLines } from "./chat/action-text";
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
3
- import { listCapabilities } from "../_internal/registry";
4
- import { type CapabilityDispatchParams, dispatchCapability, QUERY_TIMEOUT_MS } from "../api/capabilities";
3
+ import { CAPABILITY_DISCOVERY_TIMEOUT_MS } from "../_internal/capability-timeout";
4
+ import { type listApps, listCapabilities } from "../_internal/registry";
5
+ import { type CapabilityDispatchParams, dispatchCapability } from "../api/capabilities";
5
6
  import {
6
7
  CloudResourceRefSchema,
7
8
  capabilityResultSchema,
@@ -11,6 +12,7 @@ import {
11
12
  } from "../contracts/capabilities";
12
13
  import type { CapabilityRegistryEntry } from "../contracts/registry";
13
14
  import type { RequestAuthority } from "../server";
15
+ import { discoverableAppEntries } from "../services/app-discovery";
14
16
  import { resolveAppIdentityPresentation } from "../shared/app-presentation";
15
17
  import { LOCALE_HEADER } from "../shared/locale";
16
18
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
@@ -65,6 +67,7 @@ const studioReader = async (input: {
65
67
  export type AiRecentResourceDependencies = {
66
68
  store?: Pick<AiConversationService, "listRecentSentResourceRefs">;
67
69
  listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
70
+ listApps?: typeof listApps;
68
71
  dispatch?: (params: CapabilityDispatchParams) => Promise<Response>;
69
72
  skills?: Pick<typeof aiSkills, "list">;
70
73
  studioReader?: typeof studioReader;
@@ -108,7 +111,7 @@ export const listAiRecentResources = async (input: {
108
111
  dependencies?: AiRecentResourceDependencies;
109
112
  }): Promise<AiRecentResource[]> => {
110
113
  const deps = input.dependencies ?? {};
111
- const signal = AbortSignal.any([input.signal, AbortSignal.timeout(deps.timeoutMs ?? QUERY_TIMEOUT_MS)]);
114
+ const signal = AbortSignal.any([input.signal, AbortSignal.timeout(deps.timeoutMs ?? CAPABILITY_DISCOVERY_TIMEOUT_MS)]);
112
115
  // Unreadable history is no answer, not an empty one: the request fails, so the browser asks again at its next opening.
113
116
  const sent = await withinBudget(signal, () =>
114
117
  (deps.store ?? aiConversations).listRecentSentResourceRefs({ ownerUserId: input.ownerUserId }),
@@ -118,10 +121,14 @@ export const listAiRecentResources = async (input: {
118
121
  const skillIds = candidates.flatMap(({ ref }) => (ref.type === "core.ai.skill" ? [ref.id] : []));
119
122
  // Exactly the sent Skills, so a large Skill catalog never pushes one out; failing, they are left out like a reader.
120
123
  const skills = skillIds.length
121
- ? withinBudget(signal, () => (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds })).catch(() => [])
124
+ ? withinBudget(signal, () =>
125
+ (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds, authority: input.authority }),
126
+ ).catch(() => [])
122
127
  : Promise.resolve([]);
123
128
  const registry = candidates.some(({ ref }) => ref.type !== "core.ai.skill" && ref.type !== "assistant.artifact")
124
- ? withinBudget(signal, deps.listRegistry ?? listCapabilities).catch(() => [])
129
+ ? withinBudget(signal, async () =>
130
+ discoverableAppEntries(input.authority, await (deps.listRegistry ?? listCapabilities)(), deps.listApps),
131
+ ).catch(() => [])
125
132
  : Promise.resolve([]);
126
133
  const resources = await Promise.all(
127
134
  candidates.map(async (candidate): Promise<AiRecentResource | null> => {
@@ -0,0 +1,77 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { capabilityRequestHash } from "../capabilities/claims";
3
+ import { parseStoredReviewContent } from "../capabilities/review-storage";
4
+ import { CAPABILITY_MAX_REVIEW_CONTENT_BYTES, type CapabilityActionReviewPresentation } from "../contracts/capabilities";
5
+ import type { AiStoredActionReview } from "./action-review";
6
+
7
+ export type ReviewContentLocation = { conversationId: string; turnId: string; callId: string };
8
+
9
+ export const reviewContentHref = ({ conversationId, turnId, callId }: ReviewContentLocation): string =>
10
+ `/api/approvals/conversations/${encodeURIComponent(conversationId)}/turns/${encodeURIComponent(turnId)}/reviews/${encodeURIComponent(callId)}`;
11
+
12
+ export const compactCapabilityReview = (
13
+ review: CapabilityActionReviewPresentation,
14
+ location: ReviewContentLocation,
15
+ ): CapabilityActionReviewPresentation => {
16
+ if (!("sentence" in review)) return review;
17
+ const { content, ...compact } = review;
18
+ const digest = content ? capabilityRequestHash(content) : compact.contentRef?.digest;
19
+ return digest ? { ...compact, contentRef: { href: reviewContentHref(location), digest } } : compact;
20
+ };
21
+
22
+ /** The existing message snapshot owns both the content and its integrity reference. */
23
+ export const storedReviewWithContent = (review: AiStoredActionReview, location: ReviewContentLocation): AiStoredActionReview =>
24
+ "content" in review && review.content
25
+ ? { ...review, contentRef: { href: reviewContentHref(location), digest: capabilityRequestHash(review.content) } }
26
+ : review;
27
+
28
+ /** Rebind the locator on a fork; never make the new conversation depend on access to its source. */
29
+ export const compactStoredReview = (review: AiStoredActionReview, location?: ReviewContentLocation): AiStoredActionReview => {
30
+ if ("legacy" in review) return review;
31
+ const { content: _content, ...compact } = review;
32
+ return compact.contentRef && location
33
+ ? { ...compact, contentRef: { ...compact.contentRef, href: reviewContentHref(location) } }
34
+ : compact;
35
+ };
36
+
37
+ /** Project before transferring JSONB out of Postgres, not after loading every full review into Core. */
38
+ export const compactReviewMapSql = (reviews: SQL.Query<unknown>) => sql`(
39
+ SELECT COALESCE(jsonb_object_agg(entry.key, entry.value - 'content'), '{}'::jsonb)
40
+ FROM jsonb_each(CASE WHEN jsonb_typeof(${reviews}) = 'object' THEN ${reviews} ELSE '{}'::jsonb END) entry
41
+ )`;
42
+
43
+ export const compactReviewMetaSql = (meta: SQL.Query<unknown>) => sql`CASE
44
+ WHEN ${meta} ? 'toolReviews' THEN jsonb_set(${meta}, '{toolReviews}', ${compactReviewMapSql(sql`${meta}->'toolReviews'`)})
45
+ ELSE ${meta} END`;
46
+
47
+ /** Caller has already authorized this conversation. Read one exact persisted call from either existing message store. */
48
+ export const readReviewContent = async (location: ReviewContentLocation, digest: string, ownerUserId: string, signal?: AbortSignal) => {
49
+ signal?.throwIfAborted();
50
+ const query = sql<{ content: unknown }[]>`
51
+ SELECT meta->'toolReviews'->${location.callId}->'content' AS content
52
+ FROM (
53
+ SELECT meta, conversation_id, loop_id, role, seq FROM ai.messages
54
+ UNION ALL
55
+ SELECT meta, conversation_id, loop_id, role, seq FROM ai.task_messages
56
+ ) messages
57
+ WHERE conversation_id=${location.conversationId}::uuid AND loop_id=${location.turnId} AND role='assistant'
58
+ AND EXISTS (SELECT 1 FROM ai.conversations c WHERE c.id=messages.conversation_id
59
+ AND c.created_by_user_id=${ownerUserId}::uuid AND c.archived_at IS NULL)
60
+ AND meta->'toolReviews'->${location.callId}->'contentRef'->>'digest'=${digest}
61
+ AND octet_length((meta->'toolReviews'->${location.callId}->'content')::text) <= ${2 * CAPABILITY_MAX_REVIEW_CONTENT_BYTES + 2}
62
+ ORDER BY seq DESC LIMIT 1`;
63
+ // JSONB adds spaces after punctuation; at most twice the compact JSON budget. The exact limit is checked below.
64
+ const abort = () => query.cancel();
65
+ signal?.addEventListener("abort", abort, { once: true });
66
+ if (signal?.aborted) abort();
67
+ let rows: { content: unknown }[];
68
+ try {
69
+ rows = await query;
70
+ } finally {
71
+ signal?.removeEventListener("abort", abort);
72
+ }
73
+ signal?.throwIfAborted();
74
+ const row = rows[0];
75
+ const parsed = parseStoredReviewContent(row?.content);
76
+ return parsed.success && capabilityRequestHash(parsed.data) === digest ? parsed.data : null;
77
+ };
package/src/ai/routes.ts CHANGED
@@ -2,10 +2,11 @@ import { type Context, Hono } from "hono";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { describeRoute } from "hono-openapi";
4
4
  import { z } from "zod";
5
- import { listCapabilities } from "../_internal/registry";
6
- import { QUERY_TIMEOUT_MS } from "../api/capabilities";
5
+ import { CAPABILITY_DISCOVERY_TIMEOUT_MS, capabilityTimeoutMs } from "../_internal/capability-timeout";
6
+ import { getApp, listCapabilities } from "../_internal/registry";
7
7
  import { ErrorResponseSchema } from "../contracts";
8
8
  import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
9
+ import { capabilityActionPolicy } from "../contracts/capabilities";
9
10
  import {
10
11
  type AccessSubject,
11
12
  type AuthContext,
@@ -26,12 +27,16 @@ import { isAppSessionActor } from "../server/actor";
26
27
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
27
28
  import { noTransform } from "../server/middleware/no-transform";
28
29
  import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
29
- import { approvalDecisionChannel } from "../services/approvals";
30
+ import { appAccessAuthority } from "../services/app-access";
31
+ import { discoverableAppEntries } from "../services/app-discovery";
32
+ import { approvalDecisionChannel, approvals } from "../services/approvals";
33
+ import { pendingApprovalReview } from "../services/approvals/question";
30
34
  import { ImageMetadataError } from "../services/image-metadata";
31
35
  import { logger } from "../services/logging";
32
36
  import { coreSettings } from "../services/settings/api";
37
+ import { resolveAppIdentityPresentation } from "../shared/app-presentation";
33
38
  import { readThemeFromCookieHeader } from "../shared/theme";
34
- import { isUnknownActionOutcome } from "./action-review";
39
+ import { type AiStoredActionReview, isLegacyActionReview, isUnknownActionOutcome, storedActionReview } from "./action-review";
35
40
  import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
36
41
  import { AI_AUDIO_MAX_BYTES } from "./audio-format";
37
42
  import { buildAiCapabilityCatalog } from "./capabilities";
@@ -96,7 +101,14 @@ import { AI_SHORT_ID_PATTERN } from "./short-id";
96
101
  import { selectAiSkillCatalog } from "./skill-catalog";
97
102
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool } from "./skill-tool";
98
103
  import { aiSkills } from "./skills";
99
- import { AI_TOOL_CHECK_STALE_MS, aiConversations, claimAiToolCheck, readAiToolCheck, settleAiToolCheck } from "./store";
104
+ import {
105
+ AI_TOOL_CHECK_STALE_MS,
106
+ AiConversationCreationError,
107
+ aiConversations,
108
+ claimAiToolCheck,
109
+ readAiToolCheck,
110
+ settleAiToolCheck,
111
+ } from "./store";
100
112
  import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
101
113
  import { composeAiSystemPrompt } from "./system-prompt";
102
114
  import { aiToolAudit } from "./tool-audit";
@@ -283,13 +295,17 @@ const prepareConversationForMessageRetry = async (conversationId: string): Promi
283
295
  return (await aiConversations.getActiveTurn({ conversationId })) ? "busy" : "ready";
284
296
  };
285
297
 
286
- const prepareLaunchDraft = async (body: z.infer<typeof AiCreateConversationInputSchema>, subject: AccessSubject | null) => {
298
+ const prepareLaunchDraft = async (
299
+ body: z.infer<typeof AiCreateConversationInputSchema>,
300
+ subject: AccessSubject | null,
301
+ authority: import("../server").RequestAuthority | null = null,
302
+ ) => {
287
303
  const content = [...(body.draft?.content ?? [])];
288
304
  if (body.skills?.length && body.allowedTools && !body.allowedTools.includes("load_skill")) {
289
305
  return fail(err.badInput("Linked Skills require load_skill in allowedTools."));
290
306
  }
291
307
  for (const name of new Set(body.skills ?? [])) {
292
- const skill = await aiSkills.getByName(name, subject);
308
+ const skill = await aiSkills.getByName(name, subject, "read", authority);
293
309
  if (!skill?.enabled) return fail(err.badInput(`Skill is unavailable: ${name}`));
294
310
  if (!content.some((part) => part.type === "resource" && part.ref.type === "core.ai.skill" && part.ref.id === skill.shortId)) {
295
311
  content.push({ type: "resource", ref: { type: "core.ai.skill", id: skill.shortId }, title: skill.name, icon: "ti ti-sparkles" });
@@ -430,7 +446,9 @@ export const aiRoutes = (() => {
430
446
  if (preferredId) previewProfile = selectAiModelProfile(state, ctx.modelPolicy, preferredId);
431
447
  }
432
448
  const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
433
- const availableSkills = toolsSupported ? (await aiSkills.list(c.get("accessSubject"))).filter((skill) => skill.enabled) : [];
449
+ const availableSkills = toolsSupported
450
+ ? (await aiSkills.list(c.get("accessSubject"), { authority: appAccessAuthority(c) })).filter((skill) => skill.enabled)
451
+ : [];
434
452
  const skillCatalog = selectAiSkillCatalog(
435
453
  availableSkills.filter((skill) => !skill.manualOnly).map(({ name, description }) => ({ name, description })),
436
454
  previewProfile?.contextWindow ?? 0,
@@ -444,8 +462,8 @@ export const aiRoutes = (() => {
444
462
  })),
445
463
  ...(client?.localTools === "ask" ? [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()] : []),
446
464
  ...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
447
- ...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
448
- ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
465
+ ...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"), appAccessAuthority(c))] : []),
466
+ ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"), [], appAccessAuthority(c))] : []),
449
467
  ]
450
468
  : [];
451
469
  const memoryToolEnabled = tools.some((tool) => tool.def.name === "memory");
@@ -494,7 +512,7 @@ export const aiRoutes = (() => {
494
512
  c.header("Cache-Control", "no-store");
495
513
  const ctx = await resolveContext(c);
496
514
  if (ctx instanceof Response) return ctx;
497
- setRequestTimeout(c, Math.ceil(QUERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
515
+ setRequestTimeout(c, Math.ceil(CAPABILITY_DISCOVERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
498
516
  try {
499
517
  const resources = await listAiRecentResources({
500
518
  ownerUserId: ctx.ownerUserId,
@@ -574,7 +592,7 @@ export const aiRoutes = (() => {
574
592
  const body = c.req.valid("json");
575
593
  const project = body.projectId ? await aiProjects.getByShortId(body.projectId, c.get("accessSubject"), "read") : null;
576
594
  if (body.projectId && !project) return respond(c, fail(err.notFound("Project")));
577
- const draft = await prepareLaunchDraft(body, c.get("accessSubject"));
595
+ const draft = await prepareLaunchDraft(body, c.get("accessSubject"), appAccessAuthority(c));
578
596
  if (!draft.ok) return respond(c, draft);
579
597
  const requestedCapabilityTools = (body.preloadTools ?? []).filter(
580
598
  (requested): requested is { appId: string; kind: "query" | "action"; id: string } => "appId" in requested,
@@ -582,7 +600,7 @@ export const aiRoutes = (() => {
582
600
  let catalog: ReturnType<typeof buildAiCapabilityCatalog> = [];
583
601
  if (requestedCapabilityTools.length) {
584
602
  try {
585
- catalog = buildAiCapabilityCatalog(await listCapabilities());
603
+ catalog = buildAiCapabilityCatalog(await discoverableAppEntries(appAccessAuthority(c), await listCapabilities()));
586
604
  } catch {
587
605
  return respond(c, fail(err.internal("The live capability catalog is unavailable.")));
588
606
  }
@@ -613,24 +631,29 @@ export const aiRoutes = (() => {
613
631
  if (body.allowedTools && preloadTools.some((name) => !body.allowedTools!.includes(name!))) {
614
632
  return respond(c, fail(err.badInput("Preloaded tools must be included in allowedTools.")));
615
633
  }
616
- return respond(
617
- c,
618
- ok(
619
- publicConversation(
620
- await aiConversations.createConversation({
621
- ownerUserId: ctx.ownerUserId,
622
- title: body.title,
623
- projectId: project?.id,
624
- draft: draft.data.content,
625
- preloadTools: preloadTools.map((name) => name!),
626
- allowedTools: body.allowedTools,
627
- launchedByAppId: body.launchedByAppId,
628
- }),
629
- project?.shortId ?? null,
630
- ),
631
- ),
632
- 201,
633
- );
634
+ try {
635
+ const conversation = await aiConversations.createConversation({
636
+ requestId: body.requestId,
637
+ ownerUserId: ctx.ownerUserId,
638
+ title: body.title,
639
+ projectId: project?.id,
640
+ draft: draft.data.content,
641
+ preloadTools: preloadTools.map((name) => name!),
642
+ allowedTools: body.allowedTools,
643
+ launchedByAppId: body.launchedByAppId,
644
+ });
645
+ // A replay may return a conversation assigned to a Project since creation.
646
+ const currentProject = conversation.projectId
647
+ ? await aiProjects.get(conversation.projectId, c.get("accessSubject"), "read")
648
+ : null;
649
+ if (conversation.projectId && !currentProject) return respond(c, fail(err.notFound("Project")));
650
+ return respond(c, ok(publicConversation(conversation, currentProject?.shortId ?? null)), 201);
651
+ } catch (error) {
652
+ if (error instanceof AiConversationCreationError) {
653
+ return respond(c, fail(error.reason === "conflict" ? err.conflict(error.message) : err.notFound("Conversation")));
654
+ }
655
+ throw error;
656
+ }
634
657
  })
635
658
  .get("/conversations/:conversationId", async (c) => {
636
659
  const ctx = await resolveContext(c);
@@ -639,6 +662,24 @@ export const aiRoutes = (() => {
639
662
  if (!conversation) return notFound(c);
640
663
  return respond(c, ok(await conversationDetail(conversation, c.get("accessSubject"))));
641
664
  })
665
+ .get(
666
+ "/conversations/:conversationId/submission/:draftRevision",
667
+ v("param", z.object({ conversationId: z.string().regex(AI_SHORT_ID_PATTERN), draftRevision: z.coerce.number().int().min(0) })),
668
+ async (c) => {
669
+ const ctx = await resolveContext(c);
670
+ if (ctx instanceof Response) return ctx;
671
+ const conversation = await loadConversation(c, ctx);
672
+ if (!conversation) return notFound(c);
673
+ if (conversation.projectId && !(await aiProjects.get(conversation.projectId, c.get("accessSubject"), "read"))) return notFound(c);
674
+ const submission = await aiConversations.getDraftSubmission({
675
+ conversationId: conversation.id,
676
+ ownerUserId: ctx.ownerUserId,
677
+ revision: c.req.valid("param").draftRevision,
678
+ });
679
+ // Null is deliberately not a claim that the request was rejected.
680
+ return respond(c, ok({ submission }));
681
+ },
682
+ )
642
683
  .get("/conversations/:conversationId/messages", v("query", MessagesPageQuerySchema), async (c) => {
643
684
  const ctx = await resolveContext(c);
644
685
  if (ctx instanceof Response) return ctx;
@@ -1220,7 +1261,7 @@ export const aiRoutes = (() => {
1220
1261
  if (!turn || !callId) return notFound(c);
1221
1262
  const target = { conversationId: conversation.id, turnId: turn.id, callId };
1222
1263
  // The app's Query may take its whole budget; the request must outlast it.
1223
- setRequestTimeout(c, Math.ceil(QUERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
1264
+ setRequestTimeout(c, Math.ceil(capabilityTimeoutMs("query") / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
1224
1265
  try {
1225
1266
  const messages = await aiConversations.listTurnMessages({
1226
1267
  conversationId: conversation.id,
@@ -1253,7 +1294,7 @@ export const aiRoutes = (() => {
1253
1294
  message.message.role === "assistant"
1254
1295
  ? message.message.content.find((block) => block.type === "tool_call" && block.id === callId)
1255
1296
  : undefined;
1256
- const catalog = buildAiCapabilityCatalog(await listCapabilities());
1297
+ const catalog = buildAiCapabilityCatalog(await discoverableAppEntries(appAccessAuthority(c), await listCapabilities()));
1257
1298
  const presentation = message.meta?.toolPresentations?.[callId];
1258
1299
  const entry = catalog.find(
1259
1300
  (entry) =>
@@ -1352,8 +1393,51 @@ export const aiRoutes = (() => {
1352
1393
  if (!conversation) return notFound(c);
1353
1394
  const turn = await loadTurn(conversation, c.req.param("turnId"));
1354
1395
  if (!turn) return notFound(c);
1355
- const actions = await listPendingAiTurnActions({ conversationId: conversation.id, turnId: turn.id });
1356
- return respond(c, ok(actions.map((action) => publicPendingAction(action, conversation.shortId, turn.shortId))));
1396
+ const scope = { conversationId: conversation.id, turnId: turn.id };
1397
+ const actions = await listPendingAiTurnActions(scope);
1398
+ if (!actions.some((action) => action.type === "approval_request"))
1399
+ return respond(c, ok(actions.map((action) => publicPendingAction(action, conversation.shortId, turn.shortId))));
1400
+ const [records, reviews, config] = await Promise.all([
1401
+ aiConversations.listPendingActionRecords(scope),
1402
+ aiConversations.listTurnToolReviews(scope),
1403
+ aiConversations.getTurnRunConfig(scope),
1404
+ ]);
1405
+ const locale = config?.kind !== "compact" ? (config?.locale ?? getLocale(c)) : getLocale(c);
1406
+ let capabilities: Promise<Awaited<ReturnType<typeof listCapabilities>> | null> | undefined;
1407
+ const presented = await Promise.all(
1408
+ actions.map(async (action) => {
1409
+ const result = publicPendingAction(action, conversation.shortId, turn.shortId);
1410
+ if (action.type !== "approval_request") return result;
1411
+ const record = records.find((record) => record.callId === action.callId);
1412
+ const request = record?.approvalId ? await approvals.get(record.approvalId, undefined, true) : null;
1413
+ const reviewCallId = record?.kind === "custom_approval" ? action.callId.replace(/-approval-[0-9]+$/, "") : action.callId;
1414
+ const stored = reviews.get(reviewCallId);
1415
+ let review: AiStoredActionReview | null = stored && !isLegacyActionReview(stored) ? stored : null;
1416
+ if (!review && request) {
1417
+ review = pendingApprovalReview({ storedReview: null, details: request.details });
1418
+ if (review && !isLegacyActionReview(review) && !review.ask) {
1419
+ // As the approvals page: the declared reason when the review names none, read once per listing.
1420
+ capabilities ??= listCapabilities().catch(() => null);
1421
+ const manifest = (await capabilities)?.find((entry) => entry.appId === request.appId)?.manifest;
1422
+ review = storedActionReview(review, manifest ? capabilityActionPolicy(manifest, request.action) : undefined);
1423
+ }
1424
+ }
1425
+ const appId = request?.appId;
1426
+ // The name is presentation: a registry that cannot answer leaves the app ID, never the waiting approvals unread.
1427
+ const app = appId && appId !== "assistant" ? await getApp(appId).catch(() => null) : null;
1428
+ return {
1429
+ ...result,
1430
+ ...(review && !isLegacyActionReview(review) ? { review } : {}),
1431
+ app: {
1432
+ id: appId ?? "assistant",
1433
+ name: app ? resolveAppIdentityPresentation(app, locale).name : appId && appId !== "assistant" ? appId : "Cloud",
1434
+ },
1435
+ chatTitle: conversation.title,
1436
+ ...(request ? { expiresAt: request.expiresAt.toISOString() } : {}),
1437
+ };
1438
+ }),
1439
+ );
1440
+ return respond(c, ok(presented));
1357
1441
  })
1358
1442
  .get("/conversations/:conversationId/stream", async (c) => {
1359
1443
  const ctx = await resolveContext(c);
package/src/ai/runtime.ts CHANGED
@@ -55,6 +55,7 @@ const log = logger("ai:runtime");
55
55
 
56
56
  const AI_WORKER_ID = `worker-${crypto.randomUUID()}`;
57
57
  const AI_TURN_HEARTBEAT_MS = 3_000;
58
+ // Local turn orchestration (tools and approval waits included), not a model-provider quota.
58
59
  const AI_TURN_WORKER_CONCURRENCY = 8;
59
60
  const AI_TURN_MAX_ATTEMPTS = 5;
60
61