@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
@@ -1,11 +1,9 @@
1
1
  import { dates } from "@k2b/stdlib";
2
2
  import { Button, ButtonLink, type ChatDecisionState, useLocale } from "@k2b/ui";
3
3
  import { createEffect, createMemo, createSignal, For, type JSX, on, onCleanup, Show } from "solid-js";
4
- import { capabilityUnknownText } from "../../_internal/capability-sentences";
5
4
  import { CapabilityActionReceiptSchema } from "../../contracts/capabilities";
6
5
  import { AI_TOOL_CHECK_STALE_MS, type AiActionCheck } from "../action-review";
7
6
  import type { AiTurnBlock } from "../protocol";
8
- import { toolOutcome } from "../tool-wording";
9
7
  import { type ActionCard, type ActionText, actionReceiptText, toolActionCard } from "./action-card";
10
8
  import { actionMessages } from "./action-messages";
11
9
  import { type ActionLine, ActionQuestion, ActionReceipt } from "./action-question";
@@ -13,6 +11,7 @@ import { useReaderTimeZone } from "./action-sentences";
13
11
  import { useAiChatActions } from "./message-actions";
14
12
  import { capabilityErrorDescription, isRecord } from "./message-utils";
15
13
  import { aiChatMessages } from "./messages";
14
+ import { legacyOutcomeWords } from "./tool-action-words";
16
15
  import type { AiTurnActionState, AiTurnPhase } from "./turn-layout";
17
16
 
