@k2b/cloud 0.38.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 (99) hide show
  1. package/package.json +6 -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/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -1,6 +1,6 @@
1
1
  import { mutation } from "@k2b/stdlib/solid";
2
2
  import { announce, IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
3
- import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
3
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
4
4
  import { createStore, reconcile } from "solid-js/store";
5
5
  import { CloudAvatar } from "../account/Avatar";
6
6
  import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
@@ -27,9 +27,9 @@ export type GrantableLevel = Exclude<PermissionLevel, "none">;
27
27
  export type AllowedLevel = GrantableLevel | { level: GrantableLevel; scope?: string; label?: string; icon?: string; description?: string };
28
28
 
29
29
  type PermissionEditorProps = {
30
- /** Initial access entries — caller stays the source of truth for
31
- * what's stored on the resource; the editor updates its local copy
32
- * after successful mutations. */
30
+ /** The editor mounts from this list and merges later lists by entry id once no change is saving.
31
+ * Rows the caller changed since its last list win; unchanged rows keep the editor's confirmed result.
32
+ * Keep the editor mounted (no keyed Show or unmount during refresh) and pass refetched data. */
33
33
  initialEntries: AccessEntry[];
34
34
 
35
35
  /** Whether the current user can edit permissions. When `false`, the
@@ -179,7 +179,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
179
179
  const t = () => accessMessages.resolve([locale()]).t;
180
180
  // A store keeps each row's identity across level changes, so an expanded member list stays open. It holds
181
181
  // copies: store writes go into the objects themselves, and the caller's entries are not the editor's to change.
182
- const [entries, setEntries] = createStore<AccessEntry[]>(props.initialEntries.map((entry) => ({ ...entry })));
182
+ const initialEntries = props.initialEntries;
183
+ let appliedCallerEntries = new Map(initialEntries.map((entry) => [entry.id, JSON.stringify(entry)]));
184
+ const [entries, setEntries] = createStore<AccessEntry[]>(initialEntries.map((entry) => ({ ...entry })));
183
185
  const [pending, setPending] = createSignal<string | null>(null);
184
186
  const grantPending = () => pending() !== null;
185
187
  let disposed = false;
@@ -281,6 +283,29 @@ export default function PermissionEditor(props: PermissionEditorProps) {
281
283
  },
282
284
  });
283
285
  const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
286
+ createEffect(() => {
287
+ const next = props.initialEntries;
288
+ const nextCallerEntries = new Map(next.map((entry) => [entry.id, JSON.stringify(entry)]));
289
+ // Mutation loading clears before onSuccess; wait for its local writes and onFinally too.
290
+ if (busy() || updating() !== null || revoking() !== null) return;
291
+ if (
292
+ nextCallerEntries.size === appliedCallerEntries.size &&
293
+ next.every((entry) => nextCallerEntries.get(entry.id) === appliedCallerEntries.get(entry.id))
294
+ )
295
+ return;
296
+ // Rows the caller changed since its last list win; unchanged rows keep the editor's confirmed result.
297
+ const localEntries = new Map(entries.map((entry) => [entry.id, entry]));
298
+ const merged: AccessEntry[] = [];
299
+ for (const entry of next) {
300
+ const row = nextCallerEntries.get(entry.id) !== appliedCallerEntries.get(entry.id) ? entry : localEntries.get(entry.id);
301
+ if (row) merged.push({ ...row });
302
+ }
303
+ for (const entry of entries) {
304
+ if (!nextCallerEntries.has(entry.id) && !appliedCallerEntries.has(entry.id)) merged.push({ ...entry });
305
+ }
306
+ setEntries(reconcile(merged, { key: "id" }));
307
+ appliedCallerEntries = nextCallerEntries;
308
+ });
284
309
  // The service refuses to remove the last manager; the row says so up front.
285
310
  const lastManagerId = createMemo(() => {
286
311
  // A grant on part of the resource never manages it.
@@ -176,7 +176,11 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
176
176
  const createMutation = mutations.create<{ credential: ResourceApiKey; token: string }, CreateResourceApiKeyInput>({
177
177
  mutation: props.createKey,
178
178
  onSuccess: async (data) => {
179
- setKeys([data.credential, ...keys()]);
179
+ setKeys((current) =>
180
+ current.some((key) => key.id === data.credential.id)
181
+ ? current.map((key) => (key.id === data.credential.id ? data.credential : key))
182
+ : [data.credential, ...current],
183
+ );
180
184
  await prompts.dialog<void>(() => <TokenDialog token={data.token} />, {
181
185
  title: t().keyCreated,
182
186
  icon: "ti ti-key",
@@ -56,7 +56,7 @@ type AiApprovalRouteDependencies = {
56
56
  limit?: MiddlewareHandler<AuthContext>;
57
57
  authenticate?: MiddlewareHandler<AuthContext>;
58
58
  listPreferences?: (actorUserId: string, options: { conversationId?: string }) => Promise<AiToolApprovalPreference[]>;
59
- revokePreference?: (actorUserId: string, preferenceId: string) => Promise<boolean>;
59
+ revokePreference?: typeof revokeAiToolApprovalPreference;
60
60
  listCapabilities?: () => Promise<CapabilityRegistryEntry[]>;
61
61
  /** Resolves a chat the user owns by its ID or short ID. */
62
62
  findConversation?: (ownerUserId: string, conversation: string) => Promise<{ id: string } | null>;
@@ -157,7 +157,10 @@ export const createAiApprovalPreferenceRoutes = (dependencies: AiApprovalRouteDe
157
157
  v("param", AiApprovalPreferenceParamSchema),
158
158
  async (c) => {
159
159
  const user = expectUserBackedActor(c);
160
- const deleted = await revokePreference(user.id, c.req.valid("param").preferenceId);
160
+ const deleted = await revokePreference(user.id, c.req.valid("param").preferenceId, {
161
+ actor: c.get("actor"),
162
+ requestId: c.req.header("x-request-id") ?? null,
163
+ });
161
164
  return deleted ? c.json({ deleted: true as const }) : c.json({ message: "Approval not found", code: "NOT_FOUND" }, 404);
162
165
  },
163
166
  );
@@ -1,4 +1,8 @@
1
- import { sql } from "bun";
1
+ import { type SQL, sql } from "bun";
2
+ import type { AccessSubject } from "../contracts/shared";
3
+ import type { RequestActor } from "../server/middleware/auth";
4
+ import { type AuditActor, type AuditRecordParams, audit } from "../services/audit";
5
+ import { toPgTextArray } from "../services/postgres";
2
6
  import type { AiToolApprovalPolicy, AiTurnRunConfig } from "./types";
3
7
 
4
8
  /** Background authority never inherits a user's interactive Always Allow preference. */
@@ -31,6 +35,16 @@ export type AiToolApprovalContext = {
31
35
  actorUserId: string;
32
36
  };
33
37
 
38
+ /**
39
+ * Request identity for approval changes; never persist this context in a turn run config.
40
+ * When actor is absent, audit uses { userId: actorUserId } for the approval's owner.
41
+ * The access subject is derived from actor, or falls back to { type: "user", userId: actorUserId }.
42
+ */
43
+ export type AiToolApprovalChangeContext = AiToolApprovalContext & {
44
+ actor?: RequestActor;
45
+ requestId?: string | null;
46
+ };
47
+
34
48
  /** Where a remembered approval applies: in one chat until it is deleted, or always. */
35
49
  export type AiToolApprovalRemember = "chat" | "always";
36
50
 
@@ -55,6 +69,59 @@ type AiToolApprovalPreferenceRow = {
55
69
  expires_at: Date | string | null;
56
70
  };
57
71
 
72
+ type ApprovalAuditRow = Pick<AiToolApprovalPreferenceRow, "id" | "tool_name" | "approval_scope" | "conversation_id">;
73
+ type ApprovalAuditIdentity = {
74
+ actor: AuditActor | null;
75
+ accessSubject: AccessSubject | null;
76
+ serviceAccountId?: string;
77
+ requestId?: string | null;
78
+ reason?: string;
79
+ };
80
+
81
+ const approvalAuditIdentity = (context: AiToolApprovalChangeContext): ApprovalAuditIdentity => {
82
+ const user = context.actor?.kind === "user" ? context.actor.user : context.actor?.delegatedUser;
83
+ return {
84
+ actor: context.actor
85
+ ? user
86
+ ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles }
87
+ : null
88
+ : { userId: context.actorUserId },
89
+ // Match aiChatAccessSubject locally: importing assistant-models creates a cycle through firecrawl-tools.
90
+ accessSubject: user
91
+ ? {
92
+ type: "user",
93
+ userId: user.id,
94
+ ...(context.actor?.kind === "service_account" ? { delegatedByServiceAccountId: context.actor.serviceAccount.id } : {}),
95
+ }
96
+ : context.actor?.kind === "service_account"
97
+ ? { type: "service_account", serviceAccountId: context.actor.serviceAccount.id }
98
+ : { type: "user", userId: context.actorUserId },
99
+ ...(context.actor?.kind === "service_account" ? { serviceAccountId: context.actor.serviceAccount.id } : {}),
100
+ requestId: context.requestId,
101
+ };
102
+ };
103
+
104
+ const approvalAuditEvent = (
105
+ action: "ai.approval.remember" | "ai.approval.revoke",
106
+ row: ApprovalAuditRow,
107
+ identity: ApprovalAuditIdentity,
108
+ ): AuditRecordParams => ({
109
+ action,
110
+ outcome: "allowed",
111
+ actor: identity.actor,
112
+ target: { type: "ai_approval", id: row.id, label: row.tool_name },
113
+ requestId: identity.requestId,
114
+ reason: identity.reason,
115
+ metadata: {
116
+ toolName: row.tool_name,
117
+ approvalScope: row.approval_scope,
118
+ remember: row.conversation_id !== null ? "chat" : "always",
119
+ ...(row.conversation_id !== null ? { conversationId: row.conversation_id } : {}),
120
+ accessSubject: identity.accessSubject,
121
+ ...(identity.serviceAccountId ? { serviceAccountId: identity.serviceAccountId } : {}),
122
+ },
123
+ });
124
+
58
125
  export const aiToolApprovalScope = (toolName: string, policy: AiToolApprovalPolicy | undefined): string => {
59
126
  if (policy && typeof policy === "object") return policy.scope ?? toolName;
60
127
  return toolName;
@@ -100,20 +167,12 @@ export const hasRememberedAiToolApproval = async (
100
167
 
101
168
  /** Remembers an approval everywhere, or in one chat when `conversationId` is set; a chat approval ends with its chat. */
102
169
  export const rememberAiToolApproval = async (
103
- context: AiToolApprovalContext,
170
+ context: AiToolApprovalChangeContext,
104
171
  input: { toolName: string; approvalScope: string; conversationId?: string | null; expiresAt?: Date | null },
105
172
  ): Promise<void> => {
106
173
  const conversationId = input.conversationId ?? null;
107
- await sql.begin(async () => {
108
- await sql`
109
- DELETE FROM ai.tool_approval_preferences
110
- WHERE actor_user_id = ${context.actorUserId}
111
- AND tool_name = ${input.toolName}
112
- AND approval_scope = ${input.approvalScope}
113
- AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
114
- `;
115
-
116
- await sql`
174
+ await sql.begin(async (tx) => {
175
+ const [row] = await tx<{ id: string }[]>`
117
176
  INSERT INTO ai.tool_approval_preferences (
118
177
  actor_user_id,
119
178
  tool_name,
@@ -130,24 +189,46 @@ export const rememberAiToolApproval = async (
130
189
  now(),
131
190
  ${input.expiresAt ?? null}
132
191
  )
192
+ ON CONFLICT (actor_user_id, tool_name, approval_scope, conversation_id)
193
+ DO UPDATE SET
194
+ created_at = EXCLUDED.created_at,
195
+ last_used_at = EXCLUDED.last_used_at,
196
+ expires_at = EXCLUDED.expires_at
197
+ RETURNING id
133
198
  `;
199
+ await audit.record(
200
+ approvalAuditEvent(
201
+ "ai.approval.remember",
202
+ {
203
+ id: row!.id,
204
+ tool_name: input.toolName,
205
+ approval_scope: input.approvalScope,
206
+ conversation_id: conversationId,
207
+ },
208
+ approvalAuditIdentity(context),
209
+ ),
210
+ tx,
211
+ );
134
212
  });
135
213
  };
136
214
 
137
215
  /** Forgets the approval with exactly this reach: everywhere, or in the one chat `conversationId` names. */
138
216
  export const forgetAiToolApproval = async (
139
- context: AiToolApprovalContext,
217
+ context: AiToolApprovalChangeContext,
140
218
  input: { toolName: string; approvalScope: string; conversationId?: string | null },
141
219
  ): Promise<boolean> => {
142
- const rows = await sql<{ id: string }[]>`
143
- DELETE FROM ai.tool_approval_preferences
144
- WHERE actor_user_id = ${context.actorUserId}
145
- AND tool_name = ${input.toolName}
146
- AND approval_scope = ${input.approvalScope}
147
- AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
148
- RETURNING id
149
- `;
150
- return rows.length > 0;
220
+ return sql.begin(async (tx) => {
221
+ const rows = await tx<ApprovalAuditRow[]>`
222
+ DELETE FROM ai.tool_approval_preferences
223
+ WHERE actor_user_id = ${context.actorUserId}
224
+ AND tool_name = ${input.toolName}
225
+ AND approval_scope = ${input.approvalScope}
226
+ AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
227
+ RETURNING id, tool_name, approval_scope, conversation_id
228
+ `;
229
+ for (const row of rows) await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity(context)), tx);
230
+ return rows.length > 0;
231
+ });
151
232
  };
152
233
 
153
234
  const toIsoString = (value: Date | string): string => (value instanceof Date ? value : new Date(value)).toISOString();
@@ -186,12 +267,42 @@ export const listAiToolApprovalPreferences = async (
186
267
  return rows.map(approvalPreferenceFromRow);
187
268
  };
188
269
 
189
- export const revokeAiToolApprovalPreference = async (actorUserId: string, preferenceId: string): Promise<boolean> => {
190
- const rows = await sql<{ id: string }[]>`
270
+ export const revokeAiToolApprovalPreference = async (
271
+ actorUserId: string,
272
+ preferenceId: string,
273
+ change?: { actor?: RequestActor; requestId?: string | null },
274
+ ): Promise<boolean> =>
275
+ sql.begin(async (tx) => {
276
+ const rows = await tx<ApprovalAuditRow[]>`
277
+ DELETE FROM ai.tool_approval_preferences
278
+ WHERE id = ${preferenceId}
279
+ AND actor_user_id = ${actorUserId}
280
+ RETURNING id, tool_name, approval_scope, conversation_id
281
+ `;
282
+ for (const row of rows)
283
+ await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity({ ...change, actorUserId })), tx);
284
+ return rows.length > 0;
285
+ });
286
+
287
+ /** Core migrations supply their transaction so deletion and system audit events commit together. */
288
+ export const revokeAiToolApprovalPreferencesAsSystem = async (
289
+ db: SQL,
290
+ input: { ids: readonly string[]; reason: string },
291
+ ): Promise<number> => {
292
+ if (input.ids.length === 0) return 0;
293
+ const rows = await db<(ApprovalAuditRow & { actor_user_id: string | null })[]>`
191
294
  DELETE FROM ai.tool_approval_preferences
192
- WHERE id = ${preferenceId}
193
- AND actor_user_id = ${actorUserId}
194
- RETURNING id
295
+ WHERE id = ANY(${toPgTextArray([...input.ids])}::uuid[])
296
+ RETURNING id, tool_name, approval_scope, conversation_id, actor_user_id
195
297
  `;
196
- return rows.length > 0;
298
+ for (const row of rows)
299
+ await audit.record(
300
+ approvalAuditEvent("ai.approval.revoke", row, {
301
+ actor: null,
302
+ accessSubject: row.actor_user_id === null ? null : { type: "user", userId: row.actor_user_id },
303
+ reason: input.reason,
304
+ }),
305
+ db,
306
+ );
307
+ return rows.length;
197
308
  };
package/src/ai/browser.ts CHANGED
@@ -189,6 +189,16 @@ export const launchAssistant = async (input: LaunchAssistantInput): Promise<Assi
189
189
  };
190
190
  };
191
191
 
192
+ export {
193
+ CHAT_TASK_REASON_CODES,
194
+ type ChatTaskReason,
195
+ type ChatTaskReasonCode,
196
+ chatTaskLocale,
197
+ chatTaskNeedsNewTime,
198
+ chatTaskReasonSentence,
199
+ chatTaskReasonState,
200
+ chatTaskStopSentence,
201
+ } from "./chat-task-reasons";
192
202
  export {
193
203
  CLOUD_AI_STATS_MAX_ITEMS,
194
204
  CLOUD_AI_TABLE_MAX_COLUMNS,
@@ -1,15 +1,4 @@
1
1
  import { createContext, useContext } from "solid-js";
2
- import {
3
- type CapabilityActionWording,
4
- capabilityActionOutcome,
5
- capabilityActionSubject,
6
- resolveCapabilityActionSentences,
7
- } from "../../_internal/capability-sentences";
8
- import type { AiTurnBlock } from "../protocol";
9
- import { CLOUD_AI_TOOL_PRESENTATION } from "../tool-sentences";
10
- import { displayToolName, isRecord } from "./message-utils";
11
-
12
- type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
13
2
 
14
3
  const ReaderTimeZoneContext = createContext<string>();
15
4
 
@@ -20,36 +9,4 @@ export const AiReaderTimeZoneProvider = ReaderTimeZoneContext.Provider;
20
9
  export const useReaderTimeZone = (): string =>
21
10
  useContext(ReaderTimeZoneContext) ?? (typeof window === "undefined" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone);
22
11
 
23
- /**
24
- * How the chat words one call that acts on something: an app Action through its saved presentation, a
25
- * Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
26
- */
27
- export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
28
- const presentation = block.presentation;
29
- if (presentation?.kind === "capability") {
30
- if (presentation.capabilityKind !== "action") return null;
31
- return {
32
- title: presentation.title,
33
- ...(presentation.sentences ? { sentences: presentation.sentences } : {}),
34
- ...(presentation.fields ? { fields: presentation.fields } : {}),
35
- };
36
- }
37
- const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
38
- return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
39
- };
40
-
41
- /** What the call does or would do, as one sentence; `null` for calls that only read. */
42
- export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
43
- const wording = toolWording(block, locale);
44
- return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
45
- };
46
-
47
- /** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
48
- export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
49
- const wording = toolWording(block, locale);
50
- if (!wording) return null;
51
- const result = isRecord(block.result) ? block.result : undefined;
52
- // App Actions return their data inside the result envelope; Cloud tools return the data itself.
53
- const data = block.presentation?.kind === "capability" ? result?.data : block.result;
54
- return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
55
- };
12
+ export { toolOutcome, toolSubject, toolWording } from "../tool-wording";
@@ -16,6 +16,7 @@ import {
16
16
  untrack,
17
17
  } from "solid-js";
18
18
  import { markdown } from "../../shared";
19
+ import { type ChatTaskReason, chatTaskReasonSentence, chatTaskReasonState, chatTaskStopSentence } from "../chat-task-reasons";
19
20
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
20
21
  import type { AiTurnError } from "../types";
21
22
  import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
@@ -65,8 +66,17 @@ export type AiTurnSegment = {
65
66
  earlier: boolean;
66
67
  /** Read reactively, so a ticking clock does not rebuild the segment. */
67
68
  duration: () => AiTurnDuration | null;
68
- /** Shown above the work of a delivered scheduled result. */
69
- scheduledTask?: { taskId: string; occurrenceId: string } | null;
69
+ /**
70
+ * Shown above the work of a delivered scheduled result. `stop` and `reason` add a system line the chat words in
71
+ * the reader's language; with `stop` or `reasonOnly` that line replaces the stored text.
72
+ */
73
+ scheduledTask?: {
74
+ taskId: string;
75
+ occurrenceId: string;
76
+ reason?: ChatTaskReason;
77
+ reasonOnly?: true;
78
+ stop?: { cause: "paused" | "deleted"; done: string[] };
79
+ } | null;
70
80
  /**
71
81
  * The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
72
82
  * reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
@@ -789,6 +799,7 @@ function AiTurnText(props: {
789
799
  */
790
800
  export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosureState: AiToolDisclosureState }): JSX.Element {
791
801
  const locale = useLocale();
802
+ const timeZone = useReaderTimeZone();
792
803
  const chatActions = useAiChatActions();
793
804
  const layout = () => props.segment().layout;
794
805
  const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
@@ -829,7 +840,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
829
840
  </div>
830
841
  </Show>
831
842
  <For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
832
- <Show when={layout().text.length > 0}>
843
+ <Show when={props.segment().scheduledTask?.stop}>
844
+ {(stop) => (
845
+ <Chat.Activity
846
+ class="ai-turn__routine-line"
847
+ wrap
848
+ icon="ti ti-player-stop"
849
+ label={chatTaskStopSentence(stop().done, locale())}
850
+ />
851
+ )}
852
+ </Show>
853
+ <Show when={layout().text.length > 0 && !props.segment().scheduledTask?.stop && !props.segment().scheduledTask?.reasonOnly}>
833
854
  <AiTurnText
834
855
  blocks={() => layout().text}
835
856
  live={live}
@@ -838,6 +859,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
838
859
  earlier={() => props.segment().earlier}
839
860
  />
840
861
  </Show>
862
+ <Show when={props.segment().scheduledTask?.stop ? undefined : props.segment().scheduledTask?.reason}>
863
+ {(reason) => (
864
+ <Chat.Activity
865
+ class="ai-turn__routine-line"
866
+ wrap
867
+ icon={chatTaskReasonState(reason().code) === "paused" ? "ti ti-player-pause" : "ti ti-alert-triangle"}
868
+ tone={chatTaskReasonState(reason().code) === "paused" ? "neutral" : "warning"}
869
+ label={chatTaskReasonSentence(reason(), { locale: locale(), timeZone })}
870
+ />
871
+ )}
872
+ </Show>
841
873
  <For each={actionIds()}>
842
874
  {(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
843
875
  </For>
@@ -10,7 +10,7 @@ import {
10
10
  useLocale,
11
11
  } from "@k2b/ui";
12
12
  import { createMemo, createSignal, createUniqueId, Index, type JSX, Match, onCleanup, onMount, Show, Switch } from "solid-js";
13
- import type { CloudAiChartRow } from "../chart-block";
13
+ import { type CloudAiChartRow, cloudAiNumberFormatters } from "../chart-block";
14
14
  import {
15
15
  CLOUD_AI_DETAILS_VIEWPORT_ITEMS,
16
16
  CLOUD_AI_VIEW_MAX_BYTES,
@@ -244,12 +244,17 @@ function CloudStatsView(props: { summary: StatsSummary; input: CloudAiStatsView
244
244
  {(value) => (
245
245
  <StatGrid columns={columns()}>
246
246
  <Index each={value()}>
247
- {(tile) => (
247
+ {(tile, index) => (
248
248
  <StatCell
249
249
  label={tile().label}
250
250
  value={tile().value}
251
251
  title={tile().value}
252
252
  trend={tile().sparkline}
253
+ trendTooltip={({ datum }) => {
254
+ const point = tile().sparkline?.[datum.index];
255
+ const format = cloudAiNumberFormatters(props.input?.items[index]?.format, locale()).value;
256
+ return { rows: point === undefined ? [] : [{ label: tile().label, value: format(point) }] };
257
+ }}
253
258
  sub={tile().sub}
254
259
  accent={deltaAccent(tile().delta)}
255
260
  />
@@ -6,14 +6,25 @@ import { dates } from "@k2b/stdlib";
6
6
  import { z } from "zod";
7
7
  import { coreSettings } from "../services";
8
8
  import { normalizeWorkflowSchedule } from "../workflows/runtime";
9
+ import type { ChatTaskReason } from "./chat-task-reasons";
9
10
  import type { AiChatTask, AiChatTaskOccurrence, AiChatTaskOccurrenceState, AiChatTaskSchedule, AiChatTaskState } from "./chat-tasks";
10
11
  import { AI_SHORT_ID_PATTERN } from "./short-id";
11
12
 
12
- export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character scheduled task ID.");
13
- export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character task occurrence ID.");
13
+ export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine ID.");
14
+ export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine run ID.");
14
15
  export const AiConversationIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character AI conversation ID.");
15
16
 
17
+ export const chatTaskName = (task: { title?: string | null; chatTitle?: string | null; prompt: string }): string =>
18
+ task.title?.trim() ||
19
+ task.chatTitle?.trim() ||
20
+ Array.from(task.prompt.split(/\n|:\s/u)[0]!.trim())
21
+ .slice(0, 80)
22
+ .join("");
23
+
16
24
  export type AiChatTaskView = {
25
+ title: string | null;
26
+ name: string;
27
+ reason: ChatTaskReason | null;
17
28
  nextRunAt?: string | null;
18
29
  id: string;
19
30
  chatId: string;
@@ -42,6 +53,9 @@ export type AiChatTaskOccurrenceView = {
42
53
  };
43
54
 
44
55
  export const toAiChatTaskView = (task: AiChatTask): AiChatTaskView => ({
56
+ title: task.title,
57
+ name: task.name,
58
+ reason: task.reasonCode && task.reasonAt ? { code: task.reasonCode, at: task.reasonAt } : null,
45
59
  id: task.shortId,
46
60
  chatId: task.chatId,
47
61
  chatTitle: task.chatTitle,
@@ -93,12 +107,23 @@ export const getChatTaskTimezone = async (): Promise<string> =>
93
107
  export const chatTaskCreateFingerprint = (input: {
94
108
  chatId: string;
95
109
  prompt: string;
110
+ title?: string;
96
111
  schedule: z.infer<typeof ChatTaskScheduleInputSchema>;
97
112
  timezone?: string;
98
113
  grants?: TaskGrant[];
99
114
  }): string =>
100
115
  new Bun.CryptoHasher("sha256")
101
- .update(JSON.stringify([input.chatId, input.prompt.trim(), input.schedule, input.timezone ?? null, input.grants ?? []]))
116
+ .update(
117
+ JSON.stringify([
118
+ input.chatId,
119
+ input.prompt.trim(),
120
+ input.schedule,
121
+ input.timezone ?? null,
122
+ input.grants ?? [],
123
+ // Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
124
+ ...(input.title?.trim() ? [input.title.trim()] : []),
125
+ ]),
126
+ )
102
127
  .digest("hex");
103
128
 
104
129
  export const normalizeChatTaskSchedule = async (
@@ -0,0 +1,68 @@
1
+ import type { SQL } from "bun";
2
+ import { canonicalLocale } from "../shared/locale";
3
+
4
+ /** Called in a transaction; new columns and reason repairs commit together. */
5
+ export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
6
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_code TEXT`.simple();
7
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_at TIMESTAMPTZ`.simple();
8
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS title TEXT`.simple();
9
+ const [column] = await sql`SELECT 1 FROM information_schema.columns
10
+ WHERE table_schema = 'ai' AND table_name = 'chat_tasks' AND column_name = 'locale'`;
11
+ if (!column) {
12
+ await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS locale TEXT`.simple();
13
+ // Data-sized work runs only with the new column. A restart never rescans old turns.
14
+ let cursor: string | null = null;
15
+ while (true) {
16
+ const rows: { id: string; locale: string | null }[] = await sql`
17
+ SELECT task.id, COALESCE(
18
+ (SELECT turn.run_config->>'locale' FROM ai.turns turn
19
+ WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
20
+ AND NOT (turn.run_config ? 'background') AND turn.created_at <= task.created_at
21
+ ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1),
22
+ (SELECT turn.run_config->>'locale' FROM ai.turns turn
23
+ WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
24
+ AND NOT (turn.run_config ? 'background')
25
+ ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1)) AS locale
26
+ FROM ai.chat_tasks task WHERE (${cursor}::uuid IS NULL OR task.id > ${cursor}::uuid)
27
+ ORDER BY task.id LIMIT 500`;
28
+ if (!rows.length) break;
29
+ for (const row of rows) {
30
+ const locale = canonicalLocale(row.locale);
31
+ if (locale) await sql`UPDATE ai.chat_tasks SET locale = ${locale} WHERE id = ${row.id}::uuid`;
32
+ }
33
+ cursor = rows[rows.length - 1]!.id;
34
+ }
35
+ }
36
+ await sql`
37
+ DO $$ BEGIN
38
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_code_check') THEN
39
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_code_check
40
+ CHECK (reason_code IN ('paused', 'chat_archived', 'owner_inactive', 'failed', 'rights_changed', 'needs_approval'));
41
+ END IF;
42
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_title_check') THEN
43
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_title_check CHECK (char_length(btrim(title)) BETWEEN 1 AND 80);
44
+ END IF;
45
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_state_check') THEN
46
+ -- Repair legacy and rollback writes, preserving valid reasons and their timestamps.
47
+ UPDATE ai.chat_tasks SET reason_code = CASE
48
+ WHEN state NOT IN ('paused', 'needs_attention') THEN NULL
49
+ WHEN state = 'paused' THEN 'paused'
50
+ WHEN last_error IN ('Scheduled task mandate revision changed', 'Scheduled task mandate or sponsor is unavailable') THEN 'rights_changed'
51
+ ELSE 'failed' END,
52
+ reason_at = CASE WHEN state IN ('paused', 'needs_attention') THEN updated_at ELSE NULL END
53
+ WHERE NOT (
54
+ (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
55
+ AND (reason_code IS NULL OR
56
+ (state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
57
+ (state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
58
+ );
59
+ ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_state_check CHECK (
60
+ (state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
61
+ AND (reason_code IS NULL OR
62
+ (state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
63
+ (state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
64
+ );
65
+ END IF;
66
+ END $$
67
+ `.simple();
68
+ };