@k2b/cloud 0.22.0 → 0.24.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
4
4
 
5
5
  export const ASSISTANT_CODE_MODE_SKILL = {
6
6
  "key": "assistant:code-mode",
7
- "version": 56,
7
+ "version": 57,
8
8
  "name": "assistant-code-mode",
9
9
  "description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
10
10
  "instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, save one in Files, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
@@ -64,7 +64,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
64
64
  },
65
65
  {
66
66
  "path": "references/files.md",
67
- "content": "# Explicit file references and transfers\n\nFiles belong to a chat, Project, or App shared store. A location is\n`{scope:\"chat\"|\"project\"|\"app\",id,path}`. A reference adds an opaque string\n`version`. The current chat ID is supplied as `Chat:` in the platform context;\nuse it for `scope:\"chat\"` rather than guessing an ID. Reuse returned locations and references exactly; access to a location\nnever grants access to its whole store or to another resource.\n\nLoad only `code_files`, `code_file_stat`, and `code_file_copy` as needed.\n\n1. `code_files({scope,id,after?:string,limit?:number})` returns `container`,\n `items:[{location,path,size,mediaType}]`, and `nextAfter`. Limit defaults to\n 100, maximum 1,000. Continue with `nextAfter` until null.\n2. `code_file_stat({file:location})` returns `{exists:false}` or\n `{exists:true,reference,size,mediaType}`. It does not print file bytes.\n3. `code_file_copy({source:reference,destination:location,expectedVersion})`\n copies bytes on the server. For a new destination use `expectedVersion:null`;\n replacing a file requires its exact current version from `code_file_stat`.\n The result contains the destination `reference,size,mediaType`.\n\nEvery copy receives fresh user review with the exact source, destination, and\noverwrite scope. App and Project files may be readable by other authorized\nusers; copying a private chat attachment there is an explicit disclosure.\nRejection does not copy anything. Source versions, destination versions, current\npermissions, and the destination's byte limits are checked again during execution.\nAfter a conflict, inspect current state and prepare a new review before retrying.\n\nApp file reads and writes require Use, not Manage. Project files require Read\nfor sources and Write for destinations. Chat files require ownership. Transfers\nwork without a running UI and do not mount other chat attachments implicitly.\n\nFor example, inspect an invoice attachment, copy its reference into the target\nApp's shared file store, then call the App's published `importFiles` action with\nthe destination key expected by its discovered input schema. Do not pass a chat\npath to an App and assume it can read it. Action handlers see only their explicit\ninput and authorized App data. Use [App actions](app-actions.md) for discovery.\n\nFor small UTF-8 files that should become App source, use\n`code_write({id,expectedRevision,files:[{path:\"data.json\",fromFile:reference}]})`.\nThis is a reviewed import with the same source references, not a chat-only\nspecial case. Source file/bundle limits still apply; keep larger or private\nruntime data in shared files or the database instead of embedding it in source.\n\nKnown rejections return `CONFLICT` for an occupied or changed destination and\n`STORAGE_FULL` for a destination byte limit. No destination bytes were written.\nChoose another path or reduce the file size, then prepare a new review.\n\n\n## List Filesv2 files beside Grids documents\n\nDiscover the installed contracts first. Call `filesv2.bases.list`, then\n`filesv2.entry.list` for a folder or `filesv2.entry.search-in-base` for names\nbelow a known path. Keep the filters unchanged and follow `data.next` as\n`after` until null, even after an empty page. Each `data.items` entry includes\nits own `{type:\"filesv2.entry\",id}` ref and file metadata. Keep that ref in the\nStudio list, alongside the `grids.document` refs from `grids.document.list`.\nUse both `type` and `id` as the identity; dispatch each type to its own\noperations. Grids uses its own `page` cursor, not Filesv2's `data.next`.\n\nFilesv2 refs identify a base and path, including long paths; they do not grant\naccess or pin a content version. Moving or renaming changes the ref, and\nreplacing bytes at the same path keeps it. Refresh metadata when needed.\nNever construct storage URLs or turn files into public shares for this flow.\n\nOnly on a user's download request, call `filesv2.content.download` with the\nselected Filesv2 ref's exact `id`. Its `data` is `{url,method:\"GET\",expires}`.\nOffer that returned URL unchanged to the requesting user; it is a private\nbearer credential, not a stable resource link. It expires after 60 seconds;\nuse the returned `expires` timestamp and request a fresh lease when needed.\nDo not prefetch leases for list rows, persist them in App/shared data, or\ninclude them in logs. Do not send Cloud cookies or authorization headers to\nthe storage host. Grids documents keep their authenticated download path from\ntheir canonical reader; never send a `grids.document` ID to Filesv2.\n\nEach lease request checks the current user's storage and Unix permissions.\nA 403 means access is denied; a 404 means the ref or file is missing or the\nbase is no longer visible. Refresh the list and do not bypass the denial.\n`not_file` (400) means a folder was selected. `identity_changed` (409) requires\nrefreshing access/identity state before trying again. Storage unavailability is an\nerror, not an empty list. If a lease expires or a transfer fails, discard the\nURL and request a fresh lease through the same capability; if that is denied,\nstop. Revoking Cloud access prevents new leases; an already issued bearer\nlease can remain usable until expiry, subject to storage checks.\n\nFor analysis inside code, use `filesv2.content.read` and\n`capabilities.streams.read` instead of fetching a bearer URL. See\n[Capability calls](capabilities.md) for binary budgets and consent rules.\n"
67
+ "content": "# Explicit file references and transfers\n\nFiles belong to a chat, Project, or App shared store. A location is\n`{scope:\"chat\"|\"project\"|\"app\",id,path}`. A reference adds an opaque string\n`version`. The current chat ID is supplied as `Chat:` in the platform context;\nuse it for `scope:\"chat\"` rather than guessing an ID. Reuse returned locations and references exactly; access to a location\nnever grants access to its whole store or to another resource.\n\nLoad only `code_files`, `code_file_stat`, and `code_file_copy` as needed.\n\n1. `code_files({scope,id,after?:string,limit?:number})` returns `container`,\n `items:[{location,path,size,mediaType}]`, and `nextAfter`. Limit defaults to\n 100, maximum 1,000. Continue with `nextAfter` until null.\n2. `code_file_stat({file:location})` returns `{exists:false}` or\n `{exists:true,reference,size,mediaType}`. It does not print file bytes.\n3. `code_file_copy({source:reference,destination:location,expectedVersion})`\n copies bytes on the server. For a new destination use `expectedVersion:null`;\n replacing a file requires its exact current version from `code_file_stat`.\n The result contains the destination `reference,size,mediaType`.\n\nEvery copy receives fresh user review with the exact source, destination, and\noverwrite scope. App and Project files may be readable by other authorized\nusers; copying a private chat attachment there is an explicit disclosure.\nRejection does not copy anything. Source versions, destination versions, current\npermissions, and the destination's byte limits are checked again during execution.\nAfter a conflict, inspect current state and prepare a new review before retrying.\n\nApp file reads and writes require Use, not Manage. Project files require Read\nfor sources and Write for destinations. Chat files require ownership. Transfers\nwork without a running UI and do not mount other chat attachments implicitly.\n\nFor example, inspect an invoice attachment, copy its reference into the target\nApp's shared file store, then call the App's published `importFiles` action with\nthe destination key expected by its discovered input schema. Do not pass a chat\npath to an App and assume it can read it. Action handlers see only their explicit\ninput and authorized App data. Use [App actions](app-actions.md) for discovery.\n\nFor small UTF-8 files that should become App source, use\n`code_write({id,expectedRevision,files:[{path:\"data.json\",fromFile:reference}]})`.\nThis is a reviewed import with the same source references, not a chat-only\nspecial case. Source file/bundle limits still apply; keep larger or private\nruntime data in shared files or the database instead of embedding it in source.\n\nKnown rejections return `CONFLICT` for an occupied or changed destination and\n`STORAGE_FULL` for a destination byte limit. No destination bytes were written.\nChoose another path or reduce the file size, then prepare a new review.\n\n\n## List Filesv2 files beside Grids documents\n\nDiscover the installed contracts first. Call `filesv2.bases.list`, then\n`filesv2.entry.list` for a folder or `filesv2.entry.search-in-base` for names\nbelow a known path. Keep the filters unchanged and follow `data.next` as\n`after` until null, even after an empty page. Each `data.items` entry includes\nits own `{type:\"filesv2.entry\",id}` ref and file metadata. Keep that ref in the\nStudio list, alongside the `grids.document` refs from `grids.document.list`.\nUse both `type` and `id` as the identity; dispatch each type to its own\noperations. Grids uses its own `page` cursor, not Filesv2's `data.next`.\n\nFilesv2 refs are opaque; they do not grant access or pin a content version.\nOn storage with stable file IDs (`n:…` refs), a ref keeps naming the same file\nacross rename and move; elsewhere it names a base and path, so moving or\nrenaming changes it. Store refs exactly as returned and never build one from a\npath. Different refs can name the same file, because older path refs stay\nvalid. A 404 means not found or no longer visible; 503 is an outage, not a\ndeletion. Refresh metadata when needed. Never construct storage URLs or turn\nfiles into public shares for this flow.\n\nOnly on a user's download request, call `filesv2.content.download` with the\nselected Filesv2 ref's exact `id`. Its `data` is `{url,method:\"GET\",expires}`.\nOffer that returned URL unchanged to the requesting user; it is a private\nbearer credential, not a stable resource link. It expires after 60 seconds;\nuse the returned `expires` timestamp and request a fresh lease when needed.\nDo not prefetch leases for list rows, persist them in App/shared data, or\ninclude them in logs. Do not send Cloud cookies or authorization headers to\nthe storage host. Grids documents keep their authenticated download path from\ntheir canonical reader; never send a `grids.document` ID to Filesv2.\n\nEach lease request checks the current user's storage and Unix permissions.\nA 403 means access is denied; a 404 means the ref or file is missing or the\nbase is no longer visible. Refresh the list and do not bypass the denial.\n`not_file` (400) means a folder was selected. `identity_changed` (409) requires\nrefreshing access/identity state before trying again. Storage unavailability is an\nerror, not an empty list. If a lease expires or a transfer fails, discard the\nURL and request a fresh lease through the same capability; if that is denied,\nstop. Revoking Cloud access prevents new leases; an already issued bearer\nlease can remain usable until expiry, subject to storage checks.\n\nFor analysis inside code, use `filesv2.content.read` and\n`capabilities.streams.read` instead of fetching a bearer URL. See\n[Capability calls](capabilities.md) for binary budgets and consent rules.\n"
68
68
  },
