@k2b/cloud 0.36.0 → 0.38.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 (90) hide show
  1. package/package.json +5 -3
  2. package/scripts/README.md +1 -0
  3. package/scripts/cli-pack.ts +123 -0
  4. package/src/_internal/help-catalog.ts +123 -4
  5. package/src/_internal/help.ts +27 -6
  6. package/src/_internal/shell-focus.ts +11 -0
  7. package/src/access/PermissionEditor.browser-harness.tsx +46 -0
  8. package/src/access/PermissionEditor.tsx +38 -9
  9. package/src/access/ui.ts +1 -0
  10. package/src/account/Avatar.tsx +20 -2
  11. package/src/account/ui.ts +1 -1
  12. package/src/ai/browser.ts +1 -0
  13. package/src/ai/chart-block.ts +44 -13
  14. package/src/ai/chat/answer-links.ts +8 -0
  15. package/src/ai/chat/chart-block.tsx +2 -11
  16. package/src/ai/chat/messages.ts +12 -0
  17. package/src/ai/chat/primitives.tsx +2 -1
  18. package/src/ai/chat/web-tools.tsx +23 -6
  19. package/src/ai/chat-tasks.ts +65 -1
  20. package/src/ai/default-tool-contracts.ts +30 -13
  21. package/src/ai/executor.ts +46 -3
  22. package/src/ai/fetch-file-tool.ts +37 -3
  23. package/src/ai/migrate.ts +19 -2
  24. package/src/ai/readable-html.ts +1057 -0
  25. package/src/ai/types.ts +1 -0
  26. package/src/api/help.ts +23 -4
  27. package/src/api/mcp.ts +8 -2
  28. package/src/browser/FileChooser.tsx +11 -3
  29. package/src/browser/choose-files.browser-harness.tsx +4 -1
  30. package/src/browser/file-chooser-messages.ts +4 -0
  31. package/src/browser/file-providers.ts +14 -1
  32. package/src/browser/provider-folder.tsx +58 -5
  33. package/src/browser/resource-picker.tsx +5 -1
  34. package/src/browser/shell-focus.ts +67 -0
  35. package/src/cli/admin/workflows.ts +1 -0
  36. package/src/cli/commands.ts +2 -0
  37. package/src/cli/plugin.ts +5 -5
  38. package/src/config/env.ts +14 -0
  39. package/src/contracts/file-provider.ts +48 -1
  40. package/src/contracts/outgoing-mail.ts +8 -1
  41. package/src/server/help.ts +136 -10
  42. package/src/server/services/geo.ts +21 -4
  43. package/src/services/allowed-network-fetch.ts +189 -0
  44. package/src/services/help/index.ts +46 -5
  45. package/src/services/help/store.ts +26 -5
  46. package/src/services/help/types.ts +10 -2
  47. package/src/services/index.ts +2 -0
  48. package/src/services/network-security.ts +28 -0
  49. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  50. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  51. package/src/services/outgoing-mail/test-send.ts +18 -3
  52. package/src/services/outgoing-mail/transport.ts +24 -13
  53. package/src/services/settings/index.ts +48 -26
  54. package/src/shared/help.ts +41 -0
  55. package/src/shared/index.ts +9 -1
  56. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  57. package/src/shared/markdown/formula.ts +8 -8
  58. package/src/shared/markdown/index.ts +72 -11
  59. package/src/shared/network-address.ts +9 -0
  60. package/src/ssr/CloudShell.island.tsx +2 -0
  61. package/src/ssr/GlobalSearchTrigger.tsx +1 -1
  62. package/src/ssr/Layout.tsx +28 -2
  63. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  64. package/src/ssr/LayoutHelp.tsx +569 -410
  65. package/src/ssr/LayoutHelpTrigger.tsx +1 -1
  66. package/src/ssr/LayoutRail.tsx +1 -1
  67. package/src/ssr/ProfilePreferences.tsx +1 -1
  68. package/src/ssr/RailApps.tsx +1 -1
  69. package/src/ssr/ShellFocusToggle.tsx +43 -0
  70. package/src/ssr/ShellRuntime.island.tsx +53 -26
  71. package/src/ssr/app-launchpad.tsx +1 -1
  72. package/src/ssr/help-messages.ts +60 -38
  73. package/src/ssr/help.ts +3 -0
  74. package/src/ssr/layout-help-article.ts +41 -0
  75. package/src/ssr/layout-help-navigation.ts +0 -12
  76. package/src/ssr/layout-help-registry.tsx +4 -11
  77. package/src/ssr/layout-help-search.ts +9 -12
  78. package/src/ssr/layout-help-start.ts +68 -0
  79. package/src/ssr/platform-messages.ts +14 -0
  80. package/src/ssr/shell-data.tsx +7 -0
  81. package/src/ssr/shell-focus.browser-harness.tsx +83 -0
  82. package/src/ssr/shell-shortcuts.ts +8 -0
  83. package/src/styles/effects.css +688 -185
  84. package/src/styles/global.css +155 -26
  85. package/src/styles/resource-search.css +25 -0
  86. package/src/styles/utilities-navigation.css +11 -3
  87. package/src/workflows/store/events.ts +24 -0
  88. package/src/workflows/store/index.ts +8 -0
  89. package/src/workflows/store/observability.ts +6 -1
  90. package/src/workflows/store/retention.ts +157 -0
