@k2b/cloud 0.30.0 → 0.31.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 (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
package/src/ai/migrate.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { sql } from "bun";
2
+ import { CapabilityActionReviewSchema } from "../contracts/capabilities";
3
+ import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
2
4
  import { migrateAiMessageQueue } from "./message-queue";
3
5
  import { migrateAiModelAccess } from "./model-access-migrate";
4
6
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -21,6 +23,8 @@ export const migrateCloudAi = async (): Promise<void> => {
21
23
  if (!outbox?.ready) {
22
24
  throw new Error("AI live updates write to events.outbox, which does not exist. Run Core's events migration before the AI migration.");
23
25
  }
26
+ // Approval requests reference auth.users, so they follow the check that Core's own migrations ran.
27
+ await migrateApprovals();
24
28
  await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
25
29
  console.log(" ✓ ai schema");
26
30
 
@@ -640,7 +644,11 @@ export const migrateCloudAi = async (): Promise<void> => {
640
644
  PRIMARY KEY (turn_id, request_id)
641
645
  )`.simple();
642
646
 
647
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS approval_id UUID`.simple();
643
648
  await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
649
+ // A code run asks for its nested HTTP requests and Actions; their remembered approval names a website or Action, not the run.
650
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS remember_tool_name TEXT`.simple();
651
+ await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS allow_chat BOOLEAN NOT NULL DEFAULT FALSE`.simple();
644
652
 
645
653
  await sql`
646
654
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_pending_actions_turn_call
@@ -700,13 +708,39 @@ export const migrateCloudAi = async (): Promise<void> => {
700
708
  FROM ai.tool_approval_preferences
701
709
  WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
702
710
  AND NOT (length(tool_name) = 64 AND tool_name ~ '__[0-9a-f]{12}$')
703
- ON CONFLICT (actor_user_id, tool_name, approval_scope) DO NOTHING
711
+ ON CONFLICT DO NOTHING
704
712
  `.simple();
705
713
  await sql`
706
714
  DELETE FROM ai.tool_approval_preferences
707
715
  WHERE tool_name ~ '^[a-z][a-z0-9-]*__(query|action)__'
708
716
  `.simple();
709
717
 
718
+ // An approval limited to one chat ends with that chat. NULL means it applies everywhere.
719
+ await sql`
720
+ ALTER TABLE ai.tool_approval_preferences
721
+ ADD COLUMN IF NOT EXISTS conversation_id UUID REFERENCES ai.conversations(id) ON DELETE CASCADE
722
+ `.simple();
723
+ await sql`
724
+ CREATE UNIQUE INDEX IF NOT EXISTS ai_tool_approval_preferences_reach
725
+ ON ai.tool_approval_preferences(actor_user_id, tool_name, approval_scope, conversation_id) NULLS NOT DISTINCT
726
+ `.simple();
727
+ await sql`ALTER TABLE ai.tool_approval_preferences DROP CONSTRAINT IF EXISTS ai_tool_approval_preferences_unique`.simple();
728
+ await sql`
729
+ CREATE INDEX IF NOT EXISTS idx_ai_tool_approval_preferences_conversation
730
+ ON ai.tool_approval_preferences(conversation_id)
731
+ WHERE conversation_id IS NOT NULL
732
+ `.simple();
733
+ // Addresses a chat's web searches returned and the pages it read link to, normalized; web_extract and fetch_file
734
+ // read them without asking. Recorded when the tool runs, so a read never scans stored results.
735
+ await sql`
736
+ CREATE TABLE IF NOT EXISTS ai.web_addresses (
737
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
738
+ address TEXT NOT NULL,
739
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
740
+ PRIMARY KEY (conversation_id, address)
741
+ )
742
+ `.simple();
743
+
710
744
  await sql`
711
745
  CREATE TABLE IF NOT EXISTS ai.tool_calls (
712
746
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -724,9 +758,23 @@ export const migrateCloudAi = async (): Promise<void> => {
724
758
  rejected_at TIMESTAMPTZ,
725
759
  completed_at TIMESTAMPTZ,
726
760
  CONSTRAINT ai_tool_calls_status_check CHECK (status IN ('pending', 'running', 'waiting_for_approval', 'waiting_for_frontend', 'completed', 'failed', 'rejected')),
727
- CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_always', 'approved_by_preference', 'rejected'))
761
+ CONSTRAINT ai_tool_calls_approval_state_check CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'))
728
762
  )
729
763
  `.simple();
764
+ await sql`
765
+ DO $$
766
+ BEGIN
767
+ IF NOT EXISTS (
768
+ SELECT 1 FROM pg_constraint
769
+ WHERE conname = 'ai_tool_calls_approval_state_check'
770
+ AND pg_get_constraintdef(oid) LIKE '%approved_for_chat%'
771
+ ) THEN
772
+ ALTER TABLE ai.tool_calls DROP CONSTRAINT IF EXISTS ai_tool_calls_approval_state_check;
773
+ ALTER TABLE ai.tool_calls ADD CONSTRAINT ai_tool_calls_approval_state_check
774
+ CHECK (approval_state IN ('not_required', 'waiting', 'approved_once', 'approved_for_chat', 'approved_always', 'approved_by_preference', 'rejected'));
775
+ END IF;
776
+ END $$
777
+ `.simple();
730
778
 
731
779
  await sql`
732
780
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_turn_call
@@ -738,6 +786,7 @@ export const migrateCloudAi = async (): Promise<void> => {
738
786
  ON ai.tool_calls(conversation_id, created_at DESC)
739
787
  `.simple();
740
788
 
789
+ await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS approval_decided_via TEXT`.simple();
741
790
  await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
742
791
 
743
792
  // Capability shape, outcome, and latency belong to capabilities.executions,
@@ -2525,5 +2574,67 @@ export const migrateCloudAi = async (): Promise<void> => {
2525
2574
  ` ! ${legacyPaths.files} conversation and ${legacyPaths.project_files} project file paths keep a non-NFC Unicode form because a normalized twin exists`,
2526
2575
  );
2527
2576
  }
2577
+ await backfillApprovalRequests();
2528
2578
  console.log(" ✓ ai conversation tables");
2529
2579
  };
2580
+
2581
+ const backfillApprovalRequests = async (): Promise<void> => {
2582
+ const decoded = (value: unknown): unknown => (typeof value === "string" ? JSON.parse(value) : value);
2583
+ while (true) {
2584
+ const count = await sql.begin(async (db) => {
2585
+ // Completed turns and ownerless chats cannot still ask a person; leave their legacy rows alone.
2586
+ const rows = await db<
2587
+ {
2588
+ id: string;
2589
+ turn_id: string;
2590
+ call_id: string;
2591
+ tool_name: string;
2592
+ approval_scope: string;
2593
+ message: string | null;
2594
+ review: unknown;
2595
+ args: unknown;
2596
+ allow_always: boolean;
2597
+ allow_chat: boolean;
2598
+ owner: string;
2599
+ title: string;
2600
+ expiry: Date;
2601
+ }[]
2602
+ >`SELECT a.*, c.created_by_user_id AS owner, c.title,
2603
+ COALESCE(t.deadline, a.created_at + interval '24 hours') AS expiry
2604
+ FROM ai.pending_actions a JOIN ai.turns t ON t.id=a.turn_id JOIN ai.conversations c ON c.id=a.conversation_id
2605
+ WHERE a.status='pending' AND a.kind IN ('approval','custom_approval') AND a.approval_id IS NULL
2606
+ AND c.created_by_user_id IS NOT NULL AND t.status='waiting_for_action'
2607
+ -- A running Assistant may already have asked again for this action; keep the request it saw.
2608
+ AND NOT EXISTS (SELECT 1 FROM approvals.requests r
2609
+ WHERE r.requester_kind='assistant_turn' AND r.requester_ref=a.turn_id::text || '/' || a.call_id)
2610
+ ORDER BY a.created_at, a.id LIMIT 200 FOR UPDATE OF a SKIP LOCKED`;
2611
+ for (const row of rows) {
2612
+ const review = CapabilityActionReviewSchema.safeParse(row.review == null ? null : decoded(row.review));
2613
+ // request computes both canonical digests; preserve the original wait deadline, including expiry.
2614
+ const record = await approvals.request(
2615
+ {
2616
+ ...approvalScopeFromToolName(row.tool_name),
2617
+ approvalScope: row.approval_scope,
2618
+ requester: { kind: "assistant_turn", ref: `${row.turn_id}/${row.call_id}`, appId: "assistant", title: row.title },
2619
+ subjectId: row.owner,
2620
+ approverUserId: row.owner,
2621
+ // Every turn that can ask allows remembering, so these flags hold what the call offers.
2622
+ assurance: approvalAssurance(row.allow_always || row.allow_chat),
2623
+ sentence: row.message ?? row.tool_name,
2624
+ review: review.success ? review.data : null,
2625
+ input: decoded(row.args),
2626
+ expiresInMs: Math.min(APPROVAL_MAX_WAIT_MS, row.expiry.getTime() - Date.now()),
2627
+ },
2628
+ db,
2629
+ );
2630
+ if (!record.ok) continue;
2631
+ await db`UPDATE approvals.requests SET expires_at=${row.expiry},
2632
+ status=CASE WHEN ${row.expiry} <= now() THEN 'expired' ELSE status END
2633
+ WHERE id=${record.id}::uuid AND status='pending'`;
2634
+ await db`UPDATE ai.pending_actions SET approval_id=${record.id}::uuid WHERE id=${row.id}::uuid`;
2635
+ }
2636
+ return rows.length;
2637
+ });
2638
+ if (!count) return;
2639
+ }
2640
+ };
@@ -44,7 +44,15 @@ export type AiTurnBlock =
44
44
  result?: unknown;
45
45
  isError?: boolean;
46
46
  /** Present while status is awaiting_approval. */
47
- approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
47
+ approval?: {
48
+ message?: string;
49
+ review?: CapabilityActionReview;
50
+ allowAlways: boolean;
51
+ /** Whether the approval can be remembered for this chat. */
52
+ allowChat?: boolean;
53
+ /** The website origin a chat approval would allow, for an HTTP request a code run makes. */
54
+ website?: string;
55
+ };
48
56
  /**
49
57
  * `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
50
58
  * waited. Either way the approval stays visible as a receipt.
@@ -54,6 +62,8 @@ export type AiTurnBlock =
54
62
  frontendMode?: AiFrontendToolMode;
55
63
  /** Saved Cloud-owned display snapshot for capability calls. */
56
64
  presentation?: AiToolPresentation;
65
+ /** Requests a website approval let through without asking, in order; shown whatever the call's outcome. */
66
+ receipts?: { method: string; url: string }[];
57
67
  }
58
68
  | { id: string; kind: "compaction"; status: "running" | "completed" | "skipped" | "failed"; result?: CompactResult };
59
69
 
@@ -215,6 +225,14 @@ const canonicalToolName = (name: string, presentation?: AiToolPresentation): str
215
225
  return candidates.length === 1 ? candidates[0]! : name;
216
226
  };
