@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
package/src/ai/index.ts CHANGED
@@ -2,6 +2,7 @@ export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileApp
2
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
3
3
  export {
4
4
  AI_WEBSITE_APPROVAL_TOOL,
5
+ type AiToolApprovalChangeContext,
5
6
  type AiToolApprovalContext,
6
7
  type AiToolApprovalPreference,
7
8
  type AiToolApprovalRemember,
@@ -33,11 +34,22 @@ export {
33
34
  ChatTaskOccurrenceIdSchema,
34
35
  ChatTaskScheduleInputSchema,
35
36
  chatTaskCreateFingerprint,
37
+ chatTaskName,
36
38
  getChatTaskTimezone,
37
39
  normalizeChatTaskSchedule,
38
40
  toAiChatTaskOccurrenceView,
39
41
  toAiChatTaskView,
40
42
  } from "./chat-task-contracts";
43
+ export {
44
+ CHAT_TASK_REASON_CODES,
45
+ type ChatTaskReason,
46
+ type ChatTaskReasonCode,
47
+ chatTaskLocale,
48
+ chatTaskNeedsNewTime,
49
+ chatTaskReasonSentence,
50
+ chatTaskReasonState,
51
+ chatTaskStopSentence,
52
+ } from "./chat-task-reasons";
41
53
  export {
42
54
  type AiChatTask,
43
55
  AiChatTaskIdempotencyConflictError,
package/src/ai/migrate.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { sql } from "bun";
2
2
  import { CapabilityActionReviewSchema } from "../contracts/capabilities";
3
3
  import { APPROVAL_MAX_WAIT_MS, approvalAssurance, approvalScopeFromToolName, approvals, migrateApprovals } from "../services/approvals";
4
+ import { migrateChatTaskReasons } from "./chat-task-reasons-migrate";
4
5
  import { migrateAiMessageQueue } from "./message-queue";
5
6
  import { migrateAiModelAccess } from "./model-access-migrate";
6
7
  import { migrateAiQuotas } from "./quotas-migrate";
@@ -885,6 +886,8 @@ export const migrateCloudAi = async (): Promise<void> => {
885
886
  )
886
887
  `.simple();
887
888
 
889
+ await sql.begin(migrateChatTaskReasons);
890
+
888
891
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS idempotency_fingerprint TEXT`.simple();
889
892
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS revision BIGINT NOT NULL DEFAULT 0`.simple();
890
893
  await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS mandate_id UUID`.simple();
@@ -998,6 +1001,17 @@ export const migrateCloudAi = async (): Promise<void> => {
998
1001
  )
999
1002
  `.simple();
1000
1003
 
1004
+ await sql`
1005
+ DO $$ BEGIN
1006
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_task_occurrences_state_check'
1007
+ AND pg_get_constraintdef(oid) LIKE '%stopped%') THEN
1008
+ ALTER TABLE ai.chat_task_occurrences DROP CONSTRAINT IF EXISTS ai_chat_task_occurrences_state_check;
1009
+ ALTER TABLE ai.chat_task_occurrences ADD CONSTRAINT ai_chat_task_occurrences_state_check
1010
+ CHECK (state IN ('queued', 'running', 'completed', 'failed', 'stopped'));
1011
+ END IF;
1012
+ END $$
1013
+ `.simple();
1014
+
1001
1015
  await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS task_revision BIGINT NOT NULL DEFAULT 0`.simple();
1002
1016
 
1003
1017
  await sql`
@@ -1016,10 +1030,18 @@ export const migrateCloudAi = async (): Promise<void> => {
1016
1030
  WHERE state = 'queued'
1017
1031
  `.simple();
1018
1032
 
1033
+ await sql`
1034
+ DO $$ BEGIN
1035
+ IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'ai' AND indexname = 'idx_ai_chat_task_occurrences_finished'
1036
+ AND indexdef NOT LIKE '%stopped%') THEN
1037
+ DROP INDEX ai.idx_ai_chat_task_occurrences_finished;
1038
+ END IF;
1039
+ END $$
1040
+ `.simple();
1019
1041
  await sql`
1020
1042
  CREATE INDEX IF NOT EXISTS idx_ai_chat_task_occurrences_finished
1021
1043
  ON ai.chat_task_occurrences(completed_at, id)
1022
- WHERE state IN ('completed', 'failed')
1044
+ WHERE state IN ('completed', 'failed', 'stopped')
1023
1045
  `.simple();
1024
1046
 
1025
1047
  await sql`
@@ -2468,7 +2490,11 @@ export const migrateCloudAi = async (): Promise<void> => {
2468
2490
  {
2469
2491
  const { coreSettings } = await import("../services");
2470
2492
  const { setAiCredential, splitAiProfileCredentials } = await import("./credentials");
2471
- const split = splitAiProfileCredentials((await coreSettings.get<string>("ai.model_profiles_json")) ?? "[]");
2493
+ const profiles = await coreSettings.read<string>("ai.model_profiles_json");
2494
+ if (profiles.stored === "unreadable") {
2495
+ console.warn('[ai] cannot read setting "ai.model_profiles_json"; skipping credential split');
2496
+ }
2497
+ const split = profiles.stored === "unreadable" ? null : splitAiProfileCredentials(profiles.value ?? "[]");
2472
2498
  if (split && split.credentials.length > 0) {
2473
2499
  for (const { profileId, secret } of split.credentials) await setAiCredential(profileId, secret);
2474
2500
  await coreSettings.set("ai.model_profiles_json", split.profilesJson);
@@ -3,7 +3,7 @@ import { userFromActor } from "../server/actor";
3
3
  import { type ApiErrorResponse, respond } from "../server/api";
4
4
  import type { AuthContext, RequestActor } from "../server/middleware/auth";
5
5
  import { err, fail } from "../server/services";
6
- import type { AiToolApprovalContext } from "./approvals";
6
+ import type { AiToolApprovalChangeContext, AiToolApprovalContext } from "./approvals";
7
7
  import { personalAiModelPolicy } from "./personal-agent";
8
8
  import type { AiModelPolicy } from "./types";
9
9
 
@@ -13,6 +13,7 @@ export type AiChatRequestContext = {
13
13
  ownerUserId: string;
14
14
  modelPolicy: AiModelPolicy;
15
15
  toolApprovalContext: AiToolApprovalContext;
16
+ toolApprovalChangeContext: AiToolApprovalChangeContext;
16
17
  /** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
17
18
  signedInSession: boolean;
18
19
  };
@@ -28,6 +29,11 @@ export const resolveAiChatRequestContext = async <T extends AuthContext>(
28
29
  ownerUserId: user.id,
29
30
  modelPolicy: personalAiModelPolicy,
30
31
  toolApprovalContext: { actorUserId: user.id },
32
+ toolApprovalChangeContext: {
33
+ actorUserId: user.id,
34
+ actor,
35
+ requestId: c.req.header("x-request-id") ?? null,
36
+ },
31
37
  signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
32
38
  };
33
39
  };
package/src/ai/routes.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  respond,
18
18
  v,
19
19
  } from "../server";
20
+ import { isAppSessionActor } from "../server/actor";
20
21
  import { isRequestCredentialCurrent } from "../server/middleware/auth";
21
22
  import { approvalDecisionChannel } from "../services/approvals";
22
23
  import { ImageMetadataError } from "../services/image-metadata";
@@ -28,6 +29,7 @@ import { AI_AUDIO_MAX_BYTES } from "./audio-format";
28
29
  import { buildAiCapabilityCatalog } from "./capabilities";
29
30
  import { aiChatMessages } from "./chat/messages";
30
31
  import { getAiChatQuotas } from "./chat-quotas";
32
+ import { aiChatTasks } from "./chat-tasks";
31
33
  import { AI_CLIENT_TOOL_ACTION_MAX_BYTES, aiClientToolOutputSchemas } from "./client-tool-results";
32
34
  import { createCloudAiLocalBashTool, createCloudAiLocalTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
33
35
  import { enqueueAiDictation } from "./dictation-runtime";
@@ -784,6 +786,13 @@ export const aiRoutes = (() => {
784
786
  ownerUserId: ctx.ownerUserId,
785
787
  });
786
788
  if (!archived) return notFound(c);
789
+ // The chat is archived either way; admission pauses a routine this step missed before its next run.
790
+ await aiChatTasks.pauseForArchivedChat({ conversationId: conversation.id, userId: ctx.ownerUserId }).catch((error) =>
791
+ logger("ai:chat-tasks").warn("Could not pause the routines of an archived chat", {
792
+ conversationId: conversation.id,
793
+ error: error instanceof Error ? error.message : String(error),
794
+ }),
795
+ );
787
796
  return respond(c, ok({ ok: true }));
788
797
  })
789
798
  .post("/conversations/:conversationId/restore", async (c) => {
@@ -791,10 +800,27 @@ export const aiRoutes = (() => {
791
800
  if (ctx instanceof Response) return ctx;
792
801
  const conversation = await loadConversation(c, ctx, true);
793
802
  if (!conversation) return notFound(c);
803
+ await aiChatTasks.markRestoreStarted({ conversationId: conversation.id, userId: ctx.ownerUserId });
794
804
  const restored = await aiConversations.restoreConversation({
795
805
  conversationId: conversation.id,
796
806
  ownerUserId: ctx.ownerUserId,
797
807
  });
808
+ // A restored chat must not leave archive reasons on routines that stay paused.
809
+ // The phone app may restore the chat and relabel pauses, but may not resume background authority.
810
+ // If this step fails, the routine recovery relabels them after a grace period; it never resumes.
811
+ if (restored)
812
+ await aiChatTasks
813
+ .resumeForRestoredChat({
814
+ conversationId: conversation.id,
815
+ userId: ctx.ownerUserId,
816
+ resume: !isAppSessionActor(c.get("actor")),
817
+ })
818
+ .catch((error) =>
819
+ logger("ai:chat-tasks").warn("Could not resume the routines of a restored chat", {
820
+ conversationId: conversation.id,
821
+ error: error instanceof Error ? error.message : String(error),
822
+ }),
823
+ );
798
824
  return restored ? respond(c, ok(await publicConversationFor(restored, c.get("accessSubject")))) : notFound(c);
799
825
  })
800
826
  .post("/conversations/:conversationId/viewed", async (c) => {
@@ -1168,7 +1194,7 @@ export const aiRoutes = (() => {
1168
1194
  client: c.req.header(APPROVAL_CLIENT_HEADER),
1169
1195
  }),
1170
1196
  },
1171
- toolApprovalContext: ctx.toolApprovalContext,
1197
+ toolApprovalContext: ctx.toolApprovalChangeContext,
1172
1198
  signedInSession: ctx.signedInSession,
1173
1199
  });
1174
1200
  if (!result.ok) return toAiActionFailureResponse(c, result);
package/src/ai/runtime.ts CHANGED
@@ -8,7 +8,13 @@ import { type ApprovalDecisionChannel, approvals } from "../services/approvals";
8
8
  import { logger } from "../services/logging";
9
9
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
10
10
  import { coreSettings } from "../services/settings/api";
11
- import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
11
+ import {
12
+ AI_WEBSITE_APPROVAL_TOOL,
13
+ type AiToolApprovalChangeContext,
14
+ type AiToolApprovalContext,
15
+ aiTurnAllowsRememberedApprovals,
16
+ rememberAiToolApproval,
17
+ } from "./approvals";
12
18
  import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
13
19
  import { startAiDictationRuntime } from "./dictation-runtime";
14
20
  import { AiTurnExecutor, type ExecutorConfig } from "./executor";
@@ -20,8 +26,9 @@ import { aiQuotas } from "./quotas";
20
26
  import { parseAiResourceMarker } from "./resource-markers";
21
27
  import { isAiVisionModelConfigured } from "./settings";
22
28
  import { aiConversations } from "./store";
23
- import { publishAiTurnAbort, publishAiWireEvent } from "./stream";
29
+ import { publishAiWireEvent } from "./stream";
24
30
  import { aiToolAudit } from "./tool-audit";
31
+ import { finishAiTurnAbort } from "./turn-abort";
25
32
  import type {
26
33
  AiChatTurnRunConfig,
27
34
  AiClientToolId,
@@ -273,32 +280,10 @@ export const submitAiCompaction = async (input: SubmitAiCompactionInput): Promis
273
280
  // ---------------------------------------------------------------------------
274
281
 
275
282
  export const abortAiTurn = async (input: { conversationId: string; turnId: string }): Promise<{ ok: true }> => {
276
- // Capture the wire coordinates before finalization so we can emit turn_finished.
283
+ // Capture wire coordinates before the cancellation flag can wake its owner.
277
284
  const active = await aiConversations.getActiveTurn({ conversationId: input.conversationId }).catch(() => null);
278
285
  const request = await aiConversations.requestTurnAbort({ ...input, reason: "user" });
279
- if (!request.found) return { ok: true };
280
-
281
- // Tell any live owner to stop (its heartbeat also detects the cancel flag).
282
- await publishAiTurnAbort(input).catch(() => undefined);
283
-
284
- if (request.ownerless) {
285
- const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
286
- const config = await aiConversations.getTurnRunConfig(input);
287
- if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
288
- const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
289
- const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
290
- await publishAiWireEvent({
291
- v: 1,
292
- conversationId: input.conversationId,
293
- turnId: input.turnId,
294
- attempt,
295
- seq,
296
- type: "turn_finished",
297
- status: "aborted",
298
- error: null,
299
- }).catch(() => undefined);
300
- }
301
- }
286
+ await finishAiTurnAbort(input, request, active);
302
287
  return { ok: true };
303
288
  };
304
289
 
@@ -331,7 +316,7 @@ export const submitAiTurnAction = async (input: {
331
316
  callId: string;
332
317
  action: AiTurnActionInput;
333
318
  decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
334
- toolApprovalContext?: AiToolApprovalContext;
319
+ toolApprovalContext?: AiToolApprovalChangeContext;
335
320
  /** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
336
321
  signedInSession?: boolean;
337
322
  }): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
package/src/ai/store.ts CHANGED
@@ -2992,12 +2992,14 @@ export const aiConversations: AiConversationService = {
2992
2992
  return Boolean(rows[0]);
2993
2993
  },
2994
2994
 
2995
- requestTurnAbort: async (input) => {
2995
+ requestTurnAbort: async (input, options) => {
2996
2996
  const reason = input.reason ?? "user";
2997
- const rows = await sql<TurnRow[]>`
2997
+ const rows = await (options?.db ?? sql)<TurnRow[]>`
2998
2998
  UPDATE ai.turns
2999
2999
  SET cancel_requested_at = COALESCE(cancel_requested_at, now()),
3000
- cancellation_reason = COALESCE(cancellation_reason, ${reason})
3000
+ cancellation_reason = CASE WHEN ${reason} = 'routine_deleted' THEN ${reason}
3001
+ WHEN ${reason} = 'routine_paused' AND cancellation_reason IS DISTINCT FROM 'routine_deleted' THEN ${reason}
3002
+ ELSE COALESCE(cancellation_reason, ${reason}) END
3001
3003
  WHERE id = ${input.turnId}
3002
3004
  AND conversation_id = ${input.conversationId}
3003
3005
  AND status IN ('queued', 'running', 'waiting_for_action')
@@ -36,6 +36,9 @@ const isAssistantPart = (entry: AiStoredMessage): boolean =>
36
36
  entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "tool_result");
37
37
 
38
38
  const sameLoop = (entry: AiStoredMessage, loopId: string | null): boolean => (loopId ? entry.loopId === loopId : entry.loopId === null);
39
+ /** Each delivered routine run is its own answer, with its own run link and system line, also when runs follow each other. */
40
+ const sameRoutineRun = (entry: AiStoredMessage, first: AiStoredMessage): boolean =>
41
+ (entry.meta?.scheduledTask?.occurrenceId ?? null) === (first.meta?.scheduledTask?.occurrenceId ?? null);
39
42
 
40
43
  const assistantActionEntry = (entries: AiStoredMessage[]): AiStoredMessage | null => {
41
44
  const assistantEntries = entries.filter((entry) => entry.kind === "message" && entry.message.role === "assistant");
@@ -107,7 +110,12 @@ export const buildAiMessageTimeline = (messages: AiStoredMessage[]): AiMessageTi
107
110
  pendingSteers = [];
108
111
  const entries = [entry];
109
112
  index += 1;
110
- while (index < messages.length && isAssistantPart(messages[index]!) && sameLoop(messages[index]!, loopId)) {
113
+ while (
114
+ index < messages.length &&
115
+ isAssistantPart(messages[index]!) &&
116
+ sameLoop(messages[index]!, loopId) &&
117
+ (loopId !== null || sameRoutineRun(messages[index]!, entry))
118
+ ) {
111
119
  entries.push(messages[index]!);
112
120
  index += 1;
113
121
  }
@@ -0,0 +1,45 @@
1
+ import {
2
+ type CapabilityActionWording,
3
+ capabilityActionOutcome,
4
+ capabilityActionSubject,
5
+ resolveCapabilityActionSentences,
6
+ } from "../_internal/capability-sentences";
7
+ import { displayToolName, isRecord } from "./chat/message-utils";
8
+ import type { AiTurnBlock } from "./protocol";
9
+ import { CLOUD_AI_TOOL_PRESENTATION } from "./tool-sentences";
10
+
11
+ type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
12
+
13
+ /**
14
+ * How the chat words one call that acts on something: an app Action through its saved presentation, a
15
+ * Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
16
+ */
17
+ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
18
+ const presentation = block.presentation;
19
+ if (presentation?.kind === "capability") {
20
+ if (presentation.capabilityKind !== "action") return null;
21
+ return {
22
+ title: presentation.title,
23
+ ...(presentation.sentences ? { sentences: presentation.sentences } : {}),
24
+ ...(presentation.fields ? { fields: presentation.fields } : {}),
25
+ };
26
+ }
27
+ const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
28
+ return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
29
+ };
30
+
31
+ /** What the call does or would do, as one sentence; `null` for calls that only read. */
32
+ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
33
+ const wording = toolWording(block, locale);
34
+ return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
35
+ };
36
+
37
+ /** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
38
+ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
39
+ const wording = toolWording(block, locale);
40
+ if (!wording) return null;
41
+ const result = isRecord(block.result) ? block.result : undefined;
42
+ // App Actions return their data inside the result envelope; Cloud tools return the data itself.
43
+ const data = block.presentation?.kind === "capability" ? result?.data : block.result;
44
+ return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
45
+ };
@@ -0,0 +1,33 @@
1
+ import { aiConversations } from "./store";
2
+ import { publishAiTurnAbort, publishAiWireEvent } from "./stream";
3
+ import type { AiTurnAbortRequest } from "./types";
4
+
5
+ /** Finish a committed cancellation request; shared by interactive and routine cancellation. */
6
+ export const finishAiTurnAbort = async (
7
+ input: { conversationId: string; turnId: string },
8
+ request: AiTurnAbortRequest,
9
+ active: Awaited<ReturnType<typeof aiConversations.getActiveTurn>> = null,
10
+ ): Promise<void> => {
11
+ if (!request.found) return;
12
+ // Tell any live owner to stop (its heartbeat also detects the cancel flag).
13
+ await publishAiTurnAbort(input).catch(() => undefined);
14
+
15
+ if (request.ownerless) {
16
+ const finalized = await aiConversations.completeTurn({ ...input, status: "aborted", error: null });
17
+ const config = await aiConversations.getTurnRunConfig(input);
18
+ if (finalized === "completed" && !(config?.kind === "chat" && config.background)) {
19
+ const attempt = active?.turn.id === input.turnId ? active.turn.attempt : 1;
20
+ const seq = (active?.turn.id === input.turnId ? active.liveSeq : 0) + 1;
21
+ await publishAiWireEvent({
22
+ v: 1,
23
+ conversationId: input.conversationId,
24
+ turnId: input.turnId,
25
+ attempt,
26
+ seq,
27
+ type: "turn_finished",
28
+ status: "aborted",
29
+ error: null,
30
+ }).catch(() => undefined);
31
+ }
32
+ }
33
+ };
package/src/ai/types.ts CHANGED
@@ -298,6 +298,10 @@ export type AiStoredMessage = {
298
298
  sourceHref?: string;
299
299
  };
300
300
  scheduledTask?: {
301
+ reason?: import("./chat-task-reasons").ChatTaskReason;
302
+ /** The message text is only the reason sentence; the chat words the reason itself instead. */
303
+ reasonOnly?: true;
304
+ stop?: { cause: "paused" | "deleted"; done: string[] };
301
305
  taskId: string;
302
306
  occurrenceId: string;
303
307
  scheduledFor: string;
@@ -613,6 +617,10 @@ export type AiConversationFileSnapshot = {
613
617
  };
614
618
 
615
619
  export type AiBackgroundRunContext = {
620
+ scheduledFor?: string;
621
+ trigger?: "scheduled" | "manual";
622
+ timezone?: string;
623
+ stopDeliveredAt?: string;
616
624
  taskId: string;
617
625
  occurrenceId: string;
618
626
  context: Array<{ seq: number; kind: "message" | "summary"; message: Message }>;
@@ -977,7 +985,10 @@ export type AiConversationService = {
977
985
  seq: number;
978
986
  }): Promise<boolean>;
979
987
  /** Record an abort wish. The caller finalizes ownerless turns itself. */
980
- requestTurnAbort(input: { conversationId: string; turnId: string; reason?: string }): Promise<AiTurnAbortRequest>;
988
+ requestTurnAbort(
989
+ input: { conversationId: string; turnId: string; reason?: string },
990
+ options?: { db: import("bun").SQL },
991
+ ): Promise<AiTurnAbortRequest>;
981
992
  completeTurn(input: {
982
993
  conversationId: string;
983
994
  turnId: string;