@@ -527,15 +527,38 @@ export type CloudAiChartColumn = { id: string; label: string; numeric: boolean }
527
527
  export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
528
528
  export type CloudAiChartTable = { columns: CloudAiChartColumn[]; rows: CloudAiChartRow[] };
529
529
 
530
+ /** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
531
+ export function cloudAiChartTooltip(
532
+ columns: CloudAiChartColumn[],
533
+ row: CloudAiChartRow,
534
+ ): { title?: string; rows: { label: string; value: string }[] } {
535
+ const [first, ...rest] = columns;
536
+ const named = first && !first.numeric;
537
+ return {
538
+ title: named ? row.cells[first.id] : undefined,
539
+ rows: (named ? rest : columns)
540
+ .filter((column) => column.id in row.cells)
541
+ .map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
542
+ };
543
+ }
544
+
530
545
  /**
531
546
  * Builds the data table mark by mark, in the order the renderer emits them, so the chat block fills it from the same
532
547
  * drawing that shows the chart.
533
548
  */
534
- export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
549
+ export function cloudAiChartRows(
550
+ input: CloudAiChartInput,
551
+ locale: string,
552
+ options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
553
+ ) {
535
554
  const t = labels.resolve([locale]).t;
536
555
  const format = formats(input, locale);
556
+ const valueFormat = input.tooltip?.format
557
+ ? cloudAiNumberFormatters(input.tooltip.format, locale).value
558
+ : (options.formats?.value ?? (input.kind === "histogram" ? undefined : options.formats?.y) ?? format.value);
559
+ const xFormat = options.formats?.x ?? format.x.value;
537
560
  const series = seriesOf(input);
538
- const countFormat = cloudAiNumberFormatters(undefined, locale).value;
561
+ const countFormat = (input.kind === "histogram" ? options.formats?.y : undefined) ?? cloudAiNumberFormatters(undefined, locale).value;
539
562
  const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
540
563
  const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
541
564
  const columns = new Map<string, CloudAiChartColumn>();
@@ -575,26 +598,34 @@ export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
575
598
  "x",
576
599
  input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
577
600
  value,
578
- input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : format.x.value(value),
601
+ input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : xFormat(value),
579
602
  );