217
227
 
228
+ /** The receipts of one tool call, keyed by their index, in the order the requests went out. */
229
+ export const orderedWebsiteReceipts = (
230
+ receipts: Record<string, { method: string; url: string }> | undefined,
231
+ ): { method: string; url: string }[] =>
232
+ Object.entries(receipts ?? {})
233
+ .sort(([a], [b]) => Number(a) - Number(b))
234
+ .map(([, receipt]) => receipt);
235
+
218
236
  /**
219
237
  * Convert persisted loop messages into the block model. Shared by the executor
220
238
  * (baseline rebuild on claim) and the client (rendering finished turns), so a
@@ -228,6 +246,7 @@ export const buildBlocksFromMessages = (
228
246
  steerId?: string;
229
247
  toolPresentations?: Record<string, AiToolPresentation>;
230
248
  toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
249
+ websiteReceipts?: Record<string, Record<string, { method: string; url: string }>>;
231
250
  } | null;
232
251
  }[],
233
252
  ): AiTurnBlock[] => {
@@ -243,6 +262,7 @@ export const buildBlocksFromMessages = (
243
262
  blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
244
263
  } else if (block.type === "tool_call") {
245
264
  const outcome = meta?.toolOutcomes?.[block.id];
265
+ const receipts = orderedWebsiteReceipts(meta?.websiteReceipts?.[block.id]);
246
266
  toolIndex.set(block.id, blocks.length);
247
267
  blocks.push({
248
268
  id: toolBlockId(block.id),
@@ -255,6 +275,7 @@ export const buildBlocksFromMessages = (
255
275
  // A turn that ended before an approved call returned records the approval on the call's message, and an
256
276
  // approval it left waiting as expired.
257
277
  ...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
278
+ ...(receipts.length ? { receipts } : {}),
258
279
  });
259
280
  }
260
281
  });
package/src/ai/routes.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  v,
19
19
  } from "../server";
20
20
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
21
+ import { approvalDecisionChannel } from "../services/approvals";
21
22
  import { ImageMetadataError } from "../services/image-metadata";
22
23
  import { logger } from "../services/logging";
23
24
  import { coreSettings } from "../services/settings/api";
@@ -93,6 +94,8 @@ type AiChatRequestContext = {
93
94
  ownerUserId: string;
94
95
  modelPolicy: AiModelPolicy;
95
96
  toolApprovalContext: AiToolApprovalContext;
97
+ /** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
98
+ signedInSession: boolean;
96
99
  };
97
100
 
98
101
  const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
@@ -106,6 +109,7 @@ const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestCon
106
109
  ownerUserId: user.id,
107
110
  modelPolicy: personalAiModelPolicy,
108
111
  toolApprovalContext: { actorUserId: user.id },
112
+ signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
109
113
  };
110
114
  };
111
115
 
@@ -890,6 +894,7 @@ export const aiRoutes = (() => {
890
894
  try {
891
895
  const submission = {
892
896
  assistantChat: true as const,
897
+ ...(ctx.signedInSession ? { signedInSession: true as const } : {}),
893
898
  conversationId: conversation.id,
894
899
  chatId: conversation.shortId,
895
900
  input,
@@ -999,6 +1004,7 @@ export const aiRoutes = (() => {
999
1004
  }
1000
1005
  const result = await submitAiChatTurn({
1001
1006
  assistantChat: true,
1007
+ ...(ctx.signedInSession ? { signedInSession: true as const } : {}),
1002
1008
  conversationId: conversation.id,
1003
1009
  chatId: conversation.shortId,
1004
1010
  input,
@@ -1121,7 +1127,12 @@ export const aiRoutes = (() => {
1121
1127
  turnId: turn.id,
1122
1128
  callId,
1123
1129
  action: c.req.valid("json"),
1130
+ decidedBy: {
1131
+ userId: ctx.ownerUserId,
1132
+ via: approvalDecisionChannel({ credentialKind: c.get("credentialKind"), actor: ctx.actor }),
1133
+ },
1124
1134
  toolApprovalContext: ctx.toolApprovalContext,
1135
+ signedInSession: ctx.signedInSession,
1125
1136
  });
1126
1137
  if (!result.ok) return toAiActionFailureResponse(c, result);
1127
1138
  return respond(c, ok({ ok: true }));
package/src/ai/runtime.ts CHANGED
@@ -4,10 +4,11 @@ import type { QueueMessage } from "@k2b/sync";
4
4
  import { z } from "zod";
5
5
  import { lazySync } from "../_internal/process-sync";
6
6
  import type { RequestActor } from "../server";
7
+ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
7
8
  import { logger } from "../services/logging";
8
9
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
9
10
  import { coreSettings } from "../services/settings/api";
10
- import { type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
+ import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
12
  import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
12
13
  import { startAiDictationRuntime } from "./dictation-runtime";
13
14
  import { AiTurnExecutor } from "./executor";
@@ -87,6 +88,8 @@ export const enqueueExistingAiTurn = (input: AiTurnJob): Promise<unknown> => enq
87
88
  export type SubmitAiChatTurnInput = {
88
89
  /** Only interactive Assistant HTTP handlers set this server-owned marker. */