69
69
  {
70
70
  "path": "references/finance.md",
package/src/ai/index.ts CHANGED
@@ -128,17 +128,10 @@ export {
128
128
  export { AI_IMAGE_INPUT_MAX_BYTES, AI_TURN_ATTACHMENT_MAX_ITEMS, AI_TURN_IMAGE_MAX_TOTAL_BYTES } from "./limits";
129
129
  export {
130
130
  AI_INVALIDATION_DOMAINS,
131
- AI_LIVE_WS_TYPE,
132
131
  type AiInvalidation,
133
132
  type AiInvalidationDomain,
134
133
  AiInvalidationDomainSchema,
135
134
  AiInvalidationSchema,
136
- type AiLiveClientMessage,
137
- AiLiveClientMessageSchema,
138
- AiLiveCursorSchema,
139
- type AiLiveServerMessage,
140
- AiLiveServerMessageSchema,
141
- parseAiLiveServerMessage,
142
135
  } from "./live-events";
143
136
  export { AI_ENRICH_CRON_SETTING_KEY, AI_MEMORY_LEARNING_CRON_SETTING_KEY, aiMaintenanceJobs } from "./maintenance";
144
137
  export {
@@ -1,5 +1,4 @@
1
1
  import { z } from "zod";
2
- import type { AiStreamEvent } from "./protocol";
3
2
 
4
3
  export const AI_INVALIDATION_DOMAINS = [
5
4
  "conversation-list",
@@ -17,8 +16,8 @@ export const AiInvalidationDomainSchema = z.enum(AI_INVALIDATION_DOMAINS);
17
16
  export type AiInvalidationDomain = z.infer<typeof AiInvalidationDomainSchema>;
18
17
 
19
18
  export const AiResourceIdSchema = z.string().regex(/^[0-9A-Za-z]{6}$/);
20
- export const AiLiveCursorSchema = z.string().regex(/^s6t\.[a-zA-Z0-9_-]+\.\d+$/);
21
19
 
20
+ /** The data of one AI live update on the `user` channel of `/api/ai/live`: which views of the user's AI data changed. */
22
21
  export const AiInvalidationSchema = z
23
22
  .object({
24
23
  type: z.literal("ai.invalidated"),
@@ -31,263 +30,3 @@ export const AiInvalidationSchema = z
31
30
  .strict();
32
31
 
33
32
  export type AiInvalidation = z.infer<typeof AiInvalidationSchema>;
34
-
35
- export const AI_LIVE_WS_TYPE = {
36
- subscribe: "ai.live.subscribe",
37
- ready: "ai.live.ready",
38
- event: "ai.live.event",
39
- scopeChanged: "ai.live.scope_changed",
40
- revoked: "ai.live.revoked",
41
- error: "ai.live.error",
42
- turnSubscribe: "ai.turn.subscribe",
43
- turnUnsubscribe: "ai.turn.unsubscribe",
44
- turnEvent: "ai.turn.event",
45
- turnError: "ai.turn.error",
46
- } as const;
47
-
48
- const AiLiveSubscribeMessageSchema = z
49
- .object({
50
- type: z.literal(AI_LIVE_WS_TYPE.subscribe),
51
- payload: z.object({ fromCursor: AiLiveCursorSchema.nullable(), recover: z.boolean() }).strict(),
52
- })
53
- .strict();
54
-
55
- const AiTurnSubscribeMessageSchema = z
56
- .object({
57
- type: z.literal(AI_LIVE_WS_TYPE.turnSubscribe),
58
- payload: z.object({ conversationId: AiResourceIdSchema }).strict(),
59
- })
60
- .strict();
61
-
62
- const AiTurnUnsubscribeMessageSchema = z
63
- .object({
64
- type: z.literal(AI_LIVE_WS_TYPE.turnUnsubscribe),
65
- payload: z.object({ conversationId: AiResourceIdSchema }).strict(),
66
- })
67
- .strict();
68
-
69
- export const AiLiveClientMessageSchema = z.discriminatedUnion("type", [
70
- AiLiveSubscribeMessageSchema,
71
- AiTurnSubscribeMessageSchema,
72
- AiTurnUnsubscribeMessageSchema,
73
- ]);
74
- export type AiLiveClientMessage = z.infer<typeof AiLiveClientMessageSchema>;
75
-
76
- const AiLiveRevocationCodeSchema = z.enum(["login_required", "access_denied"]);
77
- const AiLiveErrorCodeSchema = z.enum(["invalid_json", "invalid_message", "backpressure", "internal_error", "stream_failed"]);
78
- const AiTurnErrorCodeSchema = z.enum(["not_found", "access_denied", "stream_failed"]);
79
-
80
- export type AiLiveRevocationCode = z.infer<typeof AiLiveRevocationCodeSchema>;
81
- export type AiLiveErrorCode = z.infer<typeof AiLiveErrorCodeSchema>;
82
- export type AiTurnErrorCode = z.infer<typeof AiTurnErrorCodeSchema>;
83
-
84
- const AiTurnBlockSchema = z.discriminatedUnion("kind", [
85
- z.object({ id: z.string(), kind: z.literal("text"), text: z.string() }).strict(),
86
- z.object({ id: z.string(), kind: z.literal("thinking"), text: z.string() }).strict(),
87
- z
88
- .object({
89
- id: z.string(),
90
- kind: z.literal("steer_message"),
91
- steerId: z.string(),
92
- text: z.string(),
93
- status: z.enum(["pending", "consumed", "failed"]),
94
- })
95
- .strict(),
96
- z.object({ id: z.string(), kind: z.literal("steer_applied"), steerId: z.string() }).strict(),
97
- z
98
- .object({
99
- id: z.string(),
100
- kind: z.literal("tool"),
101
- callId: z.string(),
102
- name: z.string(),
103
- args: z.unknown().optional(),
104
- status: z.enum(["running", "awaiting_approval", "awaiting_client", "completed", "failed", "rejected"]),
105
- result: z.unknown().optional(),
106
- isError: z.boolean().optional(),
107
- approval: z.unknown().optional(),
108
- frontendMode: z.enum(["client", "client_view", "client_interaction"]).optional(),
109
- presentation: z.unknown().optional(),
110
- })
111
- .strict(),
112
- z
113
- .object({
114
- id: z.string(),
115
- kind: z.literal("compaction"),
116
- status: z.enum(["running", "completed", "skipped", "failed"]),
117
- result: z.unknown().optional(),
118
- })
119
- .strict(),
120
- ]);
121
-
122
- const AiConversationSchema = z
123
- .object({
124
- id: AiResourceIdSchema,
125
- shortId: AiResourceIdSchema,
126
- title: z.string(),
127
- titleSource: z.enum(["default", "auto", "user"]),
128
- description: z.string(),
129
- descriptionSource: z.enum(["default", "auto", "user"]),
130
- keywords: z.array(z.string()),
131
- pinnedAt: z.string().nullable(),
132
- archivedAt: z.string().nullable(),
133
- done: z.boolean().nullable(),
134
- isDone: z.boolean(),
135
- lastUsedAt: z.string(),
136
- runStatus: z.enum(["idle", "queued", "running", "needs_attention", "waiting_for_browser", "failed"]),
137
- runError: z.string().nullable(),
138
- unreadCompletion: z.boolean(),
139
- projectId: z.string().nullable(),
140
- draft: z.object({ content: z.array(z.unknown()), revision: z.number().int(), updatedAt: z.string().nullable() }).strict(),
141
- createdByUserId: z.string().nullable(),
142
- createdAt: z.string(),
143
- updatedAt: z.string(),
144
- })
145
- .strict();
146
-
147
- const AiStoredMessageSchema = z
148
- .object({
149
- id: AiResourceIdSchema,
150
- shortId: AiResourceIdSchema,
151
- conversationId: AiResourceIdSchema,
152
- seq: z.number().int().nonnegative(),
153
- kind: z.enum(["message", "summary"]),
154
- message: z.unknown(),
155
- loopId: AiResourceIdSchema.nullable(),
156
- modelProfileId: z.string().nullable(),
157
- providerModel: z.string().nullable(),
158
- usage: z.unknown().nullable(),
159
- stopReason: z.string().nullable(),
160
- loopAggregate: z.unknown().nullable(),
161
- loopDoneReason: z.string().nullable(),
162
- compactedAt: z.string().nullable(),
163
- meta: z.unknown().nullable(),
164
- createdAt: z.string(),
165
- })
166
- .strict();
167
-
168
- const AiTurnSnapshotSchema = z
169
- .object({
170
- turnId: AiResourceIdSchema,
171
- attempt: z.number().int().nonnegative(),
172
- status: z.enum(["queued", "running", "waiting_for_action", "completed", "failed", "aborted"]),
173
- seq: z.number().int().nonnegative(),
174
- blocks: z.array(AiTurnBlockSchema),
175
- modelProfileId: z.string().nullable(),
176
- createdAt: z.string(),
177
- })
178
- .strict();
179
-
180
- const AiWireEventBaseSchema = {
181
- v: z.literal(1),
182
- conversationId: AiResourceIdSchema,
183
- turnId: AiResourceIdSchema,
184
- attempt: z.number().int().nonnegative(),
185
- seq: z.number().int().nonnegative(),
186
- } as const;
187
-
188
- const AiStreamEventWireSchema = z.union([
189
- z
190
- .object({
191
- type: z.literal("state"),
192
- conversation: AiConversationSchema,
193
- messages: z.array(AiStoredMessageSchema),
194
- hasMoreMessages: z.boolean().optional(),
195
- activeTurn: AiTurnSnapshotSchema.nullable(),
196
- })
197
- .strict(),
198
- z
199
- .object({
200
- ...AiWireEventBaseSchema,
201
- type: z.literal("turn_started"),
202
- modelProfileId: z.string(),
203
- providerModel: z.string(),
204
- blocks: z.array(AiTurnBlockSchema).optional(),
205
- })
206
- .strict(),
207
- z.object({ ...AiWireEventBaseSchema, type: z.literal("block_set"), block: AiTurnBlockSchema }).strict(),
208
- z
209
- .object({
210
- ...AiWireEventBaseSchema,
211
- type: z.literal("block_delta"),
212
- blockId: z.string(),
213
- blockKind: z.enum(["text", "thinking"]),
214
- delta: z.string(),
215
- })
216
- .strict(),
217
- z
218
- .object({
219
- ...AiWireEventBaseSchema,
220
- type: z.literal("turn_finished"),
221
- status: z.enum(["completed", "failed", "aborted"]),
222
- error: z.string().nullable(),
223
- messages: z.array(AiStoredMessageSchema).optional(),
224
- })
225
- .strict(),
226
- ]);
227
-
228
- export const AiStreamEventSchema = z.custom<AiStreamEvent>((value) => AiStreamEventWireSchema.safeParse(value).success);
229
-
230
- export const AiLiveServerMessageSchema = z.discriminatedUnion("type", [
231
- z
232
- .object({
233
- type: z.literal(AI_LIVE_WS_TYPE.ready),
234
- payload: z.object({ cursor: AiLiveCursorSchema, recovered: z.boolean() }).strict(),
235
- })
236
- .strict(),
237
- z
238
- .object({
239
- type: z.literal(AI_LIVE_WS_TYPE.event),
240
- payload: z.object({ cursor: AiLiveCursorSchema, event: AiInvalidationSchema }).strict(),
241
- })
242
- .strict(),
243
- z
244
- .object({
245
- type: z.literal(AI_LIVE_WS_TYPE.scopeChanged),
246
- payload: z.object({ at: z.string().datetime() }).strict(),
247
- })
248
- .strict(),
249
- z
250
- .object({
251
- type: z.literal(AI_LIVE_WS_TYPE.revoked),
252
- payload: z.object({ code: AiLiveRevocationCodeSchema, message: z.string().min(1).max(500) }).strict(),
253
- })
254
- .strict(),
255
- z
256
- .object({
257
- type: z.literal(AI_LIVE_WS_TYPE.error),
258
- payload: z.object({ code: AiLiveErrorCodeSchema, message: z.string().min(1).max(500) }).strict(),
259
- })
260
- .strict(),
261
- z
262
- .object({
263
- type: z.literal(AI_LIVE_WS_TYPE.turnEvent),
264
- payload: z.object({ conversationId: AiResourceIdSchema, event: AiStreamEventSchema }).strict(),
265
- })
266
- .strict(),
267
- z
268
- .object({
269
- type: z.literal(AI_LIVE_WS_TYPE.turnError),
270
- payload: z.object({ conversationId: AiResourceIdSchema, code: AiTurnErrorCodeSchema, message: z.string().min(1).max(500) }).strict(),
271
- })
272
- .strict(),
273
- ]);
274
-
275
- export type AiLiveServerMessage = z.infer<typeof AiLiveServerMessageSchema>;
276
-
277
- export const aiTurnEventMessage = (conversationId: string, event: AiStreamEvent): AiLiveServerMessage => ({
278
- type: AI_LIVE_WS_TYPE.turnEvent,
279
- payload: { conversationId, event },
280
- });
281
-
282
- export const parseAiLiveServerMessage = (raw: string): AiLiveServerMessage | null => {
283
- try {
284
- const parsed = AiLiveServerMessageSchema.safeParse(JSON.parse(raw));
285
- if (!parsed.success) return null;
286
- if (parsed.data.type !== AI_LIVE_WS_TYPE.turnEvent) return parsed.data;
287
- const eventConversationId =
288
- parsed.data.payload.event.type === "state" ? parsed.data.payload.event.conversation.id : parsed.data.payload.event.conversationId;
289
- return eventConversationId === parsed.data.payload.conversationId ? parsed.data : null;
290
- } catch {
291
- return null;
292
- }
293
- };
package/src/ai/live.ts CHANGED
@@ -1,2 +1,31 @@
1
- export { latestAiInvalidationCursor } from "./live-outbox";
2
- export { type AiLiveRoutes, type AiLiveRoutesConfig, aiLiveRoutes } from "./live-routes";
1
+ import { z } from "zod";
2
+ import { defineLive, type LiveViewer } from "../events/live";
3
+ import { AiInvalidationSchema } from "./live-events";
4
+
5
+ /**
6
+ * Live updates of a user's AI data. Core publishes and serves them at
7
+ * `/api/ai/live`; the AI's database triggers write them, keyed `u:<user ID>`,
8
+ * in the transaction of every change. Other applications, such as Assistant,
9
+ * read `cursor()` before their snapshot.
10
+ */
11
+ export const aiLive = defineLive({ appId: "core", event: AiInvalidationSchema });
12
+
13
+ /** The user an AI request acts for: the user, or the user a credential is delegated by. */
14
+ const ownerOf = (viewer: LiveViewer): string | null => (viewer.accessSubject.type === "user" ? viewer.accessSubject.userId : null);
15
+
16
+ /**
17
+ * `user` follows the viewer's own AI data: conversations, their files, tasks and
18
+ * dictations, and the Projects the viewer can read. Its single key admits only
19
+ * that user; who receives a Project change was decided when it was written.
20
+ */
21
+ export const aiLiveChannels = {
22
+ user: {
23
+ scope: z.object({}).strict(),
24
+ keys: async (_scope: unknown, viewer: LiveViewer) => {
25
+ const owner = ownerOf(viewer);
26
+ return owner ? [`u:${owner}`] : null;
27
+ },
28
+ authorize: async (key: string, viewers: readonly LiveViewer[]) =>
29
+ new Set(viewers.filter((viewer) => `u:${ownerOf(viewer)}` === key).map((viewer) => viewer.id)),
30
+ },
31
+ };
package/src/ai/migrate.ts CHANGED
@@ -13,6 +13,14 @@ const backfillAiShortIds = async (
13
13
  };
14
14
 
15
15
  export const migrateCloudAi = async (): Promise<void> => {
16
+ // AI writes its live updates to Core's platform outbox, in the transaction of every
17
+ // AI write: without events.enqueue(), every one of them would fail.
18
+ const [outbox] = await sql<{ ready: boolean }[]>`
19
+ SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
20
+ `;
21
+ if (!outbox?.ready) {
22
+ throw new Error("AI live updates write to events.outbox, which does not exist. Run Core's events migration before the AI migration.");
23
+ }
16
24
  await sql`CREATE SCHEMA IF NOT EXISTS ai`.simple();
17
25
  console.log(" ✓ ai schema");
18
26
 
@@ -1722,10 +1730,12 @@ export const migrateCloudAi = async (): Promise<void> => {
1722
1730
  WHERE project_id IS NOT NULL AND archived_at IS NULL
1723
1731
  `.simple();
1724
1732
 
1725
- // Durable Realtime UI invalidations. Triggers live at the persistence seam so
1726
- // route, worker, tool, enrichment, and scheduler writes cannot bypass them.
1727
- // The outbox row commits or rolls back with the authoritative domain write;
1728
- // publishing remains an after-commit runtime responsibility.
1733
+ // Live updates. Triggers live at the persistence seam so route, worker, tool,
1734
+ // enrichment, and scheduler writes cannot bypass them. They write to Core's
1735
+ // platform outbox in the transaction of the domain write (see the start of this migration).
1736
+
1737
+ // The previous outbox. Nothing writes it anymore; an older Core that still runs during
1738
+ // an update finds it empty. The cleanup release drops it with the topic cloud-ai-invalidations.
1729
1739
  await sql`
1730
1740
  CREATE TABLE IF NOT EXISTS ai.live_invalidation_outbox (
1731
1741
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1786,6 +1796,10 @@ export const migrateCloudAi = async (): Promise<void> => {
1786
1796
  DROP FUNCTION IF EXISTS ai.enqueue_live_for_user(UUID, TEXT, UUID, TEXT, TEXT, TEXT[])
1787
1797
  `.simple();
1788
1798
 
1799
+ // Every AI live update goes through this function: one update for one user,
1800
+ // keyed `u:<user>`, on Core's live topic. Its data is an `AiInvalidation`.
1801
+ // No coalesce key: the last payload would win and drop the domains of earlier ones.
1802
+ // Gateway health reports a body without events.enqueue: an older Core restored its own.
1789
1803
  await sql`
1790
1804
  CREATE OR REPLACE FUNCTION ai.enqueue_live_for_user(
1791
1805
  p_change_id UUID,
@@ -1796,11 +1810,23 @@ export const migrateCloudAi = async (): Promise<void> => {
1796
1810
  ) RETURNS void AS $$
1797
1811
  BEGIN
1798
1812
  IF p_user_id IS NULL OR cardinality(p_domains) = 0 THEN RETURN; END IF;
1799
- INSERT INTO ai.live_invalidation_outbox (
1800
- change_id, audience_user_id, conversation_short_id, project_short_id, domains
1801
- ) VALUES (
1802
- p_change_id, p_user_id, p_conversation_short_id, p_project_short_id,
1803
- ARRAY(SELECT DISTINCT domain FROM unnest(p_domains) domain ORDER BY domain)
1813
+ PERFORM events.enqueue(
1814
+ gen_random_uuid(),
1815
+ 'core',
1816
+ 'live',
1817
+ 'u:' || p_user_id::text,
1818
+ jsonb_build_object(
1819
+ 'v', 1,
1820
+ 'k', 'u:' || p_user_id::text,
1821
+ 'd', jsonb_build_object(
1822
+ 'type', 'ai.invalidated',
1823
+ 'changeId', p_change_id,
1824
+ 'conversationId', p_conversation_short_id,
1825
+ 'projectId', p_project_short_id,
1826
+ 'domains', to_jsonb(ARRAY(SELECT DISTINCT domain FROM unnest(p_domains) domain ORDER BY domain)),
1827
+ 'at', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
1828
+ )
1829
+ )
1804
1830
  );
1805
1831
  END
1806
1832
  $$ LANGUAGE plpgsql
@@ -2213,13 +2239,6 @@ export const migrateCloudAi = async (): Promise<void> => {
2213
2239
  )`.simple();
2214
2240
  await sql`CREATE INDEX IF NOT EXISTS ai_dictations_pending ON ai.dictations(conversation_id, created_at, id) WHERE disposition = 'pending'`.simple();
2215
2241
  await sql`CREATE INDEX IF NOT EXISTS ai_dictations_recovery ON ai.dictations(status, next_attempt_at, lease_until) WHERE status IN ('queued', 'running')`.simple();
2216
- await sql.begin(async (tx) => {
2217
- await tx`ALTER TABLE ai.live_invalidation_outbox DROP CONSTRAINT IF EXISTS ai_live_invalidation_domains_check`;
2218
- await tx`ALTER TABLE ai.live_invalidation_outbox ADD CONSTRAINT ai_live_invalidation_domains_check CHECK (
2219
- domains <@ ARRAY['conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
2220
- 'conversation-dictations', 'project-list', 'project-detail', 'project-context']::text[] AND cardinality(domains) > 0
2221
- )`;
2222
- });
2223
2242
  await sql`DROP TRIGGER IF EXISTS ai_live_dictations_changed ON ai.dictations`.simple();
2224
2243
  await sql`DROP TRIGGER IF EXISTS ai_live_dictations_state_changed ON ai.dictations`.simple();
2225
2244
  await sql`CREATE TRIGGER ai_live_dictations_changed AFTER INSERT OR DELETE ON ai.dictations
@@ -1,5 +1,5 @@
1
- import { fileURLToPath } from "node:url";
2
1
  import { z } from "zod";
2
+ import { spawnCanvasWorker } from "../_internal/canvas-worker";
3
3
 
4
4
  declare const __CLOUD_PDF_RENDER_WORKER__: string | undefined;
5
5
  let activeRenders = 0;
@@ -31,27 +31,7 @@ export async function renderPdfPages(bytes: Uint8Array, pages: number[] = [1], s
31
31
  typeof __CLOUD_PDF_RENDER_WORKER__ === "string"
32
32
  ? new URL(__CLOUD_PDF_RENDER_WORKER__, import.meta.url)
33
33
  : new URL("./pdf-render-worker.ts", import.meta.url);
34
- // RLIMIT_DATA bounds the decoder heap/native writable mappings on Linux.
35
- // Positional arguments keep paths out of shell code. No elevated privileges.
36
- const child = Bun.spawn(
37
- ["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', "pdf-decoder", process.execPath, "--no-env-file", fileURLToPath(worker)],
38
- {
39
- stdin: new Blob([JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages })]),
40
- stdout: "pipe",
41
- stderr: "ignore",
42
- // No application credentials or configuration reach the decoder, only fixed
43
- // thread pool sizes: the Bun work pool, canvas tokio runtime, and JSC GC
44
- // markers otherwise grow with the host's cores, and RLIMIT_DATA also counts
45
- // each thread's stack and allocator memory.
46
- env: {
47
- PATH: process.env.PATH,
48
- LANG: "C.UTF-8",
49
- UV_THREADPOOL_SIZE: "2",
50
- TOKIO_WORKER_THREADS: "2",
51
- BUN_JSC_numberOfGCMarkers: "2",
52
- },
53
- },
54
- );
34
+ const child = spawnCanvasWorker(worker, "pdf-decoder", JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages }));
55
35
  const stop = () => child.kill("SIGKILL");
56
36
  boundedSignal.addEventListener("abort", stop, { once: true });
57
37
  if (boundedSignal.aborted) stop();
package/src/ai/routes.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  respond,
17
17
  v,
18
18
  } from "../server";
19
+ import { isRequestCredentialCurrent } from "../server/middleware/auth";
19
20
  import { logger } from "../services/logging";
20
21
  import { coreSettings } from "../services/settings/api";
21
22
  import type { AiToolApprovalContext } from "./approvals";
@@ -1108,11 +1109,11 @@ export const aiRoutes = (() => {
1108
1109
  return createAiConversationStreamResponse({
1109
1110
  conversation,
1110
1111
  signal: c.req.raw.signal,
1111
- // Both calls hit the database: resolveContext re-runs the app's own
1112
- // access check, loadConversation re-checks ownership and resource
1113
- // scope. The actor itself is a request-time snapshot, so this catches
1114
- // a withdrawn grant but not a revoked credential — see r9358ceb.
1112
+ // The actor is a request-time snapshot: re-authenticate its
1113
+ // credential first, so a revoked session or API key ends the stream,
1114
+ // then re-check ownership and resource scope of the conversation.
1115
1115
  revalidate: async () => {
1116
+ if (!(await isRequestCredentialCurrent(c))) return false;
1116
1117
  const current = await resolveContext(c);
1117
1118
  if (current instanceof Response) return false;
1118
1119
  return Boolean(await loadConversation(c, current));
package/src/ai/runtime.ts CHANGED
@@ -12,7 +12,6 @@ import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-model
12
12
  import { startAiDictationRuntime } from "./dictation-runtime";
13
13
  import { AiTurnExecutor } from "./executor";
14
14
  import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
15
- import { startAiInvalidationRuntime, stopAiInvalidationRuntime } from "./live-outbox";
16
15
  import { drainQueuedMessages } from "./message-queue";
17
16
  import { aiQuotas } from "./quotas";
18
17
  import { parseAiResourceMarker } from "./resource-markers";
@@ -577,7 +576,6 @@ export const startAiRuntime = (
577
576
  },
578
577
  };
579
578
  running = state;
580
- startAiInvalidationRuntime();
581
579
  log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
582
580
  return releaseAiRuntime(listener);
583
581
  };
@@ -591,10 +589,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
591
589
  if (!state) return;
592
590
  if (listener) state.listeners.delete(listener);
593
591
  refCount = Math.max(0, refCount - 1);
594
- if (refCount === 0) {
595
- state.stop();
596
- void stopAiInvalidationRuntime();
597
- }
592
+ if (refCount === 0) state.stop();
598
593
  };
599
594
  };
600
595
 
package/src/ai/solid.ts CHANGED
@@ -7,11 +7,6 @@ export {
7
7
  createAiChatController,
8
8
  } from "./client/controller";
9
9
  export { conversationFileSource } from "./client/file-source";
10
- export {
11
- type AiLiveConnection,
12
- type CreateAiLiveConnectionOptions,
13
- createAiLiveConnection,
14
- } from "./client/live-connection";
15
10
  export {
16
11
  type AiActiveTurn,
17
12
  type AiChatProjection,