580
603
  } else if (field.key === "y") {
581
604
  const header =
605
+ input.tooltip?.label ??
582
606
  (input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
583
607
  (input.kind === "sparkline" || input.kind === "stat" ? t.value : "Y");
584
- cell("y", header, value, format.value(value));
608
+ cell("y", header, value, valueFormat(value));
585
609
  } else if (field.key === "value")
586
- cell("value", (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value, value, `${format.value(value)}${unit}`);
587
- else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${format.value(value)}`);
588
- else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
589
- cell(field.key, t[field.key], value, format.value(value));
590
- else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
591
- else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max")
592
610
  cell(
593
- field.key,
594
- t[field.key],
611
+ "value",
612
+ input.tooltip?.label ?? (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value,
595
613
  value,
596
- `${field.key === "count" ? countFormat(value) : format.value(value)}${field.key === "min" || field.key === "max" ? unit : ""}`,
614
+ `${valueFormat(value)}${unit}`,
597
615
  );
616
+ else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${valueFormat(value)}`);
617
+ else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
618
+ cell(field.key, t[field.key], value, valueFormat(value));
619
+ else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
620
+ else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max") {
621
+ const fieldFormat =
622
+ field.key === "count"
623
+ ? countFormat
624
+ : input.kind === "histogram" && (field.key === "from" || field.key === "to") && options.formats?.x
625
+ ? xFormat
626
+ : valueFormat;
627
+ cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
628
+ }
598
629
  }
599
630
  const row = { key: cloudAiChartMarkKey(datum), cells, values };
600
631
  rows.push(row);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Root scheme-less Cloud resource links in sanitized Assistant answer HTML.
