@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -59,6 +59,9 @@ export const cloudAiChartKindSchema = () =>
59
59
 
60
60
  const chartParts = (limits = true) => {
61
61
  const ChartLabel = limits ? z.string().trim().min(1).max(80) : z.union([z.string(), z.number().transform(String)]);
62
+ const ChartTooltip = z
63
+ .strictObject({ label: ChartLabel.optional(), format: cloudAiNumberFormatSchema().optional() })
64
+ .describe("All kinds: what tooltips and the data table call the value and how they format it; axes keep their own format.");
62
65
  // Missing observations stay numeric so the renderer can omit them with its finite-value checks.
63
66
  const ChartNumber = limits
64
67
  ? z.number()
@@ -96,6 +99,7 @@ const chartParts = (limits = true) => {
96
99
  );
97
100
  return {
98
101
  ChartLabel,
102
+ ChartTooltip,
99
103
  ChartNumber,
100
104
  list,
101
105
  ChartReferences,
@@ -148,6 +152,7 @@ const chartKinds = (limits = true) => {
148
152
  ChartBins,
149
153
  ChartCategory,
150
154
  ChartCommon,
155
+ ChartTooltip,
151
156
  ChartLabel,
152
157
  ChartPoints,
153
158
  ChartSeries,
@@ -159,11 +164,12 @@ const chartKinds = (limits = true) => {
159
164
  ChartCell,
160
165
  ChartBarGauge,
161
166
  } = chartParts(limits);
167
+ const ChartKindCommon = { ...ChartCommon, tooltip: ChartTooltip.optional() };
162
168
  const numberFormat = cloudAiNumberFormatSchema();
163
169
  return z.discriminatedUnion("kind", [
164
170
  z.object({
165
171
  kind: z.literal("bar"),
166
- ...ChartCommon,
172
+ ...ChartKindCommon,
167
173
  data: list(ChartCategory, 1, CLOUD_AI_CHART_MAX_VALUES),
168
174
  yAxis: ChartAxis.optional(),
169
175
  colorByBar: z.boolean().optional(),
@@ -173,7 +179,7 @@ const chartKinds = (limits = true) => {
173
179
  }),
174
180
  z.object({
175
181
  kind: z.literal("line"),
176
- ...ChartCommon,
182
+ ...ChartKindCommon,
177
183
  series: list(
178
184
  ChartSeries(
179
185
  limits
@@ -192,7 +198,7 @@ const chartKinds = (limits = true) => {
192
198
  }),
193
199
  z.object({
194
200
  kind: z.literal("scatter"),
195
- ...ChartCommon,
201
+ ...ChartKindCommon,
196
202
  series: list(ChartSeries(limits ? ChartPoints.min(1) : ChartPoints), 1, CLOUD_AI_CHART_MAX_SERIES),
197
203
  xAxis: ChartAxis.optional(),
198
204
  yAxis: ChartAxis.optional(),
@@ -201,7 +207,7 @@ const chartKinds = (limits = true) => {
201
207
  }),
202
208
  z.object({
203
209
  kind: z.enum(["pie", "donut"]),
204
- ...ChartCommon,
210
+ ...ChartKindCommon,
205
211
  data: list(ChartSlice, 1, CLOUD_AI_CHART_MAX_VALUES),
206
212
  legend: z.boolean().optional(),
207
213
  showLabels: z.boolean().optional(),
@@ -209,14 +215,14 @@ const chartKinds = (limits = true) => {
209
215
  }),
210
216
  z.object({
211
217
  kind: z.literal("histogram"),
212
- ...ChartCommon,
218
+ ...ChartKindCommon,
213
219
  data: list(ChartNumber, 1, CLOUD_AI_CHART_MAX_VALUES),
214
220
  bins: ChartBins.optional(),
215
221
  references: ChartReferences.optional(),
216
222
  }),
217
223
  z.object({
218
224
  kind: z.literal("gauge"),
219
- ...ChartCommon,
225
+ ...ChartKindCommon,
220
226
  value: ChartNumber,
221
227
  min: z.number().optional(),
222
228
  max: z.number().optional(),
@@ -227,20 +233,20 @@ const chartKinds = (limits = true) => {
227
233
  }),
228
234
  z.object({
229
235
  kind: z.literal("sparkline"),
230
- ...ChartCommon,
236
+ ...ChartKindCommon,
231
237
  data: list(ChartNumber, 2, CLOUD_AI_CHART_MAX_VALUES),
232
238
  area: z.boolean().optional(),
233
239
  }),
234
240
  z.object({
235
241
  kind: z.literal("boxplot"),
236
- ...ChartCommon,
242
+ ...ChartKindCommon,
237
243
  data: list(ChartBox, 1, CLOUD_AI_CHART_MAX_VALUES),
238
244
  yAxis: ChartAxis.optional(),
239
245
  references: ChartReferences.optional(),
240
246
  }),
241
247
  z.object({
242
248
  kind: z.literal("heatmap"),
243
- ...ChartCommon,
249
+ ...ChartKindCommon,
244
250
  data: list(ChartCell, 1, CLOUD_AI_VIEW_MAX_MARKS),
245
251
  format: numberFormat.optional(),
246
252
  showValues: z.boolean().optional(),
@@ -249,7 +255,7 @@ const chartKinds = (limits = true) => {
249
255
  }),
250
256
  z.object({
251
257
  kind: z.literal("barGauge"),
252
- ...ChartCommon,
258
+ ...ChartKindCommon,
253
259
  // The shared renderer draws 280 units high and a bar gauge row is 28 units, so 8 rows fit without a taller drawing.
254
260
  data: list(ChartBarGauge, 1, CLOUD_AI_CHART_MAX_SERIES, `Use at most ${CLOUD_AI_CHART_MAX_SERIES} bar gauge items.`),
255
261
  min: z.number().optional(),
@@ -260,7 +266,7 @@ const chartKinds = (limits = true) => {
260
266
  }),
261
267
  z.object({
262
268
  kind: z.literal("stat"),
263
- ...ChartCommon,
269
+ ...ChartKindCommon,
264
270
  value: ChartNumber,
265
271
  format: numberFormat.optional(),
266
272
  unit: ChartUnit.optional(),
@@ -336,11 +342,22 @@ export const cloudAiGroupSchema = () => {
336
342
  };
337
343
 
338
344
  const flatChartSchema = () => {
339
- const { ChartAxis, ChartBins, ChartCommon, ChartLabel, ChartPoints, ChartSeries, ChartReferences, ChartThresholds, ChartUnit } =
340
- chartParts();
345
+ const {
346
+ ChartAxis,
347
+ ChartBins,
348
+ ChartCommon,
349
+ ChartLabel,
350
+ ChartPoints,
351
+ ChartSeries,
352
+ ChartReferences,
353
+ ChartThresholds,
354
+ ChartUnit,
355
+ ChartTooltip,
356
+ } = chartParts();
341
357
  return z.object({
342
358
  kind: cloudAiChartKindSchema().optional().describe("Single charts: kind. Omit for groups."),
343
359
  ...ChartCommon,
360
+ tooltip: ChartTooltip.optional(),
344
361
  // One item object preserves every kind's fields before canonical validation; unions would strip overlapping items.
345
362
  data: z
346
363
  .union([
@@ -1,9 +1,11 @@
1
1
  import type { ClientRequest, IncomingHttpHeaders, IncomingMessage } from "node:http";
2
2
  import { type RequestOptions as HttpsRequestOptions, request as httpsRequest } from "node:https";
3
3
  import { z } from "zod";
4
+ import { DOCUMENT_EXTRACTION_MAX_INPUT_BYTES, DOCUMENT_EXTRACTION_MAX_OUTPUT_BYTES } from "../services/document-extraction";
4
5
  import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../services/network-security";
5
6
  import { AI_FILES_MAX_FILE_BYTES_DEFAULT, aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
6
7
  import { authorizeWebRead } from "./firecrawl-tools";
8
+ import { htmlToReadableMarkdown } from "./readable-html";
7
9
  import { defineAiTool } from "./tools";
8
10
 
9
11
  const FETCH_FILE_MAX_REDIRECTS = 5;
@@ -38,6 +40,7 @@ export const CloudAiFetchFileOutputSchema = z.object({
38
40
  size: z.number().int().nonnegative(),
39
41
  mediaType: z.string(),
40
42
  url: z.string().url(),
43
+ readable: z.object({ path: z.string(), size: z.number().int().nonnegative(), note: z.string() }).optional(),
41
44
  });
42
45
 
43
46
  const publicHttpsUrl = (rawUrl: string): URL => {
@@ -309,20 +312,51 @@ export const runCloudAiFetchFile = async (
309
312
  bytes: downloaded.bytes,
310
313
  mediaType: responseMediaType(downloaded.headers, path),
311
314
  });
312
- return { path: stat.path, size: stat.size, mediaType: stat.mediaType, url: downloaded.url.toString() };
315
+ const result = { path: stat.path, size: stat.size, mediaType: stat.mediaType, url: downloaded.url.toString() };
316
+ if (stat.mediaType !== "text/html" && stat.mediaType !== "application/xhtml+xml") return result;
317
+ try {
318
+ const contentType = downloaded.headers["content-type"];
319
+ const markdown = await htmlToReadableMarkdown(downloaded.bytes, {
320
+ baseUrl: downloaded.url,
321
+ contentType: Array.isArray(contentType) ? contentType[0] : contentType,
322
+ // The same bounds as any other document converted to Markdown for the model.
323
+ maxInputBytes: DOCUMENT_EXTRACTION_MAX_INPUT_BYTES,
324
+ maxBytes: DOCUMENT_EXTRACTION_MAX_OUTPUT_BYTES,
325
+ signal: context.signal,
326
+ });
327
+ if (!markdown) return result;
328
+ const basename = stat.path.slice(stat.path.lastIndexOf("/") + 1).replace(/\.(?:html?|xhtml|xht)$/i, "");
329
+ const readable = await aiFileStore.createAssistantFile({
330
+ conversationId: context.conversationId,
331
+ path: `/imports/${basename}.md`,
332
+ bytes: new TextEncoder().encode(markdown),
333
+ mediaType: "text/markdown",
334
+ });
335
+ return {
336
+ ...result,
337
+ readable: {
338
+ path: readable.path,
339
+ size: readable.size,
340
+ note: "Readable Markdown of the page's main content. Read this path first; use the raw path only when you need the HTML markup.",
341
+ },
342
+ };
343
+ } catch {
344
+ // The raw import already succeeded; a readable copy is best-effort, including on cancellation.
345
+ return result;
346
+ }
313
347
  };
314
348
 
315
349
  export const createCloudAiFetchFileTool = (dependencies: FetchFileDependencies = {}) =>
316
350
  defineAiTool({
317
351
  name: "fetch_file",
318
352
  description:
319
- "Download one exact public HTTPS file URL into the current conversation. Use this for images, raw GitHub files, documents, or other source files the user wants inspected. This does not browse or clone repositories, authenticate to websites, or fetch private network targets. After downloading, inspect the returned path with read_file or view_image and use present when the user should receive the file.",
353
+ "Download one exact public HTTPS file URL into this conversation for inspection. Supports images, documents, raw repository files, and web pages; no repository browsing, website authentication, or private network access. For web pages, read readable.path (main-content Markdown) first with read_file when returned; use raw HTML only when markup matters. Inspect other returned paths with read_file or view_image and use present to deliver files.",
320
354
  inputSchema: CloudAiFetchFileInputSchema,
321
355
  outputSchema: CloudAiFetchFileOutputSchema,
322
356
  approval: "never",
323
357
  timeoutMs: 90_000,
324
358
  promptHint:
325
- "fetch an exact public HTTPS source file into the chat when the user provides a file link; inspect it with read_file or view_image and present it when useful.",
359
+ "fetch an exact public HTTPS source file into the chat; for web pages read readable.path (main-content Markdown) first with read_file when returned; use raw HTML only when markup matters. Inspect other files with read_file or view_image; present when useful.",
326
360
  }).server(async (input, context) => {
327
361
  const url = publicHttpsUrl(input.url);
328
362
  const allowedForChat = await authorizeWebRead("file", url.toString(), context);
package/src/ai/index.ts CHANGED
@@ -2,6 +2,7 @@ export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileApp
2
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
3
3
  export {
4
4
  AI_WEBSITE_APPROVAL_TOOL,
5
+ type AiToolApprovalChangeContext,
5
6
  type AiToolApprovalContext,
6
7
  type AiToolApprovalPreference,
7
8
  type AiToolApprovalRemember,
@@ -33,11 +34,22 @@ export {
33
34
  ChatTaskOccurrenceIdSchema,
34
35
  ChatTaskScheduleInputSchema,
35
36
  chatTaskCreateFingerprint,
37
+ chatTaskName,
36
38
  getChatTaskTimezone,
37
39
  normalizeChatTaskSchedule,
38
40
  toAiChatTaskOccurrenceView,
39
41
  toAiChatTaskView,
40
42
  } from "./chat-task-contracts";
43
+ export {
44
+ CHAT_TASK_REASON_CODES,
45
+ type ChatTaskReason,
46
+ type ChatTaskReasonCode,
47
+ chatTaskLocale,
48
+ chatTaskNeedsNewTime,
49
+ chatTaskReasonSentence,
50
+ chatTaskReasonState,
51
+ chatTaskStopSentence,
52
+ } from "./chat-task-reasons";
41
53
  export {
42
54
  type AiChatTask,
43
55
  AiChatTaskIdempotencyConflictError,
package/src/ai/migrate.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { sql } from "bun";
2
2
  import { CapabilityActionReviewSchema } from "../contracts/capabilities";
3
3
  import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
4
+ import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
4
5
  import { migrateAiMessageQueue } from "./message-queue";
5
6
  import { migrateAiModelAccess } from "./model-access-migrate";
6
7
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -516,6 +517,12 @@ export const migrateCloudAi = async (): Promise<void> => {
516
517
  WHERE status = 'completed' AND run_config->>'kind' = 'chat'
517
518
  `.simple();
518
519
 
520
+ await sql`
521
+ CREATE INDEX IF NOT EXISTS idx_ai_turns_background_finished
522
+ ON ai.turns(completed_at, id)
523
+ WHERE run_config ? 'background' AND status IN ('completed', 'failed', 'aborted')
524
+ `.simple();
525
+
519
526
  await sql`
520
527
  CREATE TABLE IF NOT EXISTS ai.conversation_resource_refs (
521
528
  conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
@@ -879,6 +886,8 @@ export const migrateCloudAi = async (): Promise<void> => {
879
886
  )
880
887
  `.simple();
881
888
 
889
+ await sql.begin(migrateChatTaskReasons);
890
+
882
891
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS idempotency_fingerprint TEXT`.simple();
883
892
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0`.simple();
884
893
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_id UUID`.simple();
@@ -992,6 +1001,17 @@ export const migrateCloudAi = async (): Promise<void> => {
992
1001
  )
993
1002
  `.simple();
994
1003
 
1004
+ await sql`
1005
+ DO $$ BEGIN
1006
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_task_occurrences_state_check'
1007
+ AND pg_get_constraintdef(oid) LIKE '%stopped%') THEN
1008
+ ALTER TABLE ai.chat_task_occurrences DROP CONSTRAINT IF EXISTS ai_chat_task_occurrences_state_check;
1009
+ ALTER TABLE ai.chat_task_occurrences ADD CONSTRAINT ai_chat_task_occurrences_state_check
1010
+ CHECK (state IN ('queued', 'running', 'completed', 'failed', 'stopped'));
1011
+ END IF;
1012
+ END $$
1013
+ `.simple();
1014
+
995
1015
  await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS task_revision BIGINT NOT NULL DEFAULT 0`.simple();
996
1016
 
997
1017
  await sql`
@@ -999,12 +1019,31 @@ export const migrateCloudAi = async (): Promise<void> => {
999
1019
  ON ai.chat_task_occurrences(request_key)
1000
1020
  `.simple();
1001
1021
 
1022
+ await sql`
1023
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_turn
1024
+ ON ai.chat_task_occurrences(turn_id)
1025
+ `.simple();
1026
+
1002
1027
  await sql`
1003
1028
  CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_queued
1004
1029
  ON ai.chat_task_occurrences(created_at ASC, id ASC)
1005
1030
  WHERE state = 'queued'
1006
1031
  `.simple();
1007
1032
 
1033
+ await sql`
1034
+ DO $$ BEGIN
1035
+ IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'ai' AND indexname = 'idx_ai_chat_task_occurrences_finished'
1036
+ AND indexdef NOT LIKE '%stopped%') THEN
1037
+ DROP INDEX ai.idx_ai_chat_task_occurrences_finished;
1038
+ END IF;
1039
+ END $$
1040
+ `.simple();
1041
+ await sql`
1042
+ CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_finished
1043
+ ON ai.chat_task_occurrences(completed_at, id)
1044
+ WHERE state IN ('completed', 'failed', 'stopped')
1045
+ `.simple();
1046
+
1008
1047
  await sql`
1009
1048
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_active_task
1010
1049
  ON ai.chat_task_occurrences(task_id)
@@ -1273,7 +1312,7 @@ export const migrateCloudAi = async (): Promise<void> => {
1273
1312
  CONSTRAINT ai_memory_learning_runs_kind_check CHECK (run_kind IN ('turn', 'workflow')),
1274
1313
  CONSTRAINT ai_memory_learning_runs_evidence_check CHECK (
1275
1314
  (run_kind = 'turn' AND evidence_key IS NULL)
1276
- OR (run_kind = 'workflow' AND turn_id IS NOT NULL AND evidence_key IS NOT NULL)
1315
+ OR (run_kind = 'workflow' AND evidence_key IS NOT NULL)
1277
1316
  ),
1278
1317
  CONSTRAINT ai_memory_learning_runs_changes_check CHECK (jsonb_typeof(changes) = 'array')
1279
1318
  )
@@ -1299,7 +1338,7 @@ export const migrateCloudAi = async (): Promise<void> => {
1299
1338
  await sql`
1300
1339
  ALTER TABLE ai.memory_learning_runs ADD CONSTRAINT ai_memory_learning_runs_evidence_check CHECK (
1301
1340
  (run_kind = 'turn' AND evidence_key IS NULL)
1302
- OR (run_kind = 'workflow' AND turn_id IS NOT NULL AND evidence_key IS NOT NULL)
1341
+ OR (run_kind = 'workflow' AND evidence_key IS NOT NULL)
1303
1342
  )
1304
1343
  `.simple();
1305
1344
  await sql`
@@ -2451,7 +2490,11 @@ export const migrateCloudAi = async (): Promise<void> => {
2451
2490
  {
2452
2491
  const { coreSettings } = await import("../services");
2453
2492
  const { setAiCredential, splitAiProfileCredentials } = await import("./credentials");
2454
- const split = splitAiProfileCredentials((await coreSettings.get<string>("ai.model_profiles_json")) ?? "[]");
2493
+ const profiles = await coreSettings.read<string>("ai.model_profiles_json");
2494
+ if (profiles.stored === "unreadable") {
2495
+ console.warn('[ai] cannot read setting "ai.model_profiles_json"; skipping credential split');
2496
+ }
2497
+ const split = profiles.stored === "unreadable" ? null : splitAiProfileCredentials(profiles.value ?? "[]");
2455
2498
  if (split && split.credentials.length > 0) {
2456
2499
  for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret);
2457
2500
  await coreSettings.set("ai.model_profiles_json", split.profilesJson);