@k2b/cloud 0.29.0 → 0.30.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 (92) hide show
  1. package/package.json +5 -3
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +203 -185
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
  17. package/src/ai/chat/message-utils.ts +3 -0
  18. package/src/ai/chat/messages.ts +12 -26
  19. package/src/ai/chat/tool-disclosure.tsx +21 -14
  20. package/src/ai/chat/tool-groups.ts +64 -37
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +4 -4
  23. package/src/ai/chat/turn-view.tsx +141 -69
  24. package/src/ai/code-runtime-tools.ts +59 -44
  25. package/src/ai/default-tool-contracts.ts +198 -0
  26. package/src/ai/default-tools.ts +25 -1
  27. package/src/ai/executor.ts +19 -21
  28. package/src/ai/index.ts +9 -1
  29. package/src/ai/migrate.ts +59 -12
  30. package/src/ai/protocol.ts +9 -4
  31. package/src/ai/runtime-tools.ts +1 -0
  32. package/src/ai/skill-content.ts +54 -0
  33. package/src/ai/skills-routes.ts +22 -13
  34. package/src/ai/skills.ts +177 -231
  35. package/src/ai/store.ts +20 -13
  36. package/src/ai/tool-sentences.ts +38 -0
  37. package/src/ai/types.ts +10 -2
  38. package/src/api/admin-ai-skills.ts +35 -16
  39. package/src/api/admin-outgoing-mail.ts +18 -0
  40. package/src/api/fanout.ts +128 -0
  41. package/src/api/help.ts +8 -3
  42. package/src/api/mcp.ts +4 -1
  43. package/src/api/search.ts +31 -106
  44. package/src/api/widgets.ts +259 -100
  45. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  46. package/src/browser/ndjson.ts +34 -0
  47. package/src/browser/reload.ts +46 -0
  48. package/src/browser/search-stream.ts +4 -26
  49. package/src/browser/widgets.ts +28 -0
  50. package/src/capabilities/server.ts +20 -2
  51. package/src/cli/access.ts +48 -14
  52. package/src/cli/admin/ai-skills.ts +59 -39
  53. package/src/cli/admin/outgoing-mail.ts +30 -2
  54. package/src/contracts/announcement-cookie.ts +62 -0
  55. package/src/contracts/announcements.ts +14 -52
  56. package/src/contracts/capabilities.ts +24 -0
  57. package/src/contracts/outgoing-mail.ts +56 -11
  58. package/src/contracts/pwa-paths.ts +7 -2
  59. package/src/contracts/pwa.ts +2 -7
  60. package/src/contracts/registry.ts +1 -0
  61. package/src/contracts/shared.ts +5 -0
  62. package/src/contracts/widgets.ts +20 -0
  63. package/src/index.ts +1 -0
  64. package/src/server/help.ts +8 -1
  65. package/src/services/accounts/users.ts +15 -0
  66. package/src/services/help/index.ts +18 -5
  67. package/src/services/help/types.ts +3 -2
  68. package/src/services/identity/constants.ts +2 -0
  69. package/src/services/identity/key-ring.ts +123 -42
  70. package/src/services/identity/runtime-config.ts +13 -2
  71. package/src/services/outbox.ts +24 -9
  72. package/src/services/outgoing-mail/index.ts +33 -5
  73. package/src/services/outgoing-mail/messages.ts +60 -19
  74. package/src/services/outgoing-mail/store.ts +63 -4
  75. package/src/shared/network-address.ts +1 -0
  76. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  77. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  78. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  79. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  80. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  81. package/src/ssr/PwaRuntime.island.tsx +1 -1
  82. package/src/ssr/app-navigation.ts +5 -7
  83. package/src/ssr/global-search-host.tsx +55 -0
  84. package/src/ssr/help.ts +3 -3
  85. package/src/ssr/rail-context.ts +4 -3
  86. package/src/ssr/runtime.ts +14 -1
  87. package/src/styles/effects.css +142 -0
  88. package/src/styles/utilities-markdown-table.css +5 -1
  89. package/src/ai/code-mode-skill.ts +0 -114
  90. package/src/ai/data-analysis-skill.ts +0 -10
  91. package/src/ai/grids-skill.ts +0 -158
  92. package/src/ai/skill-seeds.ts +0 -560