89
90
  assistantChat?: true;
91
+ /** Only a handler that saw a signed-in browser session sets this server-owned marker; see `AiChatTurnRunConfig`. */
92
+ signedInSession?: true;
90
93
  conversationId: string;
91
94
  /** Stable public ID exposed as runtime context, not instructions. */
92
95
  chatId?: string;
@@ -153,6 +156,7 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
153
156
  ),
154
157
  ],
155
158
  ...(input.assistantChat ? { assistantChat: true } : {}),
159
+ ...(input.signedInSession ? { signedInSession: true } : {}),
156
160
  input: canonicalInput,
157
161
  chatId: input.chatId,
158
162
  actor: input.actor,
@@ -297,7 +301,7 @@ export const abortAiTurn = async (input: { conversationId: string; turnId: strin
297
301
  // ---------------------------------------------------------------------------
298
302
 
299
303
  export const AiTurnActionSchema = z.discriminatedUnion("type", [
300
- z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.literal("always").optional() }),
304
+ z.object({ type: z.literal("approval_response"), approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }),
301
305
  z.object({ type: z.literal("tool_result"), result: z.unknown() }),
302
306
  ]);
303
307
  export type AiTurnActionInput = z.infer<typeof AiTurnActionSchema>;
@@ -312,7 +316,7 @@ export const listPendingAiTurnActions = async (input: { conversationId: string;
312
316
  const [actions, config] = await Promise.all([aiConversations.listPendingTurnActions(input), aiConversations.getTurnRunConfig(input)]);
313
317
  return aiTurnAllowsRememberedApprovals(config)
314
318
  ? actions
315
- : actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false } : action));
319
+ : actions.map((action) => (action.type === "approval_request" ? { ...action, allowAlways: false, allowChat: false } : action));
316
320
  };