18
17
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -44,30 +43,6 @@ export const toolErrorCode = (result: unknown): string | null => {
44
43
  /** The message without its code, so the detail under a line reads as a sentence. */
45
44
  const errorDetail = (result: unknown): string => capabilityErrorDescription(result).replace(/^[A-Z][A-Z0-9_]{2,}:\s*/, "");
46
45
 
47
- /** The application's own short summary of a result, if it fits on one line. */
48
- const resultSummary = (block: ToolBlock): string | undefined => {
49
- const value = isRecord(block.result) ? block.result.summary : undefined;
50
- return typeof value === "string" && value.trim() && value.trim().length <= 500 ? value.trim() : undefined;
51
- };
52
-
53
- /** The words of a call without an app sentence: Cloud's frames around its catalog sentence or title. */
54
- export const legacyOutcomeWords = (block: ToolBlock, locale: string, timeZone: string) => (subject: string) => {
55
- const t = aiChatMessages(locale);
56
- const capability = block.presentation?.kind === "capability";
57
- return {
58
- running: t.receiptRunning({ title: subject }),
59
- // The app's done sentence, else its own summary of the call, else Cloud's frame.
60
- done:
61
- toolOutcome(block, "done", locale, timeZone) ??
62
- resultSummary(block) ??
63
- (capability ? t.receiptDone({ title: subject }) : t.receiptApproved({ title: subject })),
64
- rejected: toolOutcome(block, "rejected", locale, timeZone) ?? t.receiptRejected({ title: subject }),
65
- notRun: toolOutcome(block, "notRun", locale, timeZone) ?? t.receiptNotRun({ title: subject, stopped: false }),
66
- unknown: capabilityUnknownText(subject, locale),
67
- unknownStill: capabilityUnknownText(subject, locale, true),
68
- };
69
- };
70
-
71
46
  /** The card of one tool block in the reader's locale and zone. */
72
47
  export const useToolActionCard = (block: () => ToolBlock): (() => ActionCard) => {
73
48
  const locale = useLocale();
@@ -31,6 +31,7 @@ import {
31
31
  chatTaskStopSentence,
32
32
  } from "./chat-task-reasons";
33
33
  import { buildBlocksFromMessages } from "./protocol";
34
+ import { compactReviewMetaSql } from "./review-content";
34
35
  import { withAiShortIdForDb } from "./short-id";
35
36
  import { aiConversations } from "./store";
36
37
  import { toolOutcome, toolWording } from "./tool-wording";
@@ -575,7 +576,7 @@ const deliverStoppedTurn = async (
575
576
  if (!claimed) return null;
576
577
  const locale = chatTaskLocale(config.locale ?? task?.locale, appLocale);
577
578
  const messages = await db<{ seq: number; message: Message; meta: AiStoredMessage["meta"] }[]>`
578
- SELECT seq, message, meta FROM ai.task_messages WHERE loop_id = ${turn.id} ORDER BY seq`;
579
+ SELECT seq, message, ${compactReviewMetaSql(sql`meta`)} AS meta FROM ai.task_messages WHERE loop_id = ${turn.id} ORDER BY seq`;
579
580
  const done = buildBlocksFromMessages(messages).flatMap((block) => {
580
581
  if (block.kind !== "tool" || block.status !== "completed" || block.isError) return [];
581
582
  // Web reads have chat wording too, but do not change anything for a stop receipt.
@@ -138,6 +138,15 @@ export type CreateAiChatControllerOptions = {
138
138
 
139
139
  const isAccessor = <T>(value: T | Accessor<T>): value is Accessor<T> => typeof value === "function";
140
140
 
141
+ class AiRequestError extends Error {
142
+ constructor(
143
+ message: string,
144
+ readonly status: number,
145
+ ) {
146
+ super(message);
147
+ }
148
+ }
149
+
141
150
  const readError = async (response: Response, fallback: string): Promise<string> => {
142
151
  const body = await response.json().catch(() => null);
143
152
  return body && typeof body === "object" && "message" in body && typeof body.message === "string" ? body.message : fallback;
@@ -190,6 +199,17 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
190
199
  const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
191
200
  const [state, setState] = createStore<AiChatProjection>(initialProjection);
192
201
  const completedDuringSend = new Map<string, string>();
202
+ let disposed = false;
203
+ const submissionBodies = new Map<string, string>();
204
+ // Only a confirmed draft-save response can give this submission its identity.
205
+ const [creationErrorStatus, setCreationErrorStatus] = createSignal<number | null>(null);
206
+ const [uncertainSubmissions, setUncertainSubmissions] = createSignal<Record<string, number>>({});
207
+ const clearUncertainSubmission = (conversationId: string) =>
208
+ setUncertainSubmissions((current) => {
209
+ const next = { ...current };
210
+ delete next[conversationId];
211
+ return next;
212
+ });
193
213
  const [pendingSends, setPendingSends] = createSignal<Record<string, AiStoredMessage | undefined>>({});
194
214
  const clearPendingSend = (conversationId: string) =>
195
215
  setPendingSends((current) => {
@@ -251,7 +271,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
251
271
 
252
272
  const request = async <T>(path: string, init: RequestInit, fallback: string): Promise<T> => {
253
273
  const response = await fetch(url(path), { ...init, headers: { "Content-Type": "application/json", ...init.headers } });
254
- if (!response.ok) throw new Error(await readError(response, fallback));
274
+ if (!response.ok) throw new AiRequestError(await readError(response, fallback), response.status);
255
275
  return (await response.json()) as T;
256
276
  };
257
277
 
@@ -688,6 +708,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
688
708
 
689
709
  const createConversation = async (
690
710
  input: {
711
+ requestId?: string;
691
712
  title?: string;
692
713
  projectId?: string;
693
714
  draft?: { content: AiDraftContentPart[] };
@@ -696,6 +717,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
696
717
  ) => {
697
718
  const generation = ++conversationOpenGeneration;
698
719
  clearErrors();
720
+ setCreationErrorStatus(null);
699
721
  try {
700
722
  const conversation = await request<AiConversation>(
701
723
  `/conversations`,
@@ -714,6 +736,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
714
736
  return conversation;
715
737
  } catch (createError) {
716
738
  if (generation === conversationOpenGeneration) {
739
+ setCreationErrorStatus(createError instanceof AiRequestError ? createError.status : null);
717
740
  setGlobalError(createError instanceof Error ? createError.message : "Failed to create conversation");
718
741
  }
719
742
  return null;
@@ -766,7 +789,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
766
789
  input: ComposerDraftInput,
767
790
  conversationId: string | null,
768
791
  ): Promise<{ conversationId: string; content: AiDraftContentPart[]; revision: number } | null> => {
769
- if (!conversationId) return null;
792
+ if (!conversationId || uncertainSubmissions()[conversationId] !== undefined) return null;
770
793
  const conversation = isActiveConversation(conversationId) ? state.conversation : cache.get(conversationId)?.conversation;
771
794
  if (!conversation) return null;
772
795
 
@@ -833,7 +856,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
833
856
  return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
834
857
  };
835
858
 
836
- const queuedAttempts = new Map<string, { key: string; files?: File[]; id: string; saved: { revision: number } }>();
859
+ const queuedAttempts = new Map<string, { key: string; files?: File[]; id: string }>();
837
860
  const queueMessage = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
838
861
  if (!input.conversationId || !isComposerDraftSendable(input)) return Promise.resolve(false);
839
862
  const conversationId = input.conversationId;
@@ -845,38 +868,46 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
845
868
  storedFiles: input.storedFiles,
846
869
  model: input.modelProfileId,
847
870
  });
871
+ const previous = queuedAttempts.get(conversationId);
872
+ if (uncertainSubmissions()[conversationId] !== undefined) {
873
+ const same =
874
+ previous?.key === key &&
875
+ (previous.files ?? []).length === (input.files ?? []).length &&
876
+ (previous.files ?? []).every((file, index) => file === input.files?.[index]);
877
+ return same ? retrySubmission(conversationId).then((result) => result === "accepted") : Promise.resolve(false);
878
+ }
848
879
  return queueDraftOperation(async () => {
880
+ if (uncertainSubmissions()[conversationId] !== undefined) return false;
881
+ let submittedRevision: number | undefined;
849
882
  try {
850
- const previous = queuedAttempts.get(conversationId);
851
- const same =
852
- previous?.key === key &&
853
- (previous.files ?? []).length === (input.files ?? []).length &&
854
- (previous.files ?? []).every((file, index) => file === input.files?.[index]);
855
- const saved = same ? previous.saved : await persistComposerDraft(input, conversationId);
883
+ const saved = await persistComposerDraft(input, conversationId);
856
884
  if (!saved) return false;
857
- const queueId = same ? previous.id : crypto.randomUUID();
858
- queuedAttempts.set(conversationId, { key, files: input.files, id: queueId, saved });
859
- await request(
860
- `/conversations/${conversationId}/turns`,
861
- {
862
- method: "POST",
863
- body: JSON.stringify({
864
- draftRevision: saved.revision,
865
- queueId,
866
- modelProfileId: input.modelProfileId,
867
- clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
868
- }),
869
- },
870
- "Failed to queue message",
871
- );
885
+ const queueId = crypto.randomUUID();
886
+ const body = JSON.stringify({
887
+ draftRevision: saved.revision,
888
+ queueId,
889
+ modelProfileId: input.modelProfileId,
890
+ clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
891
+ });
892
+ queuedAttempts.set(conversationId, { key, files: input.files, id: queueId });
893
+ submissionBodies.set(conversationId, body);
894
+ submittedRevision = saved.revision;
895
+ await request(`/conversations/${conversationId}/turns`, { method: "POST", body }, "Failed to queue message");
872
896
  if (isActiveConversation(conversationId)) {
873
897
  if ((state.conversation?.draft.revision ?? 0) <= saved.revision)
874
898
  setState("conversation", "draft", { content: [], revision: saved.revision + 1, updatedAt: new Date().toISOString() });
875
899
  } else invalidateInactiveCache(conversationId);
876
900
  queuedAttempts.delete(conversationId);
901
+ submissionBodies.delete(conversationId);
877
902
  if (isActiveConversation(conversationId)) setGlobalError(null);
878
903
  return true;
879
904
  } catch (error) {
905
+ if (submittedRevision !== undefined && !(error instanceof AiRequestError && error.status >= 400 && error.status < 500)) {
906
+ setUncertainSubmissions((current) => ({ ...current, [conversationId]: submittedRevision! }));
907
+ } else {
908
+ queuedAttempts.delete(conversationId);
909
+ submissionBodies.delete(conversationId);
910
+ }
880
911
  setConversationError(conversationId, error instanceof Error ? error.message : "Failed to queue message");
881
912
  return false;
882
913
  }
@@ -888,7 +919,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
888
919
  const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
889
920
  return queueDraftOperation(async () => {
890
921
  const conversationId = await targetConversation;
891
- if (!conversationId || (isActiveConversation(conversationId) && running())) return false;
922
+ if (!conversationId || uncertainSubmissions()[conversationId] !== undefined || (isActiveConversation(conversationId) && running()))
923
+ return false;
892
924
  if (isActiveConversation(conversationId)) clearErrors();
893
925
  const baseProjection = isActiveConversation(conversationId)
894
926
  ? { conversation: state.conversation, messages: [...state.messages], activeTurn: state.activeTurn }
@@ -928,6 +960,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
928
960
  setRunStatusRaw("streaming");
929
961
  }
930
962
 
963
+ let submittedRevision: number | undefined;
931
964
  try {
932
965
  const savedDraft = await persistComposerDraft(input, conversationId);
933
966
  if (!savedDraft) {
@@ -956,18 +989,22 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
956
989
  },
957
990
  },
958
991
  }));
992
+ submittedRevision = savedDraft.revision;
993
+ const submissionBody = JSON.stringify({
994
+ draftRevision: savedDraft.revision,
995
+ clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
996
+ modelProfileId: input.modelProfileId,
997
+ });
998
+ submissionBodies.set(conversationId, submissionBody);
959
999
  const result = await request<SubmitTurnResult>(
960
1000
  `/conversations/${conversationId}/turns`,
961
1001
  {
962
1002
  method: "POST",
963
- body: JSON.stringify({
964
- draftRevision: savedDraft.revision,
965
- clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
966
- modelProfileId: input.modelProfileId,
967
- }),
1003
+ body: submissionBody,
968
1004
  },
969
1005
  "AI request failed",
970
1006
  );
1007
+ submissionBodies.delete(conversationId);
971
1008
  const alreadyFinished = completedDuringSend.get(conversationId) === result.turn.id;
972
1009
  completedDuringSend.delete(conversationId);
973
1010
  if (alreadyFinished) clearPendingSend(conversationId);
@@ -1006,7 +1043,13 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1006
1043
  return true;
1007
1044
  } catch (sendError) {
1008
1045
  completedDuringSend.delete(conversationId);
1009
- clearPendingSend(conversationId);
1046
+ // A 4xx on this first submission rejects it. Transport/5xx failures may have committed.
1047
+ if (
1048
+ submittedRevision !== undefined &&
1049
+ !(sendError instanceof AiRequestError && sendError.status >= 400 && sendError.status < 500)
1050
+ ) {
1051
+ setUncertainSubmissions((current) => ({ ...current, [conversationId]: submittedRevision! }));
1052
+ } else clearPendingSend(conversationId);
1010
1053
  if (invalidateInactiveCache(conversationId)) return false;
1011
1054
  setState("messages", (prev) => prev.filter((message) => message.id !== optimistic.id));
1012
1055
  cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
@@ -1017,6 +1060,73 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1017
1060
  });
1018
1061
  };
1019
1062
 
1063
+ // Keep the local draft locked until its post-submission revision is available.
1064
+ // A positive lookup alone must not hydrate the composer from a stale cached draft.
1065
+ const finishAcceptedSubmission = async (conversationId: string): Promise<"accepted" | "unknown"> => {
1066
+ const revision = uncertainSubmissions()[conversationId];
1067
+ if (revision === undefined || disposed) return "unknown";
1068
+ if (isActiveConversation(conversationId)) {
1069
+ if (!(await refreshActiveConversation())) return "unknown";
1070
+ if (isActiveConversation(conversationId) && (state.conversation?.draft.revision ?? -1) <= revision) return "unknown";
1071
+ } else cache.delete(conversationId);
1072
+ if (disposed) return "unknown";
1073
+ submissionBodies.delete(conversationId);
1074
+ queuedAttempts.delete(conversationId);
1075
+ clearUncertainSubmission(conversationId);
1076
+ clearPendingSend(conversationId);
1077
+ if (isActiveConversation(conversationId)) setGlobalError(null);
1078
+ return "accepted";
1079
+ };
1080
+
1081
+ /** Read only: a missing message in a history window never proves non-acceptance. */
1082
+ const reconcileSubmission = async (conversationId: string): Promise<"accepted" | "unknown" | "none"> => {
1083
+ const revision = uncertainSubmissions()[conversationId];
1084
+ if (revision === undefined) return "none";
1085
+ try {
1086
+ const queue = queuedAttempts.get(conversationId);
1087
+ let accepted: boolean;
1088
+ if (queue) {
1089
+ const pending = await request<Array<{ id: string }>>(
1090
+ `/conversations/${conversationId}/queue`,
1091
+ {},
1092
+ "Failed to check message status",
1093
+ );
1094
+ // Drained/cancelled entries disappear from this view. Absence never proves rejection;
1095
+ // an explicit frozen retry still uses the server's durable queue receipt.
1096
+ accepted = pending.some((message) => message.id === queue.id);
1097
+ } else {
1098
+ const result = await request<{ submission: { messageId: string; turnId: string | null } | null }>(
1099
+ `/conversations/${conversationId}/submission/${revision}`,
1100
+ {},
1101
+ "Failed to check message status",
1102
+ );
1103
+ accepted = Boolean(result.submission);
1104
+ }
1105
+ if (!accepted || disposed || uncertainSubmissions()[conversationId] !== revision) return "unknown";
1106
+ return await finishAcceptedSubmission(conversationId);
1107
+ } catch (error) {
1108
+ setConversationError(conversationId, error instanceof Error ? error.message : "Failed to check message status");
1109
+ return "unknown";
1110
+ }
1111
+ };
1112
+
1113
+ /** Explicit retry of the same frozen submission. Never saves or chooses another revision. */
1114
+ const retrySubmission = (conversationId: string) =>
1115
+ queueDraftOperation(async (): Promise<"accepted" | "unknown" | "none"> => {
1116
+ const resolved = await reconcileSubmission(conversationId);
1117
+ if (resolved !== "unknown" || disposed) return resolved;
1118
+ const body = submissionBodies.get(conversationId);
1119
+ if (!body) return "unknown";
1120
+ try {
1121
+ await request<SubmitTurnResult>(`/conversations/${conversationId}/turns`, { method: "POST", body }, "AI request failed");
1122
+ if (disposed) return "unknown";
1123
+ return await finishAcceptedSubmission(conversationId);
1124
+ } catch {
1125
+ // Even a conflict cannot turn the earlier lost response into a known rejection.
1126
+ return reconcileSubmission(conversationId);
1127
+ }
1128
+ });
1129
+
1020
1130
  const submitSteer = async (input: { text: string; clientRequestId: string; blockId: string }) => {
1021
1131
  const conversationId = activeConversationId();
1022
1132
  const turn = state.activeTurn;
@@ -1426,7 +1536,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1426
1536
  runFrontendTools();
1427
1537
  }
1428
1538
 
1429
- onCleanup(() => closeStream());
1539
+ onCleanup(() => {
1540
+ disposed = true;
1541
+ closeStream();
1542
+ });
1430
1543
 
1431
1544
  const hasMoreHistory = () => {
1432
1545
  const conversationId = activeConversationId();
@@ -1457,6 +1570,11 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
1457
1570
  openConversation,
1458
1571
  refreshActiveConversation,
1459
1572
  createConversation,
1573
+ /** HTTP status for the reported creation failure; null also covers an unknown network outcome. */
1574
+ creationErrorStatus,
1575
+ uncertainSubmissions,
1576
+ reconcileSubmission,
1577
+ retrySubmission,
1460
1578
  saveDraft,
1461
1579
  queueMessage,
1462
1580
  send,
@@ -73,7 +73,14 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
73
73
  .filter((line) => line.startsWith("data:"))
74
74
  .map((line) => line.slice(5).trimStart())
75
75
  .join("\n");
76
- if (data) yield JSON.parse(data) as AiStreamEvent;
76
+ if (data) {
77
+ const value: unknown = JSON.parse(data);
78
+ if (value && typeof value === "object" && "code" in value && value.code === "app_access_denied") {
79
+ const message = "message" in value && typeof value.message === "string" ? value.message : "Application access denied";
80
+ throw new AiStreamError("access_denied", message);
81
+ }
82
+ yield value as AiStreamEvent;
83
+ }
77
84
  }
78
85
  }
79
86
  } finally {
@@ -133,8 +140,13 @@ export const subscribeAiStream = (input: {
133
140
  if (stopped) break;
134
141
  input.onEvent(event);
135
142
  }
136
- } catch {
143
+ } catch (error) {
137
144
  if (stopped) return;
145
+ if (error instanceof AiStreamError) {
146
+ stopped = true;
147
+ input.onError?.(error);
148
+ return;
149
+ }
138
150
  } finally {
139
151
  clearConnectTimer();
140
152
  if (activeAttempt === attempt) activeAttempt = null;
@@ -1,6 +1,7 @@
1
1
  import { type Context, Hono } from "hono";
2
2
  import { z } from "zod";
3
3
  import { readBoundedJson } from "../_internal/bounded-json";
4
+ import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
4
5
  import { dispatchCapability } from "../api/capabilities";
5
6
  import { dispatchCapabilityStream } from "../api/capability-streams";
6
7
  import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
@@ -37,30 +38,33 @@ export function createCodeCapabilityRoutes(
37
38
  credentialKind: "session",
38
39
  scopes: [],
39
40
  });
40
- const invoke = async (c: Context<AuthContext>, review = false) => {
41
- const body = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
42
- const input = body.ok ? Input.safeParse(body.data) : null;
43
- if (!input?.success) return Response.json({ code: "VALIDATION_FAILED", message: "Invalid capability input" }, { status: 400 });
44
- const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
45
- const appId = c.req.param("appId")!;
46
- const capabilityId = c.req.param("capabilityId")!;
47
- if (conversation?.allowedTools && !conversation.allowedTools.includes(`${appId}.${capabilityId}`)) return denied();
48
- const config = await store.getTurnRunConfig({ conversationId: c.req.param("conversationId")!, turnId: c.req.param("turnId")! });
49
- if (!config || config.kind === "compact") return denied();
50
- return (dependencies.dispatch ?? dispatchCapability)({
51
- request: c.req.raw,
52
- authority: turnAuthority(c),
53
- origin: "assistant",
54
- continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
55
- mandate: config.mandate ? { mandateId: config.mandate.id, mandateRevision: config.mandate.revision, ownerAppId: "core" } : undefined,
56
- review,
57
- kind: review || c.req.param("kind") === "actions" ? "actions" : "queries",
58
- appId,
59
- capabilityId,
60
- input: input.data.input,
61
- locale: getLocale(c),
41
+ const invoke = async (c: Context<AuthContext>, review = false) =>
42
+ withCapabilityRequestTimeout(c, review ? "review" : c.req.param("kind") === "actions" ? "action" : "query", async () => {
43
+ const body = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
44
+ const input = body.ok ? Input.safeParse(body.data) : null;
45
+ if (!input?.success) return Response.json({ code: "VALIDATION_FAILED", message: "Invalid capability input" }, { status: 400 });
46
+ const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
47
+ const appId = c.req.param("appId")!;
48
+ const capabilityId = c.req.param("capabilityId")!;
49
+ if (conversation?.allowedTools && !conversation.allowedTools.includes(`${appId}.${capabilityId}`)) return denied();
50
+ const config = await store.getTurnRunConfig({ conversationId: c.req.param("conversationId")!, turnId: c.req.param("turnId")! });
51
+ if (!config || config.kind === "compact") return denied();
52
+ return (dependencies.dispatch ?? dispatchCapability)({
53
+ request: c.req.raw,
54
+ authority: turnAuthority(c),
55
+ origin: "assistant",
56
+ continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
57
+ mandate: config.mandate
58
+ ? { mandateId: config.mandate.id, mandateRevision: config.mandate.revision, ownerAppId: "core" }
59
+ : undefined,
60
+ review,
61
+ kind: review || c.req.param("kind") === "actions" ? "actions" : "queries",
62
+ appId,
63
+ capabilityId,
64
+ input: input.data.input,
65
+ locale: getLocale(c),
66
+ });
62
67
  });
63
- };
64
68
  return new Hono<AuthContext>()
65
69
  .use("/:conversationId/:turnId/*", async (c, next) => {
66
70
  await next();
@@ -4,7 +4,8 @@ import { readBoundedJson } from "../_internal/bounded-json";
4
4
  import { getApp } from "../_internal/registry";
5
5
  import type { RequestActor } from "../server";
6
6
  import { IDENTITY_REFRESH_TIMEOUT_MS } from "../services/identity/constants";
7
- import { signInvocationToken } from "../services/identity/invocation-token";
7
+ import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
8
+ import { issueInvocationToken as signInvocationToken } from "../services/identity/issue-invocation";
8
9
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
9
10
  import { logger } from "../services/logging";
10
11
  import { LOCALE_HEADER } from "../shared/locale";
@@ -62,11 +63,12 @@ export const runManagedCodeTool =
62
63
  if (!runConfig || runConfig.kind === "compact" || Boolean(runConfig.background) !== Boolean(runConfig.mandate)) {
63
64
  throw new Error("Code execution requires a valid turn and task-scoped background authority.");
64
65
  }
65
- const { actor } = await resolveAiCapabilityActor({
66
+ const authority = await resolveAiCapabilityActor({
66
67
  conversationId: context.conversationId,
67
68
  persistedActor: context.actor,
68
69
  store: aiConversations,
69
70
  });
71
+ const { actor } = authority;
70
72
  await authorizeCodeExecution(context.conversationId, context.turnId, actor.user.id);
71
73
  await context.reportProgress?.(context.locale?.startsWith("de") ? "Ausführungshost verbinden" : "Connecting execution host");
72
74
  const app = await getApp("assistant");
@@ -81,21 +83,17 @@ export const runManagedCodeTool =
81
83
  try {
82
84
  return await withActiveIdentitySigner(
83
85
  "invocation",
84
- (signer) =>
85
- signInvocationToken({
86
- ...audience,
87
- schemaHash: null,
88
- authority: {
89
- sub: actor.user.id,
90
- principal_type: "user",
91
- access_subject_type: "user",
92
- access_subject_id: actor.user.id,
93
- credential_kind: "session",
94
- scopes: [],
86
+ (signer, db) =>
87
+ signInvocationToken(
88
+ {
89
+ ...audience,
90
+ schemaHash: null,
91
+ authority: invocationAuthorityFromRequest(authority),
92
+ signer,
93
+ issuer: signer.issuer,
95
94
  },
96
- signer,
97
- issuer: signer.issuer,
98
- }),
95
+ { db, groupsAdmin: signer.groupsAdmin },
96
+ ),
99
97
  { signal: context.signal, timeoutMs: ISSUANCE_TIMEOUT_MS },
100
98
  );
101
99
  } catch (error) {
@@ -8,7 +8,8 @@ import {
8
8
  completeCapabilityClaim,
9
9
  markCapabilityClaimUncertain,
10
10
  } from "../capabilities/claims";
11
- import { signInvocationToken } from "../services/identity/invocation-token";
11
+ import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
12
+ import { issueInvocationToken as signInvocationToken } from "../services/identity/issue-invocation";
12
13
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
13
14
  import { LOCALE_HEADER } from "../shared/locale";
14
15
  import { resolveAiCapabilityActor } from "./capability-execution";
@@ -53,11 +54,12 @@ export function createCodeSourceTool(name: CodeSourceToolName) {
53
54
  promptHint: "promptHint" in definition ? definition.promptHint : undefined,
54
55
  }).server(async (input, context) => {
55
56
  if (!context.conversationId) throw new Error("Code tools require an Assistant conversation.");
56
- const { actor } = await resolveAiCapabilityActor({
57
+ const authority = await resolveAiCapabilityActor({
57
58
  conversationId: context.conversationId,
58
59
  persistedActor: context.actor,
59
60
  store: aiConversations,
60
61
  });
62
+ const { actor } = authority;
61
63
  const databaseOperations: Partial<Record<CodeSourceToolName, string>> = {
62
64
  code_sql: "query",
63
65
  code_database_read: "status",
@@ -97,23 +99,19 @@ export function createCodeSourceTool(name: CodeSourceToolName) {
97
99
  await authorizeDatabase?.();
98
100
  const signed = await withActiveIdentitySigner(
99
101
  "invocation",
100
- (signer) =>
101
- signInvocationToken({
102
- targetAppId: "assistant",
103
- callingAppId: "core",
104
- operation: `tool:${name}`,
105
- schemaHash: null,
106
- authority: {
107
- sub: actor.user.id,
108
- principal_type: "user",
109
- access_subject_type: "user",
110
- access_subject_id: actor.user.id,
111
- credential_kind: "session",
112
- scopes: [],
102
+ (signer, db) =>
103
+ signInvocationToken(
104
+ {
105
+ targetAppId: "assistant",
106
+ callingAppId: "core",
107
+ operation: `tool:${name}`,
108
+ schemaHash: null,
109
+ authority: invocationAuthorityFromRequest(authority),
110
+ signer,
111
+ issuer: signer.issuer,
113
112
  },
114
- signer,
115
- issuer: signer.issuer,
116
- }),
113
+ { db, groupsAdmin: signer.groupsAdmin },
114
+ ),
117
115
  { signal, timeoutMs: 5000 },
118
116
  );
119
117
  const headers = new Headers({ authorization: `Bearer ${signed.token}`, "content-type": "application/json" });