@@ -17,17 +17,34 @@ import {
17
17
  import { markdown } from "../../shared";
18
18
  import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
19
19
  import type { AiTurnError } from "../types";
20
- import { ApprovalBlockView, CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
20
+ import { toolOutcome, toolSubject } from "./action-sentences";
21
+ import {
22
+ type AiApprovalDecision,
23
+ ApprovalBlockView,
24
+ CompactionBlockView,
25
+ CompactToolRow,
26
+ SurveyToolView,
27
+ TextEditorToolView,
28
+ } from "./blocks";
21
29
  import { CapabilityTablePreview } from "./capability-table";
30
+ import { CloudChartBlock } from "./chart-block";
22
31
  import { PresentToolBlock } from "./file-tools";
23
32
  import { useAiChatActions } from "./message-actions";
24
- import { aiToolIcon, capabilityErrorDescription, displayToolName, isCardToolName, isRecord, isSurveyToolName } from "./message-utils";
33
+ import {
34
+ aiToolIcon,
35
+ capabilityErrorDescription,
36
+ displayToolName,
37
+ isCardToolName,
38
+ isChartToolName,
39
+ isRecord,
40
+ isSurveyToolName,
41
+ } from "./message-utils";
25
42
  import { aiChatMessages } from "./messages";
26
43
  import { AssistantMarkdownBlock } from "./primitives";
27
44
  import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
28
- import { type AiWorkEntry, countWorkSteps, groupWorkBlocks, summarizeToolGroup } from "./tool-groups";
45
+ import { type AiWorkEntry, type AiWorkItem, countWorkSteps, groupWorkBlocks } from "./tool-groups";
29
46
  import { aiTurnErrorCanContinue, aiTurnErrorDescription } from "./turn-error";
30
- import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
47
+ import type { AiTurnAction, AiTurnActionState, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
31
48
  import { CloudCardBlock } from "./visual-tools";
32
49
 
33
50
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
@@ -88,10 +105,12 @@ export const formatWorkClock = (ms: number): string => {
88
105
 
89
106
  const basename = (value: string) => value.slice(value.lastIndexOf("/") + 1) || value;
90
107
 
108
+ /** What a call acts on: the file name of its path, or its name, title, or query as given. */
91
109
  const toolTarget = (block: ToolBlock): string => {
92
110
  const args = isRecord(block.args) ? block.args : {};
93
- const value = [args.path, args.name, args.title, args.query].find((entry) => typeof entry === "string" && entry.trim());
94
- return typeof value === "string" ? basename(value.trim()) : "";
111
+ if (typeof args.path === "string" && args.path.trim()) return basename(args.path.trim());
112
+ const value = [args.name, args.title, args.query].find((entry) => typeof entry === "string" && entry.trim());
113
+ return typeof value === "string" ? value.trim() : "";
95
114
  };
96
115
 
97
116
  /** A step that runs longer than this shows its duration, so a long step does not look like a stalled one. */
@@ -124,6 +143,7 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
124
143
  if (name === "present") return withTarget(t.stepDeliveringFile);
125
144
  if (name === "code_present") return withTarget(t.stepPreparingView);
126
145
  if (isCardToolName(name)) return t.stepPreparingCard;
146
+ if (isChartToolName(name)) return t.stepPreparingChart;
127
147
  if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
128
148
  return withTarget(t.stepWritingCode);
129
149
  if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
@@ -234,6 +254,7 @@ function ThoughtRow(props: { block: Extract<AiTurnBlock, { kind: "thinking" }>;
234
254
  blockId={props.block.id}
235
255
  label={firstLine()}
236
256
  icon="ti ti-bulb"
257
+ bodyInset={false}
237
258
  renderBody={() => <p class="ai-turn-steps__thought">{props.block.text.trim()}</p>}
238
259
  />
239
260
  </Show>
@@ -254,25 +275,26 @@ function WorkEntryRow(props: { entry: AiWorkEntry; busy: boolean }) {
254
275
  );
255
276
  }
256
277
 
257
- function WorkGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
278
+ /** Housekeeping folded into one row at step level. It opens in place: its steps follow at the same indent. */
279
+ function HousekeepingGroup(props: { id: string; entries: AiWorkEntry[]; active: boolean }) {
258
280
  const locale = useLocale();
259
- const tools = () => props.entries.filter((entry): entry is ToolBlock => entry.kind === "tool");
260
281
  const last = () => props.entries.at(-1)!;
261
282
  return (
262
- <Show when={props.entries.length > 1} fallback={<WorkEntryRow entry={last()} busy={props.active} />}>
263
- <AiToolActivity
264
- blockId={`group:${props.id}`}
265
- label={tools().length ? summarizeToolGroup(tools(), locale()) : aiChatMessages(locale()).reasoning}
266
- description={countWorkSteps(props.entries, locale())}
267
- icon="ti ti-stack-2"
268
- busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
269
- renderBody={() => (
283
+ <AiToolActivity
284
+ blockId={`group:${props.id}`}
285
+ label={aiChatMessages(locale()).groupLoad}
286
+ description={countWorkSteps(props.entries, locale())}
287
+ icon="ti ti-stack-2"
288
+ bodyInset={false}
289
+ busy={props.active && last().kind === "tool" && (last() as ToolBlock).status === "running"}
290
+ renderBody={() => (
291
+ <div class="ai-turn-steps">
270
292
  <For each={props.entries}>
271
293
  {(entry, index) => <WorkEntryRow entry={entry} busy={props.active && index() === props.entries.length - 1} />}
272
294
  </For>
273
- )}
274
- />
275
- </Show>
295
+ </div>
296
+ )}
297
+ />
276
298
  );
277
299
  }
278
300
 
@@ -288,34 +310,46 @@ function WorkText(props: { text: string }) {
288
310
  );
289
311
  }
290
312
 
291
- /** Expanded work: intermediate texts as quiet paragraphs, the steps between them as groups. One indent, one size. */
313
+ const workItemKey = (item: AiWorkItem): string =>
314
+ item.kind === "text" ? `text:${item.block.id}` : item.kind === "step" ? `step:${item.entry.id}` : `group:${item.id}`;
315
+
316
+ /**
317
+ * Expanded work, one indent below the work line and one size: intermediate texts as quiet paragraphs, the steps between
318
+ * them directly, and housekeeping folded at step level. A row shown while this list is open never folds away under the
319
+ * reader, also when reasoning or arguments that would fold it arrive later; the next time it opens, it folds like in
320
+ * history.
321
+ */
292
322
  function AiWorkSteps(props: { blocks: AiTurnBlock[]; active: boolean }) {
293
- const groups = createMemo(() => groupWorkBlocks(props.blocks));
294
- const keyed = createMemo(
295
- () => new Map(groups().map((group) => [group.kind === "text" ? `text:${group.block.id}` : `steps:${group.id}`, group])),
296
- );
297
- const keys = createMemo(() => [...keyed().keys()]);
323
+ const shown = new Set<string>();
324
+ const items = createMemo(() => {
325
+ const work = groupWorkBlocks(props.blocks, shown);
326
+ for (const item of work) if (item.kind === "step") shown.add(item.entry.id);
327
+ return new Map(work.map((item) => [workItemKey(item), item]));
328
+ });
329
+ const keys = createMemo(() => [...items().keys()]);
298
330
  return (
299
331
  <div class="ai-turn-steps">
300
332
  <For each={keys()}>
301
333
  {(key, index) => {
302
- const group = () => keyed().get(key);
334
+ const item = () => items().get(key);
303
335
  const text = () => {
304
- const value = group();
336
+ const value = item();
305
337
  return value?.kind === "text" ? value.block.text : undefined;
306
338
  };
307
- const steps = () => {
308
- const value = group();
309
- return value?.kind === "steps" ? value : undefined;
339
+ const step = () => {
340
+ const value = item();
341
+ return value?.kind === "step" ? value.entry : undefined;
342
+ };
343
+ const group = () => {
344
+ const value = item();
345
+ return value?.kind === "housekeeping" ? value : undefined;
310
346
  };
347
+ const active = () => props.active && index() === keys().length - 1;
311
348
  return (
312
349
  <Switch>
313
- <Match when={text() !== undefined}>
314
- <WorkText text={text()!} />
315
- </Match>
316
- <Match when={steps()}>
317
- {(value) => <WorkGroup id={value().id} entries={value().entries} active={props.active && index() === keys().length - 1} />}
318
- </Match>
350
+ <Match when={text()}>{(value) => <WorkText text={value()} />}</Match>
351
+ <Match when={step()}>{(entry) => <WorkEntryRow entry={entry()} busy={active()} />}</Match>
352
+ <Match when={group()}>{(value) => <HousekeepingGroup id={value().id} entries={value().entries} active={active()} />}</Match>
319
353
  </Switch>
320
354
  );
321
355
  }}
@@ -413,6 +447,9 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
413
447
  <Match when={isCardToolName(current().name)}>
414
448
  <CloudCardBlock args={current().args} />
415
449
  </Match>
450
+ <Match when={isChartToolName(current().name)}>
451
+ <CloudChartBlock args={current().args} completed={current().status === "completed"} />
452
+ </Match>
416
453
  <Match when={true}>
417
454
  <Show when={!props.receipt()}>
418
455
  <CapabilityResultRow block={current()} />
@@ -428,31 +465,51 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
428
465
  );
429
466
  }
430
467
 
431
- function AiReceipt(props: { action: AiTurnAction; stopped: boolean }) {
468
+ type ReceiptState = Exclude<AiTurnActionState, "open" | "interaction">;
469
+
470
+ /**
471
+ * One line for an action in place 4, the same element while it runs and once it is done, rejected, failed, or not run.
472
+ * It says what happened in the owning app's words: its sentence for the outcome, such as "Moved report.pdf to the
473
+ * trash", else, once done, the application's own summary of the call. Without either it puts the state in the reader's
474
+ * language before what the call does, such as "Rejected: Write report.md", or before its title and target.
475
+ */
476
+ function AiReceipt(props: { block: ToolBlock; state: ReceiptState; stopped: boolean }) {
432
477
  const locale = useLocale();
433
478
  const t = () => aiChatMessages(locale());
434
- const block = () => props.action.block;
435
- const title = () => block().presentation?.title ?? displayToolName(block().name, locale());
436
- const links = () => (props.action.state === "done" ? resultLinks(block()) : []);
479
+ const block = () => props.block;
480
+ const subject = () => {
481
+ const sentence = toolSubject(block(), locale());
482
+ if (sentence) return sentence;
483
+ const title = block().presentation?.title ?? displayToolName(block().name, locale());
484
+ const target = toolTarget(block());
485
+ return target ? `${title} · ${target}` : title;
486
+ };
487
+ const capabilityAction = () => block().presentation?.kind === "capability";
488
+ const links = () => (props.state === "done" ? resultLinks(block()) : []);
437
489
  const label = () => {
438
- const state = props.action.state;
490
+ const state = props.state;
491
+ if (state === "running") return t().receiptRunning({ title: subject() });
439
492
  if (state === "done") {
440
- const summary = resultSummary(block());
441
- if (summary) return summary;
442
493
  // A tool that is not a Cloud action has no effect of its own to report; the receipt records the decision.
443
- return block().presentation?.kind === "capability" ? t().receiptDone({ title: title() }) : t().receiptApproved({ title: title() });
494
+ if (!capabilityAction()) return t().receiptApproved({ title: subject() });
495
+ return toolOutcome(block(), "done", locale()) || resultSummary(block()) || t().receiptDone({ title: subject() });
444
496
  }
445
- if (state === "rejected") return t().receiptRejected({ title: title() });
446
- if (state === "failed") return t().receiptFailed({ title: title() });
447
- return t().receiptNotRun({ title: title(), stopped: props.stopped });
497
+ if (state === "rejected") return toolOutcome(block(), "rejected", locale()) ?? t().receiptRejected({ title: subject() });
498
+ if (state === "failed") return t().receiptFailed({ title: subject() });
499
+ const notRun = toolOutcome(block(), "notRun", locale());
500
+ return notRun
501
+ ? t().receiptNotRunSentence({ sentence: notRun, stopped: props.stopped })
502
+ : t().receiptNotRun({ title: subject(), stopped: props.stopped });
448
503
  };
504
+ const declined = () => props.state === "rejected" || props.state === "not_run";
449
505
  return (
450
506
  <Chat.Activity
451
507
  class="ai-turn-receipt"
452
- icon={aiToolIcon(block().name, block().presentation?.appIcon)}
453
- accent={block().presentation?.appAccent}
508
+ icon={declined() ? "ti ti-circle-off" : aiToolIcon(block().name, block().presentation?.appIcon)}
509
+ accent={declined() ? undefined : block().presentation?.appAccent}
454
510
  label={label()}
455
- description={props.action.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
511
+ busy={props.state === "running"}
512
+ description={props.state === "failed" ? capabilityErrorDescription(block().result) : undefined}
456
513
  trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
457
514
  />
458
515
  );
@@ -463,8 +520,26 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
463
520
  let row!: HTMLDivElement;
464
521
  // Set while focus is in this place, including after the focused control disappeared with the decided card.
465
522
  let focusedHere = false;
523
+ // The decision the server accepted for a card here; its receipt replaces the card before the turn reports it. It
524
+ // belongs to the call it answered: code that asks again shows the next request on the same block as a new card.
525
+ const [decided, setDecided] = createSignal<AiApprovalDecision | null>(null);
466
526
  const action = () => props.action();
467
527
  const state = () => action()?.state;
528
+ const decision = () => {
529
+ const current = action();
530
+ const value = decided();
531
+ return current && value?.callId === current.block.callId ? value.approved : null;
532
+ };
533
+ const card = () => {
534
+ const current = action();
535
+ return current?.state === "open" && current.block.status === "awaiting_approval" && decision() === null ? current.block : undefined;
536
+ };
537
+ const receipt = (): ReceiptState | undefined => {
538
+ const current = action();
539
+ if (!current || card()) return undefined;
540
+ if (current.state === "open" && current.block.status === "awaiting_approval") return decision() ? "running" : "rejected";
541
+ return current.state === "open" || current.state === "interaction" ? undefined : current.state;
542
+ };
468
543
  // Screen readers hear a waiting approval once, as one short line instead of the whole card, without moving focus.
469
544
  createEffect(
470
545
  on(state, (next, previous) => {
@@ -472,18 +547,22 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
472
547
  if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
473
548
  announce(
474
549
  aiChatMessages(locale()).approvalNeeded({
475
- title: current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
550
+ title: toolSubject(current.block, locale()) ?? current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
476
551
  }),
477
552
  );
478
553
  }),
479
554
  );
480
- // A card the user decided becomes its receipt in place. Focus stays on that place without scrolling.
555
+ // A card the user decided becomes its receipt in place, once, as the direct result of the decision. Focus stays on
556
+ // that place without scrolling.
481
557
  createEffect(
482
- on(state, (next, previous) => {
483
- if (previous !== "open" || next === "open" || !focusedHere || typeof document === "undefined") return;
484
- const focused = document.activeElement;
485
- if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
486
- }),
558
+ on(
559
+ () => card() !== undefined,
560
+ (open, wasOpen) => {
561
+ if (!wasOpen || open || !focusedHere || typeof document === "undefined") return;
562
+ const focused = document.activeElement;
563
+ if (!focused || focused === document.body || row.contains(focused)) row.focus({ preventScroll: true });
564
+ },
565
+ ),
487
566
  );
488
567
  return (
489
568
  <div
@@ -500,11 +579,12 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
500
579
  >
501
580
  <Show when={action()}>
502
581
  {(current) => (
503
- <Switch fallback={<AiReceipt action={current()} stopped={props.phase === "stopped"} />}>
504
- <Match when={current().state === "open" && current().block.status === "awaiting_approval"}>
505
- <ApprovalBlockView turnId={props.turnId} block={current().block} />
582
+ <Switch>
583
+ <Match when={card()}>{(block) => <ApprovalBlockView turnId={props.turnId} block={block()} onDecided={setDecided} />}</Match>
584
+ <Match when={receipt()}>
585
+ {(receiptState) => <AiReceipt block={current().block} state={receiptState()} stopped={props.phase === "stopped"} />}
506
586
  </Match>
507
- <Match when={current().state === "open" || current().state === "interaction"}>
587
+ <Match when={true}>
508
588
  <Switch
509
589
  fallback={
510
590
  <Chat.Activity
@@ -522,14 +602,6 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
522
602
  </Match>
523
603
  </Switch>
524
604
  </Match>
525
- <Match when={current().state === "running"}>
526
- <Chat.Activity
527
- label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
528
- icon={aiToolIcon(current().block.name, current().block.presentation?.appIcon)}
529
- accent={current().block.presentation?.appAccent}
530
- busy
531
- />
532
- </Match>
533
605
  </Switch>
534
606
  )}
535
607
  </Show>
@@ -3,8 +3,10 @@ import { z } from "zod";
3
3
  import { readBoundedJson } from "../_internal/bounded-json";
4
4
  import { getApp } from "../_internal/registry";
5
5
  import type { RequestActor } from "../server";
6
+ import { IDENTITY_REFRESH_TIMEOUT_MS } from "../services/identity/constants";
6
7
  import { signInvocationToken } from "../services/identity/invocation-token";
7
8
  import { withActiveIdentitySigner } from "../services/identity/key-ring";
9
+ import { logger } from "../services/logging";
8
10
  import { LOCALE_HEADER } from "../shared/locale";
9
11
  import { TIMEZONE_COOKIE } from "../shared/time";
10
12
  import { CodeToolFailure } from "./browser-code-contracts";
@@ -13,6 +15,9 @@ import { CODE_CAPABILITY_TOKEN_HEADER, codeCapabilityOperation } from "./code-ca
13
15
  import { authorizeCodeExecution } from "./code-execution";
14
16
  import { aiConversations } from "./store";
15
17
 
18
+ const log = logger("ai:code-runtime");
19
+ const ISSUANCE_TIMEOUT_MS = 5_000;
20
+
16
21
  const Reply = z.object({
17
22
  ok: z.literal(true),
18
23
  data: z.object({
@@ -49,54 +54,64 @@ export const runManagedCodeTool =
49
54
  await context.reportProgress?.(context.locale?.startsWith("de") ? "Ausführungshost verbinden" : "Connecting execution host");
50
55
  const app = await getApp("assistant");
51
56
  if (!app) throw new Error("Assistant code host is unavailable");
57
+ const issue = async (audience: { targetAppId: string; callingAppId: string; operation: string }) => {
58
+ // A token is signed before its request goes out, so a timed-out issuance
59
+ // never reached the host and is safe to repeat. Retry long enough for a
60
+ // stalled shared refresh to finish or fall back to the cached key. Every
61
+ // timeout used up a full attempt or ended a shared load, so this cannot spin.
62
+ const giveUpAt = Date.now() + IDENTITY_REFRESH_TIMEOUT_MS + ISSUANCE_TIMEOUT_MS;
63
+ while (true) {
64
+ try {
65
+ return await withActiveIdentitySigner(
66
+ "invocation",
67
+ (signer) =>
68
+ signInvocationToken({
69
+ ...audience,
70
+ schemaHash: null,
71
+ authority: {
72
+ sub: actor.user.id,
73
+ principal_type: "user",
74
+ access_subject_type: "user",
75
+ access_subject_id: actor.user.id,
76
+ credential_kind: "session",
77
+ scopes: [],
78
+ },
79
+ signer,
80
+ issuer: signer.issuer,
81
+ }),
82
+ { signal: context.signal, timeoutMs: ISSUANCE_TIMEOUT_MS },
83
+ );
84
+ } catch (error) {
85
+ if (context.signal.aborted || !(error instanceof DOMException && error.name === "TimeoutError")) throw error;
86
+ if (Date.now() < giveUpAt) continue;
87
+ log.warn("Code call gave up on identity issuance", { tool: name, error: error.message });
88
+ throw new Error(
89
+ context.locale?.startsWith("de")
90
+ ? "Der Identitätsdienst hat nicht rechtzeitig geantwortet. Prüfe, ob dieser Aufruf schon gestartet ist, bevor du ihn erneut ausführst."
91
+ : "The identity service did not respond in time. Check whether this call already started before running it again.",
92
+ { cause: error },
93
+ );
94
+ }
95
+ }
96
+ };
97
+ // Polls run every 250 ms for as long as the code runs. Reuse both tokens
98
+ // while they stay valid for at least 10 s, so a long run or an approval
99
+ // round trip does not depend on a fresh issuance for every poll.
100
+ const usable = (token: Awaited<ReturnType<typeof signInvocationToken>> | undefined) =>
101
+ token && token.claims.exp * 1000 - Date.now() >= 10_000 ? token : undefined;
102
+ let signed: Awaited<ReturnType<typeof signInvocationToken>> | undefined;
52
103
  let callback: Awaited<ReturnType<typeof signInvocationToken>> | undefined;
53
104
  const request = async (decision?: { id: string; approved: boolean }) => {
54
105
  context.signal.throwIfAborted();
55
106
  await authorizeCodeExecution(context.conversationId!, context.turnId!, actor.user.id);
56
- const signed = await withActiveIdentitySigner(
57
- "invocation",
58
- (signer) =>
59
- signInvocationToken({
60
- targetAppId: "assistant",
61
- callingAppId: "core",
62
- operation: `tool:${name}`,
63
- schemaHash: null,
64
- authority: {
65
- sub: actor.user.id,
66
- principal_type: "user",
67
- access_subject_type: "user",
68
- access_subject_id: actor.user.id,
69
- credential_kind: "session",
70
- scopes: [],
71
- },
72
- signer,
73
- issuer: signer.issuer,
74
- }),
75
- { signal: context.signal, timeoutMs: 5000 },
76
- );
77
- if (!callback || callback.claims.exp * 1000 - Date.now() < 10_000) {
78
- callback = await withActiveIdentitySigner(
79
- "invocation",
80
- (signer) =>
81
- signInvocationToken({
82
- targetAppId: "core",
83
- callingAppId: "assistant",
84
- operation: codeCapabilityOperation(context.conversationId!, context.turnId!),
85
- schemaHash: null,
86
- authority: {
87
- sub: actor.user.id,
88
- principal_type: "user",
89
- access_subject_type: "user",
90
- access_subject_id: actor.user.id,
91
- credential_kind: "session",
92
- scopes: [],
93
- },
94
- signer,
95
- issuer: signer.issuer,
96
- }),
97
- { signal: context.signal, timeoutMs: 5000 },
98
- );
99
- }
107
+ signed = usable(signed) ?? (await issue({ targetAppId: "assistant", callingAppId: "core", operation: `tool:${name}` }));
108
+ callback =
109
+ usable(callback) ??
110
+ (await issue({
111
+ targetAppId: "core",
112
+ callingAppId: "assistant",
113
+ operation: codeCapabilityOperation(context.conversationId!, context.turnId!),
114
+ }));
100
115
  const headers = new Headers({ authorization: `Bearer ${signed.token}`, "content-type": "application/json" });
101
116
  headers.set(CODE_CAPABILITY_TOKEN_HEADER, callback.token);
102
117
  if (context.locale) headers.set(LOCALE_HEADER, context.locale);