3
+ * Normalized links stay root-relative under /app/ or /open/ and can never change origin.
4
+ */
5
+ export function rootResourceLinks(html: string): string {
6
+ // sanitize-html emits double-quoted attributes with ", <, and > escaped in values.
7
+ return html.replace(/<a\b[^>]*>/g, (tag) => tag.replace(/(\shref=")((?:app|open)\/)/g, "$1/$2"));
8
+ }
@@ -9,7 +9,6 @@ import {
9
9
  } from "@k2b/ui";
10
10
  import { createMemo, createUniqueId, Index, type JSX, Show } from "solid-js";
11
11
  import {
12
- type CloudAiChartColumn,
13
12
  type CloudAiChartRow,
14
13
  type CloudAiChartTable,
15
14
  cloudAiChartLegend,
@@ -17,21 +16,13 @@ import {
17
16
  cloudAiChartReferenceMark,
18
17
  cloudAiChartRenderOptions,
19
18
  cloudAiChartRows,
19
+ cloudAiChartTooltip,
20
20
  cloudAiChartXValueFormat,
21
21
  } from "../chart-block";
22
22
  import type { CloudAiChartInput } from "../default-tool-contracts";
23
23
  import { type CloudAiGroupView, parseCloudAiChartToolView } from "../view-block";
24
24
  import { aiChatMessages } from "./messages";
25
25
 
26
- const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
27
- const [first, ...rest] = columns;
28
- const named = first && !first.numeric;
29
- return {
30
- title: named ? row.cells[first.id] : undefined,
31
- rows: (named ? rest : columns).map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
32
- };
33
- };
34
-
35
26
  type Kind = CloudAiChartInput["kind"];
36
27
  type DrawnChart = { input: CloudAiChartInput; table: CloudAiChartTable; snapshot: ChartSnapshot; legend: ChartLegendEntry[] };
37
28
 
@@ -44,7 +35,7 @@ const drawChart = (input: CloudAiChartInput, locale: string): DrawnChart | null
44
35
  key: ({ datum }) => cloudAiChartMarkKey(datum),
45
36
  tooltip: ({ datum }) => {
46
37
  const row = rows.add(datum);
47
- return tooltip(rows.columns(), row);
38
+ return cloudAiChartTooltip(rows.columns(), row);
48
39
  },
49
40
  reference: ({ datum }) => cloudAiChartReferenceMark(input, datum),
50
41
  });
@@ -34,6 +34,7 @@ const messages = i18n.define({
34
34
  turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
35
35
  if (code === "model_unavailable") return "The model service did not answer.";
36
36
  if (code === "provider_stopped") return "The model provider ended this answer early.";
37
+ if (code === "tool_failed") return "A step failed, and the model stopped without an answer.";
37
38
  if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
38
39
  if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
39
40
  if (code === "time_limit")
@@ -136,6 +137,11 @@ const messages = i18n.define({
136
137
  revokeFailed: "Could not revoke. Try again.",
137
138
  revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
138
139
  websiteRevoked: "Revoked. The next request asks again.",
140
+ fetchingFile: ({ name }: { name: string }) => `Fetching ${name}`,
141
+ fetchedFile: ({ name }: { name: string }) => `Fetched ${name}`,
142
+ fetchSource: "Source",
143
+ fetchSavedAs: "Saved as",
144
+ fetchReadableCopy: "Readable copy",
139
145
  details: "Details",
140
146
  hideDetails: "Hide details",
141
147
  moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
@@ -336,6 +342,7 @@ const messages = i18n.define({
336
342
  turnErrorReason: ({ code, limitMinutes }) => {
337
343
  if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
338
344
  if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
345
+ if (code === "tool_failed") return "Ein Schritt ist fehlgeschlagen, und das Modell hat danach nicht geantwortet.";
339
346
  if (code === "quota_exhausted")
340
347
  return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
341
348
  if (code === "context_full")
@@ -444,6 +451,11 @@ const messages = i18n.define({
444
451
  revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
445
452
  revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
446
453
  websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
454
+ fetchingFile: ({ name }) => `Lädt herunter: ${name}`,
455
+ fetchedFile: ({ name }) => `Heruntergeladen: ${name}`,
456
+ fetchSource: "Quelle",
457
+ fetchSavedAs: "Gespeichert als",
458
+ fetchReadableCopy: "Lesbare Kopie",
447
459
  details: "Details",
448
460
  hideDetails: "Details ausblenden",
449
461
  moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
@@ -1,4 +1,5 @@
1
1
  import { createEffect, onCleanup, onMount } from "solid-js";
2
+ import { rootResourceLinks } from "./answer-links";
2
3
  import { useAiChatActions } from "./message-actions";
3
4
 
4
5
  export function AssistantMarkdownBlock(props: { html: string }) {
@@ -41,5 +42,5 @@ export function AssistantMarkdownBlock(props: { html: string }) {
41
42
  element.addEventListener("click", handleClick);
42
43
  onCleanup(() => element.removeEventListener("click", handleClick));
43
44
  });
44
- return <div ref={element} class="assistant-markdown-block" innerHTML={props.html} />;
45
+ return <div ref={element} class="assistant-markdown-block" innerHTML={rootResourceLinks(props.html)} />;
45
46
  }
@@ -136,15 +136,22 @@ export function WebExtractToolBlock(props: { block: ToolBlock }) {
136
136
  );
137
137
  }
138
138
 
139
- type FetchFileResult = { path: string; size: number; mediaType: string; url: string };
139
+ type FetchFileResult = { path: string; size: number; mediaType: string; url: string; readablePath?: string };
140
140
 
141
+ /** Results stored before readable copies existed have no `readable` field and still render. */
141
142
  const fetchFileResult = (value: unknown): FetchFileResult | null =>
142
143
  isRecord(value) &&
143
144
  typeof value.path === "string" &&
144
145
  typeof value.size === "number" &&
145
146
  typeof value.mediaType === "string" &&
146
147
  typeof value.url === "string"
147
- ? { path: value.path, size: value.size, mediaType: value.mediaType, url: value.url }
148
+ ? {
149
+ path: value.path,
150
+ size: value.size,
151
+ mediaType: value.mediaType,
152
+ url: value.url,
153
+ ...(isRecord(value.readable) && typeof value.readable.path === "string" ? { readablePath: value.readable.path } : {}),
154
+ }
148
155
  : null;
149
156
 
150
157
  const filenameOf = (value: string): string => {
@@ -158,6 +165,8 @@ const filenameOf = (value: string): string => {
158
165
 
159
166
  /** Imported web file: source identity, useful metadata, and chat path without raw tool JSON. */
160
167
  export function FetchFileToolBlock(props: { block: ToolBlock }) {
168
+ const locale = useLocale();
169
+ const t = () => aiChatMessages(locale());
161
170
  const sourceUrl = () => (isRecord(props.block.args) && typeof props.block.args.url === "string" ? props.block.args.url : "");
162
171
  const result = () => fetchFileResult(props.block.result);
163
172
  const displayName = () => filenameOf(result()?.path ?? sourceUrl()) || "file";
@@ -172,7 +181,7 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
172
181
  when={props.block.status === "completed" && result()}
173
182
  fallback={
174
183
  <Chat.Activity
175
- label={`Fetching ${displayName()}`}
184
+ label={t().fetchingFile({ name: displayName() })}
176
185
  description={domainOf(sourceUrl())}
177
186
  leading={<Favicon url={sourceUrl()} fallbackIcon="ti ti-world-download" />}
178
187
  busy
@@ -183,19 +192,27 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
183
192
  <AiToolActivity
184
193
  blockId={props.block.id}
185
194
  leading={<Favicon url={sourceUrl() || file().url} fallbackIcon="ti ti-world-download" />}
186
- label={`Fetched ${displayName()}`}
195
+ label={t().fetchedFile({ name: displayName() })}
187
196
  description={description()}
188
197
  bodyInset={false}
189
198
  >
190
199
  <dl class="grid w-full min-w-0 grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
191
- <dt class="font-medium text-dimmed">Source</dt>
200
+ <dt class="font-medium text-dimmed">{t().fetchSource}</dt>
192
201
  <dd class="min-w-0 truncate">
193
202
  <a href={file().url} target="_blank" rel="noreferrer noopener" class="text-secondary hover:text-primary">
194
203
  {file().url}
195
204
  </a>
196
205
  </dd>
197
- <dt class="font-medium text-dimmed">Saved as</dt>
206
+ <dt class="font-medium text-dimmed">{t().fetchSavedAs}</dt>
198
207
  <dd class="min-w-0 truncate text-secondary">{file().path}</dd>
208
+ <Show when={file().readablePath}>
209
+ {(readablePath) => (
210
+ <>
211
+ <dt class="font-medium text-dimmed">{t().fetchReadableCopy}</dt>
212
+ <dd class="min-w-0 truncate text-secondary">{readablePath()}</dd>
213
+ </>
214
+ )}
215
+ </Show>
199
216
  </dl>
200
217
  </AiToolActivity>
201
218
  )}
@@ -16,11 +16,15 @@ import {
16
16
  updateMandatePolicy,
17
17
  validateMandateIssueAuthority,
18
18
  } from "../services/mandates";
19
- import { parsePgJsonValue } from "../services/postgres";
19
+ import { parsePgJsonValue, toPgUuidArray } from "../services/postgres";
20
20
  import { withAiShortIdForDb } from "./short-id";
21
21
  import { aiConversations } from "./store";
22
22
  import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
23
23
 
24
+ /** Run history and transcripts are operational evidence; 90 days matches capability execution history.
25
+ * Fixed platform constant, not configurable. */
26
+ export const AI_CHAT_TASK_RUN_RETENTION_DAYS = 90;
27
+
24
28
  export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
25
29
  export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
26
30
  export type AiChatTask = {
@@ -914,6 +918,66 @@ export const aiChatTasks = {
914
918
  return occurrences;
915
919
  },
916
920
 
921
+ /** Removes expired runs and their evidence, preserving current once slot rows as dedupe guards. */
922
+ pruneExpiredRuns: async (input: { now?: Date; limit?: number } = {}): Promise<number> => {
923
+ const cutoff = new Date((input.now ?? new Date()).getTime() - AI_CHAT_TASK_RUN_RETENTION_DAYS * 86_400_000);
924
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
925
+ return sql.begin(async (tx) => {
926
+ // Keep the current once slot row as a dedupe guard while its turn and transcript expire.
927
+ const occurrences = await tx<{ id: string; turn_id: string | null; keep: boolean }[]>`
928
+ SELECT occurrence.id, occurrence.turn_id, EXISTS (
929
+ SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
930
+ AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
931
+ ) AS keep
932
+ FROM ai.chat_task_occurrences occurrence
933
+ WHERE occurrence.state IN ('completed', 'failed') AND occurrence.completed_at < ${cutoff}
934
+ AND (occurrence.turn_id IS NOT NULL OR NOT EXISTS (
935
+ SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
936
+ AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
937
+ ))
938
+ AND NOT EXISTS (
939
+ SELECT 1 FROM ai.turns turn WHERE turn.id = occurrence.turn_id
940
+ AND turn.status NOT IN ('completed', 'failed', 'aborted')
941
+ )
942
+ ORDER BY occurrence.completed_at, occurrence.id
943
+ LIMIT ${limit}
944
+ FOR UPDATE OF occurrence SKIP LOCKED
945
+ `;
946
+ if (occurrences.length > 0) {
947
+ const occurrenceIds = toPgUuidArray(occurrences.filter((occurrence) => !occurrence.keep).map((occurrence) => occurrence.id));
948
+ const turnIds = toPgUuidArray(occurrences.flatMap((occurrence) => (occurrence.turn_id ? [occurrence.turn_id] : [])));
949
+ await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${turnIds}::text[])`;
950
+ await tx`
951
+ DELETE FROM ai.turns WHERE id = ANY(${turnIds}::uuid[])
952
+ AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
953
+ `;
954
+ await tx`DELETE FROM ai.chat_task_occurrences WHERE id = ANY(${occurrenceIds}::uuid[])`;
955
+ }
956
+ const remaining = limit - occurrences.length;
957
+ if (remaining === 0) return occurrences.length;
958
+ const orphanedTurns = await tx<{ id: string }[]>`
959
+ SELECT turn.id FROM ai.turns turn
960
+ WHERE turn.run_config ? 'background' AND turn.status IN ('completed', 'failed', 'aborted')
961
+ AND turn.completed_at < ${cutoff}
962
+ AND NOT EXISTS (
963
+ SELECT 1 FROM ai.chat_task_occurrences occurrence WHERE occurrence.turn_id = turn.id
964
+ )
965
+ ORDER BY turn.completed_at, turn.id
966
+ LIMIT ${remaining}
967
+ FOR UPDATE OF turn SKIP LOCKED
968
+ `;
969
+ if (orphanedTurns.length === 0) return occurrences.length;
970
+ const orphanedTurnIds = toPgUuidArray(orphanedTurns.map((turn) => turn.id));
971
+ await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${orphanedTurnIds}::text[])`;
972
+ const removedTurns = await tx<{ id: string }[]>`
973
+ DELETE FROM ai.turns WHERE id = ANY(${orphanedTurnIds}::uuid[])
974
+ AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
975
+ RETURNING id
976
+ `;
977
+ return occurrences.length + removedTurns.length;
978
+ });
979
+ },
980
+
917
981
  listQueuedOccurrences: async (limit = 100): Promise<Array<{ occurrence: AiChatTaskOccurrence; task: AiChatTask }>> => {
918
982
  const rows = await sql<(OccurrenceRow & TaskRow)[]>`
919
983
  SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
@@ -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([
@@ -23,6 +23,7 @@ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
23
23
  import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
24
24
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
25
25
  import { aiChatMessages } from "./chat/messages";
26
+ import { isFailedTool } from "./chat/tool-groups";
26
27
  import { aiTurnErrorText } from "./chat/turn-error";
27
28
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
28
29
  import { createCloudCompactFn } from "./compaction";
@@ -134,6 +135,17 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
134
135
  };
135
136
  };
136
137
 
138
+ const lastToolRoundFailed = (messages: AiStoredMessage[], blocks: AiTurnBlock[]): boolean => {
139
+ const latest = messages.findLast(({ message }) => message.role === "assistant")?.message;
140
+ if (latest?.role !== "assistant") return false;
141
+ const callIds = new Set(latest.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
142
+ // A worker may have stored a malformed call but crashed before its error result; load repairs that result without an event.
143
+ return (
144
+ [...callIds].some(isMalformedToolCallId) ||
145
+ blocks.some((block) => block.kind === "tool" && callIds.has(block.callId) && isFailedTool(block))
146
+ );
147
+ };
148
+
137
149
  const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
138
150
  try {
139
151
  await aiConversations.indexConversationResources(input);
@@ -1500,6 +1512,7 @@ export class AiTurnExecutor {
1500
1512
  const outcome = await this.driveChatLoop({
1501
1513
  loop,
1502
1514
  pipeline,
1515
+ initialRoundFailed: lastToolRoundFailed(turnMessages, turnBlocks),
1503
1516
  conversationId,
1504
1517
  turnId,
1505
1518
  abortController,
@@ -1572,6 +1585,7 @@ export class AiTurnExecutor {
1572
1585
  private async driveChatLoop(input: {
1573
1586
  loop: NessiLoop;
1574
1587
  pipeline: StreamPipeline;
1588
+ initialRoundFailed: boolean;
1575
1589
  conversationId: string;
1576
1590
  turnId: string;
1577
1591
  abortController: AbortController;
@@ -1613,6 +1627,10 @@ export class AiTurnExecutor {
1613
1627
  } = input;
1614
1628
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1615
1629
  let lastIssueMessage: string | null = null;
1630
+ let lastResponse: AssistantMessage | undefined;
1631
+ let roundFailed = input.initialRoundFailed;
1632
+ let followedFailedRound = false;
1633
+ const malformedCallIds = new Set<string>();
1616
1634
 
1617
1635
  try {
1618
1636
  for await (const event of loop) {
@@ -1672,6 +1690,7 @@ export class AiTurnExecutor {
1672
1690
  .noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
1673
1691
  .catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
1674
1692
  const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
1693
+ if (toolBlock?.kind === "tool" && isFailedTool(toolBlock)) roundFailed = true;
1675
1694
  // The policy keys calls by the name the model called, as the persisted calls it seeds from.
1676
1695
  noteToolCall(
1677
1696
  toolBlock?.kind === "tool"
@@ -1687,20 +1706,44 @@ export class AiTurnExecutor {
1687
1706
  result: event.result,
1688
1707
  isError: event.isError === true,
1689
1708
  });
1690
- } else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
1691
- noteToolRound();
1709
+ } else if (event.type === "turn_end") {
1710
+ lastResponse = event.message;
1711
+ followedFailedRound = roundFailed;
1712
+ const calls = new Set(event.message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
1713
+ // nessi closes a round after executing its tools. Malformed calls have stored failed blocks,
1714
+ // but are added by Cloud's store wrapper, so they are absent from the event's message.
1715
+ roundFailed = pipeline.blocks.some(
1716
+ (block) => block.kind === "tool" && (calls.has(block.callId) || malformedCallIds.has(block.callId)) && isFailedTool(block),
1717
+ );
1718
+ malformedCallIds.clear();
1719
+ if (!abortController.signal.aborted && isToolRound(event.message)) noteToolRound();
1692
1720
  } else if (event.type === "issue") {
1721
+ if (event.issue.kind === "malformed_tool_call" && event.issue.callId) malformedCallIds.add(event.issue.callId);
1693
1722
  lastIssueMessage = event.issue.message;
1694
1723
  log.warn("AI turn issue", { conversationId, turnId, kind: event.issue.kind, message: event.issue.message });
1695
1724
  } else if (event.type === "loop_end") {
1725
+ const stoppedAfterFailure =
1726
+ event.reason === "stop" &&
1727
+ !abortController.signal.aborted &&
1728
+ followedFailedRound &&
1729
+ lastResponse !== undefined &&
1730
+ !lastResponse.content.some((block) => block.type === "tool_call" || (block.type === "text" && block.text.trim() !== ""));
1731
+ const doneReason = stoppedAfterFailure ? "error" : event.reason;
1696
1732
  const aggregate = await withDurableTurnTiming(turnId, event.aggregate);
1697
1733
  if (aggregate.assistantMessageCount > 0) {
1698
1734
  await aiConversations
1699
- .setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason: event.reason })
1735
+ .setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason })
1700
1736
  .catch(() => undefined);
1701
1737
  }
1702
1738
  const timing = aggregate.timing;
1703
1739
  if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
1740
+ if (stoppedAfterFailure)
1741
+ return {
1742
+ kind: "finished",
1743
+ status: "failed",
1744
+ failure: { error: { code: "tool_failed" }, detail: "The model stopped without an answer after a failed tool call." },
1745
+ timing,
1746
+ };
1704
1747
  if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
1705
1748
  const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
1706
1749
  const reason: AiTurnFailureReason =