@agent-native/core 0.131.9 → 0.132.1

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 (295) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +105 -0
  3. package/corpus/core/docs/content/durable-background-runs.mdx +15 -6
  4. package/corpus/core/docs/design/durable-agent-runs.md +15 -11
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/a2a/activity.ts +127 -10
  7. package/corpus/core/src/a2a/index.ts +3 -0
  8. package/corpus/core/src/action.ts +52 -11
  9. package/corpus/core/src/agent/durable-background.ts +38 -28
  10. package/corpus/core/src/agent/harness/background.ts +12 -2
  11. package/corpus/core/src/agent/production-agent.ts +210 -43
  12. package/corpus/core/src/agent/run-store.ts +45 -3
  13. package/corpus/core/src/application-state/handlers.ts +45 -0
  14. package/corpus/core/src/application-state/index.ts +3 -0
  15. package/corpus/core/src/application-state/store.ts +28 -11
  16. package/corpus/core/src/audit/redact.ts +104 -18
  17. package/corpus/core/src/client/AgentPanel.tsx +2 -7
  18. package/corpus/core/src/client/MultiTabAssistantChat.tsx +3 -0
  19. package/corpus/core/src/client/RunStuckBanner.tsx +29 -6
  20. package/corpus/core/src/client/agent-chat.ts +4 -9
  21. package/corpus/core/src/client/application-state.ts +115 -12
  22. package/corpus/core/src/client/dynamic-suggestions.ts +4 -12
  23. package/corpus/core/src/client/guided-questions.tsx +12 -23
  24. package/corpus/core/src/db/client.ts +148 -28
  25. package/corpus/core/src/db/create-get-db.ts +39 -6
  26. package/corpus/core/src/db/runtime-diagnostics.ts +41 -6
  27. package/corpus/core/src/deploy/build.ts +26 -33
  28. package/corpus/core/src/feature-flags/store.ts +10 -5
  29. package/corpus/core/src/integrations/a2a-continuations-store.ts +11 -0
  30. package/corpus/core/src/integrations/config-store.ts +13 -0
  31. package/corpus/core/src/integrations/google-docs-poller.ts +26 -2
  32. package/corpus/core/src/mcp-client/routes.ts +33 -2
  33. package/corpus/core/src/onboarding/plugin.ts +31 -25
  34. package/corpus/core/src/org/accept-pending.ts +2 -0
  35. package/corpus/core/src/org/auto-join-domain.ts +2 -0
  36. package/corpus/core/src/org/context.ts +35 -10
  37. package/corpus/core/src/org/handlers.ts +6 -0
  38. package/corpus/core/src/org/request-org-cache.ts +71 -0
  39. package/corpus/core/src/resources/store.ts +10 -9
  40. package/corpus/core/src/scripts/runner.ts +6 -3
  41. package/corpus/core/src/secrets/storage.ts +97 -5
  42. package/corpus/core/src/server/action-change.ts +33 -0
  43. package/corpus/core/src/server/action-routes.ts +11 -9
  44. package/corpus/core/src/server/agent-teams.ts +10 -2
  45. package/corpus/core/src/server/better-auth-instance.ts +39 -12
  46. package/corpus/core/src/server/core-routes-plugin.ts +19 -2
  47. package/corpus/core/src/server/credential-provider.ts +57 -14
  48. package/corpus/core/src/server/http-response-telemetry.ts +1 -0
  49. package/corpus/core/src/server/poll.ts +95 -57
  50. package/corpus/core/src/server/voice-providers-status.ts +19 -4
  51. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -0
  52. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1 -1
  53. package/corpus/templates/analytics/actions/ensure-native-v2-dashboards.ts +299 -0
  54. package/corpus/templates/analytics/actions/list-native-v2-dashboard-templates.ts +34 -0
  55. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +82 -0
  56. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +2 -2
  57. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +40 -34
  58. package/corpus/templates/analytics/app/hooks/use-user-pref.ts +3 -3
  59. package/corpus/templates/analytics/app/i18n-data.ts +40 -0
  60. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +18 -10
  61. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +14 -0
  62. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +1 -0
  63. package/corpus/templates/analytics/app/routes/chart.tsx +3 -0
  64. package/corpus/templates/analytics/changelog/2026-07-30-analytics-dashboards-now-expose-native-funnel-heatmap-callou.md +6 -0
  65. package/corpus/templates/analytics/changelog/2026-07-30-analytics-now-ships-reusable-native-v2-dashboard-manifests-w.md +6 -0
  66. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +81 -1
  67. package/corpus/templates/analytics/server/lib/native-v2-dashboards.ts +457 -0
  68. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +73 -2
  69. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  70. package/corpus/templates/analytics/shared/dashboard-funnel.ts +83 -0
  71. package/corpus/templates/content/changelog/2026-07-30-long-running-delegated-requests-now-continue-reliably.md +6 -0
  72. package/corpus/templates/content/netlify.toml +3 -0
  73. package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
  74. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +2 -1
  75. package/corpus/templates/slides/actions/patch-deck.ts +25 -1
  76. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +17 -4
  77. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +9 -1
  78. package/corpus/templates/slides/app/components/layout/Layout.tsx +1 -1
  79. package/corpus/templates/slides/app/context/DeckContext.tsx +11 -3
  80. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +39 -11
  81. package/corpus/templates/slides/app/pages/Index.tsx +2 -2
  82. package/corpus/templates/slides/changelog/2026-07-30-slide-previews-keep-their-aspect-ratio-isolate-embedded-styl.md +6 -0
  83. package/corpus/templates/slides/changelog/2026-07-30-slides-no-longer-lose-decks-while-organization-access-is-loa.md +6 -0
  84. package/corpus/toolkit/CHANGELOG.md +8 -0
  85. package/corpus/toolkit/package.json +1 -1
  86. package/corpus/toolkit/src/composer/runtime-adapters.tsx +16 -11
  87. package/dist/a2a/activity.d.ts +22 -4
  88. package/dist/a2a/activity.d.ts.map +1 -1
  89. package/dist/a2a/activity.js +97 -11
  90. package/dist/a2a/activity.js.map +1 -1
  91. package/dist/a2a/index.d.ts +1 -1
  92. package/dist/a2a/index.d.ts.map +1 -1
  93. package/dist/a2a/index.js +1 -1
  94. package/dist/a2a/index.js.map +1 -1
  95. package/dist/action.d.ts.map +1 -1
  96. package/dist/action.js +41 -11
  97. package/dist/action.js.map +1 -1
  98. package/dist/agent/durable-background.d.ts +13 -8
  99. package/dist/agent/durable-background.d.ts.map +1 -1
  100. package/dist/agent/durable-background.js +36 -28
  101. package/dist/agent/durable-background.js.map +1 -1
  102. package/dist/agent/harness/background.d.ts.map +1 -1
  103. package/dist/agent/harness/background.js +12 -2
  104. package/dist/agent/harness/background.js.map +1 -1
  105. package/dist/agent/production-agent.d.ts.map +1 -1
  106. package/dist/agent/production-agent.js +195 -44
  107. package/dist/agent/production-agent.js.map +1 -1
  108. package/dist/agent/run-store.d.ts +2 -0
  109. package/dist/agent/run-store.d.ts.map +1 -1
  110. package/dist/agent/run-store.js +44 -3
  111. package/dist/agent/run-store.js.map +1 -1
  112. package/dist/application-state/handlers.d.ts +15 -0
  113. package/dist/application-state/handlers.d.ts.map +1 -1
  114. package/dist/application-state/handlers.js +42 -2
  115. package/dist/application-state/handlers.js.map +1 -1
  116. package/dist/application-state/index.d.ts +2 -2
  117. package/dist/application-state/index.d.ts.map +1 -1
  118. package/dist/application-state/index.js +2 -2
  119. package/dist/application-state/index.js.map +1 -1
  120. package/dist/application-state/store.d.ts +11 -0
  121. package/dist/application-state/store.d.ts.map +1 -1
  122. package/dist/application-state/store.js +23 -10
  123. package/dist/application-state/store.js.map +1 -1
  124. package/dist/audit/redact.d.ts +25 -2
  125. package/dist/audit/redact.d.ts.map +1 -1
  126. package/dist/audit/redact.js +85 -21
  127. package/dist/audit/redact.js.map +1 -1
  128. package/dist/client/AgentPanel.d.ts.map +1 -1
  129. package/dist/client/AgentPanel.js +2 -7
  130. package/dist/client/AgentPanel.js.map +1 -1
  131. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  132. package/dist/client/MultiTabAssistantChat.js +1 -1
  133. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  134. package/dist/client/RunStuckBanner.d.ts +11 -1
  135. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  136. package/dist/client/RunStuckBanner.js +20 -7
  137. package/dist/client/RunStuckBanner.js.map +1 -1
  138. package/dist/client/agent-chat.d.ts.map +1 -1
  139. package/dist/client/agent-chat.js +4 -6
  140. package/dist/client/agent-chat.js.map +1 -1
  141. package/dist/client/application-state.d.ts +11 -0
  142. package/dist/client/application-state.d.ts.map +1 -1
  143. package/dist/client/application-state.js +87 -9
  144. package/dist/client/application-state.js.map +1 -1
  145. package/dist/client/dynamic-suggestions.d.ts.map +1 -1
  146. package/dist/client/dynamic-suggestions.js +4 -13
  147. package/dist/client/dynamic-suggestions.js.map +1 -1
  148. package/dist/client/guided-questions.d.ts.map +1 -1
  149. package/dist/client/guided-questions.js +6 -22
  150. package/dist/client/guided-questions.js.map +1 -1
  151. package/dist/collab/routes.d.ts +1 -1
  152. package/dist/collab/struct-routes.d.ts +1 -1
  153. package/dist/db/client.d.ts +57 -10
  154. package/dist/db/client.d.ts.map +1 -1
  155. package/dist/db/client.js +122 -31
  156. package/dist/db/client.js.map +1 -1
  157. package/dist/db/create-get-db.d.ts.map +1 -1
  158. package/dist/db/create-get-db.js +31 -7
  159. package/dist/db/create-get-db.js.map +1 -1
  160. package/dist/db/runtime-diagnostics.d.ts +1 -0
  161. package/dist/db/runtime-diagnostics.d.ts.map +1 -1
  162. package/dist/db/runtime-diagnostics.js +31 -6
  163. package/dist/db/runtime-diagnostics.js.map +1 -1
  164. package/dist/deploy/build.d.ts +5 -20
  165. package/dist/deploy/build.d.ts.map +1 -1
  166. package/dist/deploy/build.js +18 -30
  167. package/dist/deploy/build.js.map +1 -1
  168. package/dist/feature-flags/store.d.ts.map +1 -1
  169. package/dist/feature-flags/store.js +10 -5
  170. package/dist/feature-flags/store.js.map +1 -1
  171. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  172. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  173. package/dist/integrations/a2a-continuations-store.js +12 -0
  174. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  175. package/dist/integrations/config-store.d.ts +1 -0
  176. package/dist/integrations/config-store.d.ts.map +1 -1
  177. package/dist/integrations/config-store.js +11 -0
  178. package/dist/integrations/config-store.js.map +1 -1
  179. package/dist/integrations/google-docs-poller.d.ts.map +1 -1
  180. package/dist/integrations/google-docs-poller.js +19 -2
  181. package/dist/integrations/google-docs-poller.js.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  183. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  184. package/dist/mcp-client/routes.d.ts.map +1 -1
  185. package/dist/mcp-client/routes.js +30 -2
  186. package/dist/mcp-client/routes.js.map +1 -1
  187. package/dist/notifications/routes.d.ts +3 -3
  188. package/dist/observability/routes.d.ts +1 -1
  189. package/dist/onboarding/plugin.d.ts.map +1 -1
  190. package/dist/onboarding/plugin.js +12 -8
  191. package/dist/onboarding/plugin.js.map +1 -1
  192. package/dist/org/accept-pending.d.ts.map +1 -1
  193. package/dist/org/accept-pending.js +2 -0
  194. package/dist/org/accept-pending.js.map +1 -1
  195. package/dist/org/auto-join-domain.d.ts.map +1 -1
  196. package/dist/org/auto-join-domain.js +2 -0
  197. package/dist/org/auto-join-domain.js.map +1 -1
  198. package/dist/org/context.d.ts.map +1 -1
  199. package/dist/org/context.js +29 -9
  200. package/dist/org/context.js.map +1 -1
  201. package/dist/org/handlers.d.ts.map +1 -1
  202. package/dist/org/handlers.js +5 -0
  203. package/dist/org/handlers.js.map +1 -1
  204. package/dist/org/request-org-cache.d.ts +16 -0
  205. package/dist/org/request-org-cache.d.ts.map +1 -0
  206. package/dist/org/request-org-cache.js +62 -0
  207. package/dist/org/request-org-cache.js.map +1 -0
  208. package/dist/resources/store.d.ts.map +1 -1
  209. package/dist/resources/store.js +11 -9
  210. package/dist/resources/store.js.map +1 -1
  211. package/dist/scripts/runner.d.ts.map +1 -1
  212. package/dist/scripts/runner.js +3 -3
  213. package/dist/scripts/runner.js.map +1 -1
  214. package/dist/secrets/storage.d.ts.map +1 -1
  215. package/dist/secrets/storage.js +82 -5
  216. package/dist/secrets/storage.js.map +1 -1
  217. package/dist/server/action-change.d.ts +20 -0
  218. package/dist/server/action-change.d.ts.map +1 -1
  219. package/dist/server/action-change.js +29 -0
  220. package/dist/server/action-change.js.map +1 -1
  221. package/dist/server/action-routes.d.ts.map +1 -1
  222. package/dist/server/action-routes.js +7 -8
  223. package/dist/server/action-routes.js.map +1 -1
  224. package/dist/server/agent-teams.d.ts.map +1 -1
  225. package/dist/server/agent-teams.js +10 -2
  226. package/dist/server/agent-teams.js.map +1 -1
  227. package/dist/server/better-auth-instance.d.ts.map +1 -1
  228. package/dist/server/better-auth-instance.js +31 -14
  229. package/dist/server/better-auth-instance.js.map +1 -1
  230. package/dist/server/core-routes-plugin.d.ts +1 -0
  231. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  232. package/dist/server/core-routes-plugin.js +18 -4
  233. package/dist/server/core-routes-plugin.js.map +1 -1
  234. package/dist/server/credential-provider.d.ts +16 -0
  235. package/dist/server/credential-provider.d.ts.map +1 -1
  236. package/dist/server/credential-provider.js +49 -13
  237. package/dist/server/credential-provider.js.map +1 -1
  238. package/dist/server/http-response-telemetry.d.ts.map +1 -1
  239. package/dist/server/http-response-telemetry.js +1 -0
  240. package/dist/server/http-response-telemetry.js.map +1 -1
  241. package/dist/server/poll.d.ts +6 -0
  242. package/dist/server/poll.d.ts.map +1 -1
  243. package/dist/server/poll.js +84 -47
  244. package/dist/server/poll.js.map +1 -1
  245. package/dist/server/voice-providers-status.d.ts.map +1 -1
  246. package/dist/server/voice-providers-status.js +12 -3
  247. package/dist/server/voice-providers-status.js.map +1 -1
  248. package/docs/content/durable-background-runs.mdx +15 -6
  249. package/docs/design/durable-agent-runs.md +15 -11
  250. package/package.json +2 -2
  251. package/src/a2a/activity.ts +127 -10
  252. package/src/a2a/index.ts +3 -0
  253. package/src/action.ts +52 -11
  254. package/src/agent/durable-background.ts +38 -28
  255. package/src/agent/harness/background.ts +12 -2
  256. package/src/agent/production-agent.ts +210 -43
  257. package/src/agent/run-store.ts +45 -3
  258. package/src/application-state/handlers.ts +45 -0
  259. package/src/application-state/index.ts +3 -0
  260. package/src/application-state/store.ts +28 -11
  261. package/src/audit/redact.ts +104 -18
  262. package/src/client/AgentPanel.tsx +2 -7
  263. package/src/client/MultiTabAssistantChat.tsx +3 -0
  264. package/src/client/RunStuckBanner.tsx +29 -6
  265. package/src/client/agent-chat.ts +4 -9
  266. package/src/client/application-state.ts +115 -12
  267. package/src/client/dynamic-suggestions.ts +4 -12
  268. package/src/client/guided-questions.tsx +12 -23
  269. package/src/db/client.ts +148 -28
  270. package/src/db/create-get-db.ts +39 -6
  271. package/src/db/runtime-diagnostics.ts +41 -6
  272. package/src/deploy/build.ts +26 -33
  273. package/src/feature-flags/store.ts +10 -5
  274. package/src/integrations/a2a-continuations-store.ts +11 -0
  275. package/src/integrations/config-store.ts +13 -0
  276. package/src/integrations/google-docs-poller.ts +26 -2
  277. package/src/mcp-client/routes.ts +33 -2
  278. package/src/onboarding/plugin.ts +31 -25
  279. package/src/org/accept-pending.ts +2 -0
  280. package/src/org/auto-join-domain.ts +2 -0
  281. package/src/org/context.ts +35 -10
  282. package/src/org/handlers.ts +6 -0
  283. package/src/org/request-org-cache.ts +71 -0
  284. package/src/resources/store.ts +10 -9
  285. package/src/scripts/runner.ts +6 -3
  286. package/src/secrets/storage.ts +97 -5
  287. package/src/server/action-change.ts +33 -0
  288. package/src/server/action-routes.ts +11 -9
  289. package/src/server/agent-teams.ts +10 -2
  290. package/src/server/better-auth-instance.ts +39 -12
  291. package/src/server/core-routes-plugin.ts +19 -2
  292. package/src/server/credential-provider.ts +57 -14
  293. package/src/server/http-response-telemetry.ts +1 -0
  294. package/src/server/poll.ts +95 -57
  295. package/src/server/voice-providers-status.ts +19 -4