317
321
 
318
322
  export const submitAiTurnAction = async (input: {
@@ -320,7 +324,10 @@ export const submitAiTurnAction = async (input: {
320
324
  turnId: string;
321
325
  callId: string;
322
326
  action: AiTurnActionInput;
327
+ decidedBy: { userId: string; via: ApprovalDecisionChannel };
323
328
  toolApprovalContext?: AiToolApprovalContext;
329
+ /** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
330
+ signedInSession?: boolean;
324
331
  }): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
325
332
  const pending = await aiConversations.getPendingTurnAction(input);
326
333
  if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
@@ -338,18 +345,50 @@ export const submitAiTurnAction = async (input: {
338
345
 
339
346
  if (input.action.type === "approval_response") {
340
347
  if (pending.kind === "client_tool") return { ok: false, status: 400, message: "Frontend tool requests require a tool result." };
341
- if (input.action.remember === "always") {
348
+ const remember = input.action.remember;
349
+ if (remember) {
342
350
  const config = await aiConversations.getTurnRunConfig(input);
343
- if (!input.action.approved || !pending.allowAlways || !input.toolApprovalContext || !aiTurnAllowsRememberedApprovals(config)) {
351
+ const toolName = pending.rememberToolName ?? pending.name;
352
+ const allowed = remember === "always" ? pending.allowAlways : (pending.allowChat ?? pending.allowAlways);
353
+ if (
354
+ !input.action.approved ||
355
+ !allowed ||
356
+ !input.toolApprovalContext ||
357
+ !aiTurnAllowsRememberedApprovals(config) ||
358
+ // A website is remembered only by a person in a signed-in session: `cld`, an API key, or another delegated
359
+ // credential can approve this one request, but cannot let the next ones through.
360
+ (toolName === AI_WEBSITE_APPROVAL_TOOL && (remember !== "chat" || !input.signedInSession))
361
+ ) {
344
362
  return { ok: false, status: 400, message: "This approval cannot be remembered." };
345
363
  }
346
- await rememberAiToolApproval(input.toolApprovalContext, { toolName: pending.name, approvalScope: pending.approvalScope });
364
+ }
365
+ if (pending.approvalId) {
366
+ const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
367
+ if (!decision.ok)
368
+ return decision.code === "ALREADY_DECIDED"
369
+ ? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
370
+ : { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
371
+ }
372
+ if (remember && input.toolApprovalContext) {
373
+ const toolName = pending.rememberToolName ?? pending.name;
374
+ await rememberAiToolApproval(input.toolApprovalContext, {
375
+ toolName,
376
+ approvalScope: pending.approvalScope,
377
+ conversationId: remember === "chat" ? input.conversationId : null,
378
+ });
347
379
  }
348
380
  await aiToolAudit
349
381
  .noteApprovalResolved({
350
382
  turnId: input.turnId,
351
383
  callId: input.callId,
352
- approvalState: input.action.approved ? (input.action.remember === "always" ? "approved_always" : "approved_once") : "rejected",
384
+ decidedVia: input.decidedBy.via,
385
+ approvalState: input.action.approved
386
+ ? remember === "always"
387
+ ? "approved_always"
388
+ : remember === "chat"
389
+ ? "approved_for_chat"
390
+ : "approved_once"
391
+ : "rejected",
353
392
  })
354
393
  .catch(() => undefined);
355
394
 
package/src/ai/store.ts CHANGED
@@ -4,6 +4,7 @@ import { type SQL, sql } from "bun";
4
4
  import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
5
5
  import { logger } from "../services/logging";
6
6
  import { toPgTextArray } from "../services/postgres";
7
+ import { AI_WEBSITE_APPROVAL_TOOL } from "./approvals";
7
8
  import { aiTurnErrorText } from "./chat/turn-error";
8
9
  import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
9
10
  import type { AiTurnBlock } from "./protocol";
@@ -36,6 +37,7 @@ import type {
36
37
  AiTurnStatus,
37
38
  AiTurnSteer,
38
39
  AiTurnSweepResult,
40
+ AiWebsiteReceipt,
39
41
  } from "./types";
40
42
 
41
43
  /** A queued turn this old without a claim is considered lost and re-enqueued by the sweep. */
@@ -191,6 +193,7 @@ type TurnRow = {
191
193
  };
192
194
 
193
195
  type PendingActionRow = {
196
+ approval_id: string | null;
194
197
  turn_id: string;
195
198
  conversation_id: string;
196
199
  call_id: string;
@@ -202,6 +205,8 @@ type PendingActionRow = {
202
205
  review: unknown | null;
203
206
  approval_scope: string;
204
207
  allow_always: boolean;
208
+ allow_chat: boolean | null;
209
+ remember_tool_name: string | null;
205
210
  frontend_mode: AiFrontendToolMode | null;
206
211
  resolved_event: unknown | null;
207
212
  };
@@ -678,9 +683,12 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
678
683
  message: row.message ?? undefined,
679
684
  review: parseCapabilityActionReview(row.review),
680
685
  allowAlways: row.allow_always,
686
+ allowChat: row.allow_always || row.allow_chat === true,
687
+ ...(row.remember_tool_name === AI_WEBSITE_APPROVAL_TOOL ? { website: row.approval_scope } : {}),
681
688
  };
682
689
 
683
690
  const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRecord => ({
691
+ approvalId: row.approval_id ?? undefined,
684
692
  turnId: row.turn_id,
685
693
  conversationId: row.conversation_id,
686
694
  callId: row.call_id,
@@ -692,6 +700,8 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
692
700
  review: parseCapabilityActionReview(row.review),
693
701
  approvalScope: row.approval_scope,
694
702
  allowAlways: row.allow_always,
703
+ allowChat: row.allow_always || row.allow_chat === true,
704
+ ...(row.remember_tool_name ? { rememberToolName: row.remember_tool_name } : {}),
695
705
  frontendMode: row.frontend_mode ?? undefined,
696
706
  resolvedEvent: row.resolved_event ? parseJsonValue<InboundEvent>(row.resolved_event) : null,
697
707
  });
@@ -784,6 +794,18 @@ const messageSearchText = (message: Message): string => {
784
794
  return text.slice(0, SEARCH_TEXT_MAX_CHARS);
785
795
  };
786
796
 
797
+ /**
798
+ * An ended turn uses no further approval: cancel its open questions and the turn's own approvals that no call used.
799
+ * A Studio request linked from the turn stays as decided, because the code run uses it when the call runs there.
800
+ */
801
+ const cancelTurnApprovals = async (db: typeof sql, turnId: string): Promise<void> => {
802
+ await db`
803
+ UPDATE approvals.requests SET status = 'cancelled'
804
+ WHERE id IN (SELECT approval_id FROM ai.pending_actions WHERE turn_id = ${turnId} AND approval_id IS NOT NULL)
805
+ AND (status = 'pending' OR (status = 'approved' AND requester_kind = 'assistant_turn'))
806
+ `;
807
+ };
808
+
787
809
  /**
788
810
  * Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
789
811
  * time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
@@ -878,6 +900,35 @@ const recordTurnEnd = async (
878
900
  }
879
901
  };
880
902
 
903
+ /**
904
+ * Records requests a website approval let through on the assistant message that holds the tool call, as each one
905
+ * goes out, so the chat keeps every receipt whether the call succeeds, fails, or its turn stops. Website approvals
906
+ * apply only to interactive chats, whose messages live in `ai.messages`. A receipt with a known index replaces itself.
907
+ */
908
+ export const recordAiWebsiteReceipts = async (input: {
909
+ conversationId: string;
910
+ turnId: string;
911
+ callId: string;
912
+ receipts: AiWebsiteReceipt[];
913
+ }): Promise<void> => {
914
+ if (!input.receipts.length) return;
915
+ const receipts = Object.fromEntries(input.receipts.map(({ index, method, url }) => [String(index), { method, url }]));
916
+ await sql`
917
+ UPDATE ai.messages
918
+ SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object(
919
+ 'websiteReceipts',
920
+ COALESCE(meta->'websiteReceipts', '{}'::jsonb) || jsonb_build_object(
921
+ ${input.callId}::text,
922
+ COALESCE(meta->'websiteReceipts'->${input.callId}::text, '{}'::jsonb) || (${JSON.stringify(receipts)}::text)::jsonb
923
+ )
924
+ )
925
+ WHERE conversation_id = ${input.conversationId}::uuid
926
+ AND loop_id = ${input.turnId}::text
927
+ AND role = 'assistant'
928
+ AND message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', ${input.callId}::text))
929
+ `;
930
+ };
931
+
881
932
  /** Insert a message inside an open conversation-lock transaction and bump the conversation. */
882
933
  const insertMessageLocked = async (
883
934
  input: {
@@ -3017,6 +3068,7 @@ export const aiConversations: AiConversationService = {
3017
3068
  WHERE turn_id = ${input.turnId}
3018
3069
  AND status = 'pending'
3019
3070
  `;
3071
+ await cancelTurnApprovals(tx, input.turnId);
3020
3072
  if (input.status !== "completed") {
3021
3073
  await recordTurnEnd(tx, {
3022
3074
  conversationId: input.conversationId,
@@ -3173,6 +3225,7 @@ export const aiConversations: AiConversationService = {
3173
3225
  WHERE turn_id = ${finalized.turnId}
3174
3226
  AND status = 'pending'
3175
3227
  `;
3228
+ await cancelTurnApprovals(sql, finalized.turnId);
3176
3229
  await sql`
3177
3230
  UPDATE ai.turn_steers
3178
3231
  SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
@@ -3261,6 +3314,7 @@ export const aiConversations: AiConversationService = {
3261
3314
  savePendingTurnAction: async (input) => {
3262
3315
  await sql`
3263
3316
  INSERT INTO ai.pending_actions (
3317
+ approval_id,
3264
3318
  turn_id,
3265
3319
  conversation_id,
3266
3320
  call_id,
@@ -3271,12 +3325,15 @@ export const aiConversations: AiConversationService = {
3271
3325
  review,
3272
3326
  approval_scope,
3273
3327
  allow_always,
3328
+ allow_chat,
3329
+ remember_tool_name,
3274
3330
  frontend_mode,
3275
3331
  status,
3276
3332
  resolved_event,
3277
3333
  resolved_at
3278
3334
  )
3279
3335
  VALUES (
3336
+ ${input.approvalId ?? null}::uuid,
3280
3337
  ${input.turnId},
3281
3338
  ${input.conversationId},
3282
3339
  ${input.callId},
@@ -3287,6 +3344,8 @@ export const aiConversations: AiConversationService = {
3287
3344
  ${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
3288
3345
  ${input.approvalScope},
3289
3346
  ${input.allowAlways},
3347
+ ${input.allowChat ?? input.allowAlways},
3348
+ ${input.rememberToolName ?? null},
3290
3349
  ${input.frontendMode ?? null},
3291
3350
  ${input.resolvedEvent ? "resolved" : "pending"},
3292
3351
  ${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
@@ -3294,6 +3353,7 @@ export const aiConversations: AiConversationService = {
3294
3353
  )
3295
3354
  ON CONFLICT (turn_id, call_id)
3296
3355
  DO UPDATE SET
3356
+ approval_id = EXCLUDED.approval_id,
3297
3357
  kind = EXCLUDED.kind,
3298
3358
  tool_name = EXCLUDED.tool_name,
3299
3359
  args = EXCLUDED.args,
@@ -3301,6 +3361,8 @@ export const aiConversations: AiConversationService = {
3301
3361
  review = EXCLUDED.review,
3302
3362
  approval_scope = EXCLUDED.approval_scope,
3303
3363
  allow_always = EXCLUDED.allow_always,
3364
+ allow_chat = EXCLUDED.allow_chat,
3365
+ remember_tool_name = EXCLUDED.remember_tool_name,
3304
3366
  frontend_mode = EXCLUDED.frontend_mode
3305
3367
  `;
3306
3368
  },
@@ -3370,12 +3432,15 @@ export const aiConversations: AiConversationService = {
3370
3432
 
3371
3433
  clearPendingTurnActions: async (input) => {
3372
3434
  await sql`
3373
- UPDATE ai.pending_actions
3435
+ WITH aborted AS (UPDATE ai.pending_actions
3374
3436
  SET status = 'aborted',
3375
3437
  resolved_at = COALESCE(resolved_at, now())
3376
3438
  WHERE conversation_id = ${input.conversationId}
3377
3439
  AND turn_id = ${input.turnId}
3378
3440
  AND status = 'pending'
3441
+ RETURNING approval_id)
3442
+ UPDATE approvals.requests SET status='cancelled'
3443
+ WHERE id IN (SELECT approval_id FROM aborted WHERE approval_id IS NOT NULL) AND status='pending'
3379
3444
  `;
3380
3445
  },
3381
3446
 
@@ -1,4 +1,5 @@
1
1
  import { sql } from "bun";
2
+ import type { ApprovalDecisionChannel } from "../services/approvals";
2
3
 
3
4
  /**
4
5
  * AI-specific tool-call state only: approval, turn linkage, and the request id
@@ -8,7 +9,14 @@ import { sql } from "bun";
8
9
  */
9
10
  export type AiToolCallLocation = "server" | "client" | "client_view" | "client_interaction";
10
11
 
11
- export type AiToolApprovalState = "not_required" | "waiting" | "approved_once" | "approved_always" | "approved_by_preference" | "rejected";
12
+ export type AiToolApprovalState =
13
+ | "not_required"
14
+ | "waiting"
15
+ | "approved_once"
16
+ | "approved_for_chat"
17
+ | "approved_always"
18
+ | "approved_by_preference"
19
+ | "rejected";
12
20
 
13
21
  export const aiToolAudit = {
14
22
  /** Resolve the current server-recorded invocation, never a model-supplied chat id. */
@@ -140,12 +148,14 @@ export const aiToolAudit = {
140
148
  turnId: string;
141
149
  callId: string;
142
150
  approvalState: Exclude<AiToolApprovalState, "not_required" | "waiting">;
151
+ decidedVia?: ApprovalDecisionChannel;
143
152
  }): Promise<void> => {
144
153
  const approved = input.approvalState !== "rejected";
145
154
  await sql`
146
155
  UPDATE ai.tool_calls
147
156
  SET
148
157
  approval_state = ${input.approvalState},
158
+ approval_decided_via = ${input.decidedVia ?? null},
149
159
  status = CASE WHEN ${approved} THEN status ELSE 'rejected' END,
150
160
  approved_at = CASE WHEN ${approved} THEN now() ELSE approved_at END,
151
161
  rejected_at = CASE WHEN ${approved} THEN rejected_at ELSE now() END