@@ -32,7 +32,7 @@ import { getSession } from "../server/auth.js";
32
32
  import { getH3App } from "../server/framework-request-handler.js";
33
33
  import { readBody } from "../server/h3-helpers.js";
34
34
  import { runWithRequestContext } from "../server/request-context.js";
35
- import { getAllSettings } from "../settings/store.js";
35
+ import { getAllSettings, getSettingsEmitter } from "../settings/store.js";
36
36
  import {
37
37
  areBuiltinMcpCapabilitiesSupported,
38
38
  BUILTIN_MCP_CAPABILITIES,
@@ -291,6 +291,13 @@ function sortedConfigSignature(config: McpConfig | null): string {
291
291
  return JSON.stringify(entries);
292
292
  }
293
293
 
294
+ /**
295
+ * How long the refresh may skip the settings read on the strength of "no
296
+ * in-process settings write since the last one". Bounds how stale a remote MCP
297
+ * server list added by ANOTHER process can be.
298
+ */
299
+ const MCP_CONFIG_REFRESH_BACKSTOP_MS = 5 * 60 * 1000;
300
+
294
301
  function mcpConfigRefreshIntervalMs(): number {
295
302
  const raw = process.env.AGENT_NATIVE_MCP_CONFIG_REFRESH_MS;
296
303
  if (raw?.trim() === "0") return 0;
@@ -307,8 +314,24 @@ export function startMcpConfigRefresh(
307
314
 
308
315
  let currentSignature = sortedConfigSignature(manager.getConfig());
309
316
  let refreshing = false;
317
+ // `buildMergedConfig` reads the entire settings table just to diff a
318
+ // signature, which on an idle app is a full-table round trip every minute
319
+ // forever. Only pay it when an in-process settings write says something might
320
+ // have changed, plus a periodic backstop for writes from another process.
321
+ let settingsDirty = true;
322
+ let lastFullRefresh = 0;
323
+ const markDirty = () => {
324
+ settingsDirty = true;
325
+ };
326
+ getSettingsEmitter().on("settings", markDirty);
310
327
  const refresh = async () => {
311
328
  if (refreshing) return;
329
+ if (
330
+ !settingsDirty &&
331
+ Date.now() - lastFullRefresh < MCP_CONFIG_REFRESH_BACKSTOP_MS
332
+ ) {
333
+ return;
334
+ }
312
335
  refreshing = true;
313
336
  try {
314
337
  const next = await buildMergedConfig();
@@ -317,7 +340,12 @@ export function startMcpConfigRefresh(
317
340
  await manager.reconfigure(next);
318
341
  currentSignature = nextSignature;
319
342
  }
343
+ settingsDirty = false;
344
+ lastFullRefresh = Date.now();
320
345
  } catch (err: any) {
346
+ // Keep this dirty so a transient database or manager failure is retried
347
+ // on the next interval instead of being hidden by the backstop window.
348
+ settingsDirty = true;
321
349
  console.warn(
322
350
  `[mcp-client] config refresh failed: ${err?.message ?? err}`,
323
351
  );
@@ -328,7 +356,10 @@ export function startMcpConfigRefresh(
328
356
 
329
357
  const timer = setInterval(refresh, intervalMs);
330
358
  (timer as { unref?: () => void }).unref?.();
331
- return () => clearInterval(timer);
359
+ return () => {
360
+ clearInterval(timer);
361
+ getSettingsEmitter().off("settings", markDirty);
362
+ };
332
363
  }
333
364
 
334
365
  async function resolveContextForRequest(event: H3Event): Promise<{
@@ -81,30 +81,34 @@ async function serializeSteps(
81
81
  options: { preview?: boolean } = {},
82
82
  ): Promise<OnboardingStepStatus[]> {
83
83
  const steps = listOnboardingSteps();
84
- const out: OnboardingStepStatus[] = [];
85
- for (const step of steps) {
86
- let complete = false;
87
- if (!options.preview) {
88
- try {
89
- complete = (await step.isComplete(context)) === true;
90
- } catch {
91
- complete = false;
92
- }
93
- if (!complete) {
94
- complete = await hasOverride(context.sessionId, step.id);
84
+ // Steps are independent of each other, and each `isComplete()` is itself a
85
+ // chain of credential/settings reads — walking them one at a time made this
86
+ // route cost the SUM of every step's round trips against a remote database
87
+ // instead of the slowest one. `Promise.all` preserves `steps` order.
88
+ return Promise.all(
89
+ steps.map(async (step) => {
90
+ let complete = false;
91
+ if (!options.preview) {
92
+ try {
93
+ complete = (await step.isComplete(context)) === true;
94
+ } catch {
95
+ complete = false;
96
+ }
97
+ if (!complete) {
98
+ complete = await hasOverride(context.sessionId, step.id);
99
+ }
95
100
  }
96
- }
97
- out.push({
98
- id: step.id,
99
- title: step.title,
100
- description: step.description,
101
- order: step.order,
102
- required: step.required ?? false,
103
- complete,
104
- methods: step.methods,
105
- });
106
- }
107
- return out;
101
+ return {
102
+ id: step.id,
103
+ title: step.title,
104
+ description: step.description,
105
+ order: step.order,
106
+ required: step.required ?? false,
107
+ complete,
108
+ methods: step.methods,
109
+ };
110
+ }),
111
+ );
108
112
  }
109
113
 
110
114
  function withOnboardingRequestContext<T>(
@@ -240,11 +244,13 @@ export function createOnboardingPlugin(
240
244
  // doesn't surface as a 500 to the client.
241
245
  try {
242
246
  return await withOnboardingRequestContext(context, async () => {
243
- const value = await appStateGet(context.sessionId, DISMISSED_KEY);
247
+ const [value, statuses] = await Promise.all([
248
+ appStateGet(context.sessionId, DISMISSED_KEY),
249
+ serializeSteps(context),
250
+ ]);
244
251
  const dismissed = !!(
245
252
  value && (value as { dismissed?: boolean }).dismissed
246
253
  );
247
- const statuses = await serializeSteps(context);
248
254
  return {
249
255
  dismissed,
250
256
  allComplete: allRequiredComplete(statuses),
@@ -1,5 +1,6 @@
1
1
  import { getDbExec } from "../db/client.js";
2
2
  import { setActiveOrgId } from "./active-org.js";
3
+ import { invalidateRequestMemberOrgIds } from "./request-org-cache.js";
3
4
 
4
5
  const nanoid = (): string =>
5
6
  globalThis.crypto?.randomUUID?.().replace(/-/g, "") ??
@@ -75,6 +76,7 @@ export async function acceptPendingInvitationsForEmail(
75
76
  ON CONFLICT (org_id, LOWER(email)) DO NOTHING`,
76
77
  args: [nanoid(), inv.orgId, email, role, Date.now()],
77
78
  });
79
+ invalidateRequestMemberOrgIds();
78
80
  }
79
81
  await db.execute({
80
82
  sql: `UPDATE org_invitations SET status = 'accepted' WHERE id = ?`,
@@ -1,6 +1,7 @@
1
1
  import { getDbExec } from "../db/client.js";
2
2
  import { getUserSetting } from "../settings/user-settings.js";
3
3
  import { setActiveOrgId } from "./active-org.js";
4
+ import { invalidateRequestMemberOrgIds } from "./request-org-cache.js";
4
5
 
5
6
  const nanoid = (): string =>
6
7
  globalThis.crypto?.randomUUID?.().replace(/-/g, "") ??
@@ -87,6 +88,7 @@ export async function autoJoinDomainMatchingOrgs(
87
88
  args: [nanoid(), m.orgId, email, Date.now()],
88
89
  });
89
90
  joined.push({ orgId: m.orgId });
91
+ invalidateRequestMemberOrgIds();
90
92
  } catch {
91
93
  // Race with a parallel join (e.g. user accepted an invite to the
92
94
  // same org milliseconds earlier). The unique constraint keeps the
@@ -7,6 +7,10 @@ import { getSetting } from "../settings/store.js";
7
7
  import { getUserSetting } from "../settings/user-settings.js";
8
8
  import { setActiveOrgId } from "./active-org.js";
9
9
  import { autoJoinDomainMatchingOrgs } from "./auto-join-domain.js";
10
+ import {
11
+ invalidateRequestMemberOrgIds,
12
+ requestMemberOrgIds,
13
+ } from "./request-org-cache.js";
10
14
  import type { OrgContext, OrgRole } from "./types.js";
11
15
 
12
16
  const EMPTY_CONTEXT: OrgContext = {
@@ -210,6 +214,12 @@ async function resolveOrgContextUncached(event: H3Event): Promise<OrgContext> {
210
214
 
211
215
  const exec = getDbExec();
212
216
 
217
+ // Started before the memberships await so the two round trips overlap; the
218
+ // `catch` only covers the early returns below, the later `await` still
219
+ // propagates a real failure.
220
+ const activeOrgSettingPromise = loadActiveOrgSettingForEvent(event, email);
221
+ activeOrgSettingPromise.catch(() => {});
222
+
213
223
  let memberships: MembershipRow[] | null;
214
224
  try {
215
225
  memberships = await loadMembershipsForEvent(event, email);
@@ -238,7 +248,7 @@ async function resolveOrgContextUncached(event: H3Event): Promise<OrgContext> {
238
248
  return { email, orgId: null, orgName: null, role: null };
239
249
  }
240
250
 
241
- const activeOrgSetting = await loadActiveOrgSettingForEvent(event, email);
251
+ const activeOrgSetting = await activeOrgSettingPromise;
242
252
  const explicitPersonal = activeOrgSetting?.orgId === null;
243
253
 
244
254
  const emailDomain = emailDomainOf(email);
@@ -410,17 +420,26 @@ async function loadMemberships(email: string): Promise<MembershipRow[] | null> {
410
420
  export async function resolveOrgIdForEmail(
411
421
  email: string,
412
422
  ): Promise<string | null> {
413
- const rows = await queryOrgMembers({
414
- sql: `SELECT org_id FROM org_members WHERE LOWER(email) = ?
415
- ${MEMBERSHIP_FALLBACK_ORDER_BY}`,
416
- args: [email.toLowerCase()],
423
+ const idsPromise = requestMemberOrgIds(email, async () => {
424
+ const rows = await queryOrgMembers({
425
+ sql: `SELECT org_id FROM org_members WHERE LOWER(email) = ?
426
+ ${MEMBERSHIP_FALLBACK_ORDER_BY}`,
427
+ args: [email.toLowerCase()],
428
+ });
429
+ return rows?.map((r: any) => String(r.org_id)) ?? null;
417
430
  });
418
- if (!rows?.length) return null;
419
- const ids = rows.map((r: any) => String(r.org_id));
420
- const activeOrgSetting = (await getUserSetting(
431
+ // Both reads depend only on the email, so they overlap instead of queueing.
432
+ // The `catch` only keeps the early return below from surfacing an unhandled
433
+ // rejection; the `await` further down still propagates the failure.
434
+ const settingPromise = getUserSetting(
421
435
  email,
422
436
  "active-org-id",
423
- )) as ActiveOrgSetting;
437
+ ) as Promise<ActiveOrgSetting>;
438
+ settingPromise.catch(() => {});
439
+
440
+ const ids = await idsPromise;
441
+ if (!ids?.length) return null;
442
+ const activeOrgSetting = await settingPromise;
424
443
  if (activeOrgSetting?.orgId === null) return null;
425
444
  if (activeOrgSetting?.orgId && ids.includes(activeOrgSetting.orgId)) {
426
445
  return activeOrgSetting.orgId;
@@ -438,9 +457,13 @@ export async function resolveOrgIdForEmailViaEvent(
438
457
  event: H3Event,
439
458
  email: string,
440
459
  ): Promise<string | null> {
460
+ // Overlapped, not queued: each is a separate round trip and this pair runs
461
+ // on the session-backfill path of every authenticated request.
462
+ const settingPromise = loadActiveOrgSettingForEvent(event, email);
463
+ settingPromise.catch(() => {});
441
464
  const memberships = await loadMembershipsForEvent(event, email);
442
465
  if (!memberships || memberships.length === 0) return null;
443
- const activeOrgSetting = await loadActiveOrgSettingForEvent(event, email);
466
+ const activeOrgSetting = await settingPromise;
444
467
  if (activeOrgSetting?.orgId === null) return null;
445
468
  if (
446
469
  activeOrgSetting?.orgId &&
@@ -485,6 +508,7 @@ export async function createOrganization(
485
508
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`,
486
509
  args: [nanoid(), id, email, role, createdAt],
487
510
  });
511
+ invalidateRequestMemberOrgIds();
488
512
 
489
513
  await warnOnAdditionalOrganization(exec, email, id, trimmedName);
490
514
 
@@ -669,6 +693,7 @@ async function tryCreateDefaultOrg(
669
693
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`,
670
694
  args: [nanoid(), orgId, email, "owner", now],
671
695
  });
696
+ invalidateRequestMemberOrgIds();
672
697
 
673
698
  await setActiveOrgId(email, orgId, "auto-created default organization");
674
699
 
@@ -46,6 +46,7 @@ import { readBody } from "../server/h3-helpers.js";
46
46
  import { setActiveOrgId } from "./active-org.js";
47
47
  import { getOrgContext, createOrganization } from "./context.js";
48
48
  import { isFreeEmailProvider } from "./free-email-providers.js";
49
+ import { invalidateRequestMemberOrgIds } from "./request-org-cache.js";
49
50
  import type { OrgRole } from "./types.js";
50
51
  import { parseWorkspaceUrl } from "./workspace-url.js";
51
52
 
@@ -505,6 +506,7 @@ export const acceptInvitationHandler = defineEventHandler(
505
506
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`,
506
507
  args: [nanoid(), invOrgId, email, inviteRole, Date.now()],
507
508
  });
509
+ invalidateRequestMemberOrgIds();
508
510
 
509
511
  await e.execute({
510
512
  sql: `UPDATE org_invitations SET status = 'accepted' WHERE id = ?`,
@@ -574,6 +576,7 @@ export const removeMemberHandler = defineEventHandler(
574
576
  sql: `DELETE FROM org_members WHERE org_id = ? AND LOWER(email) = ?`,
575
577
  args: [ctx.orgId, memberEmailLower],
576
578
  });
579
+ invalidateRequestMemberOrgIds();
577
580
 
578
581
  return { success: true };
579
582
  },
@@ -772,6 +775,8 @@ export const deleteOrgHandler = defineEventHandler(async (event: H3Event) => {
772
775
  });
773
776
  }
774
777
 
778
+ invalidateRequestMemberOrgIds();
779
+
775
780
  const nextRes = await e.execute({
776
781
  sql: `SELECT org_id AS "orgId" FROM org_members WHERE LOWER(email) = ? LIMIT 1`,
777
782
  args: [ctx.email.toLowerCase()],
@@ -875,6 +880,7 @@ export const joinByDomainHandler = defineEventHandler(
875
880
  sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, 'member', ?)`,
876
881
  args: [nanoid(), orgId, email, Date.now()],
877
882
  });
883
+ invalidateRequestMemberOrgIds();
878
884
 
879
885
  await setActiveOrgId(email, orgId, "joined domain-matched organization");
880
886
 
@@ -0,0 +1,71 @@
1
+ import { getRequestContext } from "../server/request-context.js";
2
+
3
+ /**
4
+ * Per-request memo of the `org_members` read behind `resolveOrgIdForEmail`,
5
+ * keyed on the active AsyncLocalStorage `RequestContext` (WeakMap → freed with
6
+ * the request) and then on the lowercased email. Mirrors the settings cache in
7
+ * `settings/store.ts`.
8
+ *
9
+ * The `event.context` caches in `context.ts` only cover call chains that carry
10
+ * an h3 event. Identity resolution for credential lookups, agent runs, A2A,
11
+ * MCP, and adapter-authenticated action calls has no event, so every one of
12
+ * those callers used to pay its own round trip for the same answer — on a
13
+ * remote Postgres that is ~83ms each.
14
+ *
15
+ * TRAP: the key is the email, never "the current request's user". A single
16
+ * request legitimately resolves several addresses (the signed-in caller plus a
17
+ * run owner or credential subject), so a context-only key would answer one
18
+ * identity with another's memberships.
19
+ */
20
+ const requestOrgIds = new WeakMap<
21
+ object,
22
+ Map<string, Promise<string[] | null>>
23
+ >();
24
+
25
+ function cacheForRequest(
26
+ create: boolean,
27
+ ): Map<string, Promise<string[] | null>> | null {
28
+ const ctx = getRequestContext();
29
+ if (!ctx || typeof ctx !== "object") return null;
30
+ let cache = requestOrgIds.get(ctx);
31
+ if (!cache && create) {
32
+ cache = new Map();
33
+ requestOrgIds.set(ctx, cache);
34
+ }
35
+ return cache ?? null;
36
+ }
37
+
38
+ /**
39
+ * Resolve the org ids `email` belongs to, once per request. `null` means the
40
+ * membership rows were unreadable and is cached like any other answer; a
41
+ * rejection is evicted so one transient failure cannot answer every later
42
+ * lookup in the same request.
43
+ */
44
+ export function requestMemberOrgIds(
45
+ email: string,
46
+ load: () => Promise<string[] | null>,
47
+ ): Promise<string[] | null> {
48
+ const cache = cacheForRequest(true);
49
+ if (!cache) return load();
50
+ const key = email.trim().toLowerCase();
51
+ let pending = cache.get(key);
52
+ if (!pending) {
53
+ pending = load().catch((err) => {
54
+ cache.delete(key);
55
+ throw err;
56
+ });
57
+ cache.set(key, pending);
58
+ }
59
+ return pending;
60
+ }
61
+
62
+ /**
63
+ * Drop this request's memoized memberships after a write to `org_members`.
64
+ * Clears every email rather than one: deleting an organization or removing a
65
+ * member changes the answer for accounts other than the one being written.
66
+ * Requests already in flight elsewhere keep their own snapshot for the rest of
67
+ * their (short) lifetime, the same tradeoff the settings cache documents.
68
+ */
69
+ export function invalidateRequestMemberOrgIds(): void {
70
+ cacheForRequest(false)?.clear();
71
+ }
@@ -1494,18 +1494,19 @@ export async function resourceEffectiveContext(
1494
1494
  ): Promise<EffectiveResourceContext> {
1495
1495
  await ensureTable();
1496
1496
 
1497
- const workspace = await resourceGetByPath(WORKSPACE_OWNER, path, {
1498
- ...options,
1499
- userEmail,
1500
- });
1497
+ // Four independent reads of the same path under different owners; the
1498
+ // precedence below is pure JS, so serialising them only bought four round
1499
+ // trips of latency. Mirrors `resourceListAccessible`, which already fans out.
1501
1500
  const organizationOwner = sharedResourceOwner(options?.orgId);
1502
- const organization =
1501
+ const [workspace, organization, legacyShared, personal] = await Promise.all([
1502
+ resourceGetByPath(WORKSPACE_OWNER, path, { ...options, userEmail }),
1503
1503
  organizationOwner === SHARED_OWNER
1504
- ? null
1505
- : await resourceGetByPath(organizationOwner, path);
1506
- const legacyShared = await resourceGetByPath(SHARED_OWNER, path);
1504
+ ? Promise.resolve(null)
1505
+ : resourceGetByPath(organizationOwner, path),
1506
+ resourceGetByPath(SHARED_OWNER, path),
1507
+ resourceGetByPath(userEmail, path),
1508
+ ]);
1507
1509
  const shared = organization ?? legacyShared;
1508
- const personal = await resourceGetByPath(userEmail, path);
1509
1510
  const effective = personal ?? shared ?? workspace ?? null;
1510
1511
  const effectiveScope: ResourceInheritanceScope | null = personal
1511
1512
  ? "personal"
@@ -17,7 +17,10 @@ import { pathToFileURL } from "url";
17
17
 
18
18
  import type { ActionEntry } from "../agent/production-agent.js";
19
19
  import { closeDbExec } from "../db/client.js";
20
- import { notifyActionChange } from "../server/action-change.js";
20
+ import {
21
+ actionCallIsReadOnly,
22
+ notifyActionChange,
23
+ } from "../server/action-change.js";
21
24
  import {
22
25
  runWithRequestContext,
23
26
  getRequestOrgId,
@@ -442,7 +445,7 @@ async function dispatchAction(
442
445
  ) {
443
446
  const parsed = parseActionArgs(args, { coerceBooleans: true });
444
447
  const result = await handler.run(parsed, cliActionCtx(actionName));
445
- if (handler.readOnly !== true) {
448
+ if (!actionCallIsReadOnly(handler, parsed, false)) {
446
449
  await notifyActionChange({ actionName }).catch(() => {});
447
450
  }
448
451
  if (result) assertCliHandoffLaunched(printActionResult(result));
@@ -476,7 +479,7 @@ async function dispatchAction(
476
479
  parsed as Record<string, string>,
477
480
  cliActionCtx(actionName),
478
481
  );
479
- if (packageAction.readOnly !== true) {
482
+ if (!actionCallIsReadOnly(packageAction, parsed, false)) {
480
483
  await notifyActionChange({ actionName }).catch(() => {});
481
484
  }
482
485
  if (result) assertCliHandoffLaunched(printActionResult(result));
@@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
16
16
 
17
17
  import { getDbExec, isPostgres } from "../db/client.js";
18
18
  import { ensureColumnExists, ensureTableExists } from "../db/ddl-guard.js";
19
+ import { getRequestContext } from "../server/request-context.js";
19
20
  import {
20
21
  encryptSecretValue as encryptLegacyValue,
21
22
  encryptSharedSecretValue as encryptValue,
@@ -217,6 +218,8 @@ export async function writeAppSecret(args: WriteSecretArgs): Promise<string> {
217
218
  now,
218
219
  ];
219
220
 
221
+ invalidateRequestSecret(args);
222
+
220
223
  if (isPostgres()) {
221
224
  const { rows } = await client.execute({
222
225
  sql: `${upsertSql} RETURNING id`,
@@ -339,6 +342,52 @@ async function populateSharedAppSecret(
339
342
  }
340
343
  }
341
344
 
345
+ // ---------------------------------------------------------------------------
346
+ // Per-request read memo
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Per-request memo of secret reads, keyed on the active AsyncLocalStorage
351
+ * RequestContext (WeakMap → freed with the request). Mirrors the settings
352
+ * store's cache, including its staleness rule: a write in THIS request is
353
+ * written through, while other in-flight requests keep their snapshot for
354
+ * their own (short) lifetime.
355
+ *
356
+ * The map key is `scope|scopeId|key`, which is the secret's full identity —
357
+ * `user:<email>`, `org:<id>`, `solo:<email>`, `workspace` — so a hit can never
358
+ * answer one caller with another caller's secret. Do NOT reduce the key to
359
+ * just `key`.
360
+ *
361
+ * This exists because one credential resolution is a WATERFALL: user scope,
362
+ * then org, then workspace, then solo. Callers re-resolve the same credential
363
+ * several times per request (engine detection, config resolution, usability
364
+ * checks), and against a remote database each probe is a network round trip.
365
+ */
366
+ const _requestSecretsCache = new WeakMap<
367
+ object,
368
+ Map<string, ReadSecretResult | null>
369
+ >();
370
+
371
+ function requestSecretsCache(): Map<string, ReadSecretResult | null> | null {
372
+ const ctx = getRequestContext();
373
+ if (!ctx || typeof ctx !== "object") return null;
374
+ let cache = _requestSecretsCache.get(ctx);
375
+ if (!cache) {
376
+ cache = new Map();
377
+ _requestSecretsCache.set(ctx, cache);
378
+ }
379
+ return cache;
380
+ }
381
+
382
+ function secretCacheKey(ref: SecretRef): string {
383
+ return `${ref.scope}|${ref.scopeId}|${ref.key}`;
384
+ }
385
+
386
+ /** Drop this request's memo for a secret whose stored value just changed. */
387
+ function invalidateRequestSecret(ref: SecretRef): void {
388
+ requestSecretsCache()?.delete(secretCacheKey(ref));
389
+ }
390
+
342
391
  type AppSecretsReadQuery = { sql: string; args: unknown[] };
343
392
 
344
393
  /**
@@ -379,6 +428,17 @@ async function executeAppSecretsRead(query: AppSecretsReadQuery) {
379
428
  */
380
429
  export async function readAppSecret(
381
430
  ref: SecretRef,
431
+ ): Promise<ReadSecretResult | null> {
432
+ const cache = requestSecretsCache();
433
+ const cacheKey = secretCacheKey(ref);
434
+ if (cache?.has(cacheKey)) return cache.get(cacheKey) ?? null;
435
+ const result = await readAppSecretUncached(ref);
436
+ cache?.set(cacheKey, result);
437
+ return result;
438
+ }
439
+
440
+ async function readAppSecretUncached(
441
+ ref: SecretRef,
382
442
  ): Promise<ReadSecretResult | null> {
383
443
  const { key, scope, scopeId } = ref;
384
444
  const { rows } = await executeAppSecretsRead({
@@ -417,15 +477,41 @@ export async function readAppSecrets(args: {
417
477
  scope: SecretScope;
418
478
  scopeId: string;
419
479
  }): Promise<Map<string, ReadSecretResult>> {
420
- const keys = [...new Set(args.keys.filter(Boolean))];
421
- if (keys.length === 0) return new Map();
480
+ const requested = [...new Set(args.keys.filter(Boolean))];
481
+ if (requested.length === 0) return new Map();
482
+
483
+ const cache = requestSecretsCache();
484
+ const results = new Map<string, ReadSecretResult>();
485
+ const keys: string[] = [];
486
+ for (const key of requested) {
487
+ const cacheKey = secretCacheKey({
488
+ key,
489
+ scope: args.scope,
490
+ scopeId: args.scopeId,
491
+ });
492
+ if (cache?.has(cacheKey)) {
493
+ const cached = cache.get(cacheKey);
494
+ if (cached) results.set(key, cached);
495
+ continue;
496
+ }
497
+ keys.push(key);
498
+ }
499
+ if (keys.length === 0) return results;
422
500
 
423
501
  const placeholders = keys.map(() => "?").join(", ");
424
502
  const { rows } = await executeAppSecretsRead({
425
503
  sql: `SELECT key, encrypted_value, shared_encrypted_value, updated_at, id FROM app_secrets WHERE scope = ? AND scope_id = ? AND key IN (${placeholders})`,
426
504
  args: [args.scope, args.scopeId, ...keys],
427
505
  });
428
- const results = new Map<string, ReadSecretResult>();
506
+ // The statement covered every uncached key in this scope, so a key missing
507
+ // from `rows` is genuinely absent — memo it as such rather than leaving a
508
+ // single-key read to go ask again.
509
+ for (const key of keys) {
510
+ cache?.set(
511
+ secretCacheKey({ key, scope: args.scope, scopeId: args.scopeId }),
512
+ null,
513
+ );
514
+ }
429
515
  for (const row of rows) {
430
516
  const key = String(row.key ?? "");
431
517
  if (!key) continue;
@@ -442,11 +528,16 @@ export async function readAppSecrets(args: {
442
528
  decrypted.sharedCiphertextToReplace ?? null,
443
529
  );
444
530
  }
445
- results.set(key, {
531
+ const result = {
446
532
  value: decrypted.value,
447
533
  last4: last4(decrypted.value),
448
534
  updatedAt: Number(row.updated_at ?? 0),
449
- });
535
+ };
536
+ results.set(key, result);
537
+ cache?.set(
538
+ secretCacheKey({ key, scope: args.scope, scopeId: args.scopeId }),
539
+ result,
540
+ );
450
541
  } catch {
451
542
  // Match readAppSecret: corrupted or stale ciphertext behaves as missing.
452
543
  }
@@ -589,6 +680,7 @@ function parseAllowlist(raw: string | null): string[] | null {
589
680
 
590
681
  export async function deleteAppSecret(ref: SecretRef): Promise<boolean> {
591
682
  await ensureTable();
683
+ invalidateRequestSecret(ref);
592
684
  const { key, scope, scopeId } = ref;
593
685
  const client = getDbExec();
594
686
  const { rowsAffected } = await client.execute({
@@ -4,10 +4,43 @@ import {
4
4
  actionChangeMarkerValue,
5
5
  type ActionChangeTarget,
6
6
  } from "../action-change-marker.js";
7
+ import type { ActionPlanModeConfig } from "../action.js";
7
8
  import { appStatePut } from "../application-state/store.js";
8
9
  import { recordChange } from "./poll.js";
9
10
  import { getRequestOrgId, getRequestUserEmail } from "./request-context.js";
10
11
 
12
+ /**
13
+ * Whether THIS invocation should announce a change.
14
+ *
15
+ * `readOnly` describes an action name, but a tool that discriminates on an
16
+ * argument reads on some calls and writes on others — `manage-agent-engine`
17
+ * with `action: "list"` is a status poll, with `action: "set"` a write. Such
18
+ * actions already publish the per-call answer as a Plan-mode `effect`
19
+ * predicate, so read it here instead of treating every call as a write: a poll
20
+ * that bumps the `"action"` change version makes every query keyed on it
21
+ * refetch forever.
22
+ *
23
+ * `readOnly` stays authoritative otherwise. Widening the flag to cover mixed
24
+ * tools would also open them to read-only A2A peers and to the agent's
25
+ * read-result cache, where it would be false.
26
+ */
27
+ export function actionCallIsReadOnly(
28
+ entry: { readOnly?: boolean; planMode?: ActionPlanModeConfig<any> },
29
+ params: unknown,
30
+ fallback: boolean,
31
+ ): boolean {
32
+ const effect = entry.planMode?.effect;
33
+ if (typeof effect === "function") {
34
+ try {
35
+ return effect(params) === "read";
36
+ } catch {
37
+ // A predicate that throws says nothing; fall through to the flag.
38
+ }
39
+ }
40
+ if (typeof entry.readOnly === "boolean") return entry.readOnly;
41
+ return fallback;
42
+ }
43
+
11
44
  export interface NotifyActionChangeOptions {
12
45
  actionName: string;
13
46
  owner?: string;