@agent-native/dispatch 0.41.1-nightly-20261104110058 → 0.42.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 (195) hide show
  1. package/CHANGELOG.md +26 -55
  2. package/dist/actions/get-hosted-apps-settings.d.ts +5 -0
  3. package/dist/actions/get-hosted-apps-settings.d.ts.map +1 -0
  4. package/dist/actions/get-hosted-apps-settings.js +25 -0
  5. package/dist/actions/get-hosted-apps-settings.js.map +1 -0
  6. package/dist/actions/index.d.ts.map +1 -1
  7. package/dist/actions/index.js +4 -0
  8. package/dist/actions/index.js.map +1 -1
  9. package/dist/actions/list-connected-agents.d.ts +1 -0
  10. package/dist/actions/list-connected-agents.js +11 -10
  11. package/dist/actions/list-connected-agents.js.map +1 -1
  12. package/dist/actions/list_apps.js +3 -2
  13. package/dist/actions/list_apps.js.map +1 -1
  14. package/dist/actions/provider-api-register.d.ts +10 -10
  15. package/dist/actions/set-hosted-apps-settings.d.ts +7 -0
  16. package/dist/actions/set-hosted-apps-settings.d.ts.map +1 -0
  17. package/dist/actions/set-hosted-apps-settings.js +35 -0
  18. package/dist/actions/set-hosted-apps-settings.js.map +1 -0
  19. package/dist/components/app-card-tiers.d.ts +13 -0
  20. package/dist/components/app-card-tiers.d.ts.map +1 -0
  21. package/dist/components/app-card-tiers.js +22 -0
  22. package/dist/components/app-card-tiers.js.map +1 -0
  23. package/dist/components/app-list-row.d.ts +12 -4
  24. package/dist/components/app-list-row.d.ts.map +1 -1
  25. package/dist/components/app-list-row.js +19 -4
  26. package/dist/components/app-list-row.js.map +1 -1
  27. package/dist/components/app-open-actions.d.ts +4 -0
  28. package/dist/components/app-open-actions.d.ts.map +1 -1
  29. package/dist/components/app-open-actions.js +9 -0
  30. package/dist/components/app-open-actions.js.map +1 -1
  31. package/dist/components/app-pin-button.d.ts +9 -0
  32. package/dist/components/app-pin-button.d.ts.map +1 -0
  33. package/dist/components/app-pin-button.js +29 -0
  34. package/dist/components/app-pin-button.js.map +1 -0
  35. package/dist/components/app-tier-sections.d.ts +27 -0
  36. package/dist/components/app-tier-sections.d.ts.map +1 -0
  37. package/dist/components/app-tier-sections.js +44 -0
  38. package/dist/components/app-tier-sections.js.map +1 -0
  39. package/dist/components/chat-home-apps.d.ts.map +1 -1
  40. package/dist/components/chat-home-apps.js +19 -26
  41. package/dist/components/chat-home-apps.js.map +1 -1
  42. package/dist/components/connected-app-card.d.ts +3 -1
  43. package/dist/components/connected-app-card.d.ts.map +1 -1
  44. package/dist/components/connected-app-card.js +6 -3
  45. package/dist/components/connected-app-card.js.map +1 -1
  46. package/dist/components/create-app-popover.d.ts +4 -0
  47. package/dist/components/create-app-popover.d.ts.map +1 -1
  48. package/dist/components/create-app-popover.js +6 -0
  49. package/dist/components/create-app-popover.js.map +1 -1
  50. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  51. package/dist/components/dispatch-control-plane.js +25 -35
  52. package/dist/components/dispatch-control-plane.js.map +1 -1
  53. package/dist/components/hosted-apps-settings-group.d.ts +6 -0
  54. package/dist/components/hosted-apps-settings-group.d.ts.map +1 -0
  55. package/dist/components/hosted-apps-settings-group.js +76 -0
  56. package/dist/components/hosted-apps-settings-group.js.map +1 -0
  57. package/dist/components/layout/Layout.d.ts +6 -4
  58. package/dist/components/layout/Layout.d.ts.map +1 -1
  59. package/dist/components/layout/Layout.js +97 -80
  60. package/dist/components/layout/Layout.js.map +1 -1
  61. package/dist/components/workspace-app-card.d.ts.map +1 -1
  62. package/dist/components/workspace-app-card.js +9 -20
  63. package/dist/components/workspace-app-card.js.map +1 -1
  64. package/dist/components/workspace-app-layout-notice.d.ts +9 -0
  65. package/dist/components/workspace-app-layout-notice.d.ts.map +1 -0
  66. package/dist/components/workspace-app-layout-notice.js +27 -0
  67. package/dist/components/workspace-app-layout-notice.js.map +1 -0
  68. package/dist/components/workspace-app-search.d.ts.map +1 -1
  69. package/dist/components/workspace-app-search.js +11 -5
  70. package/dist/components/workspace-app-search.js.map +1 -1
  71. package/dist/components/workspace-template-card.d.ts.map +1 -1
  72. package/dist/components/workspace-template-card.js +7 -2
  73. package/dist/components/workspace-template-card.js.map +1 -1
  74. package/dist/db/index.d.ts.map +1 -1
  75. package/dist/db/index.js +1 -0
  76. package/dist/db/index.js.map +1 -1
  77. package/dist/lib/other-apps.d.ts +10 -1
  78. package/dist/lib/other-apps.d.ts.map +1 -1
  79. package/dist/lib/other-apps.js +7 -1
  80. package/dist/lib/other-apps.js.map +1 -1
  81. package/dist/lib/workspace-app-layout.d.ts +62 -6
  82. package/dist/lib/workspace-app-layout.d.ts.map +1 -1
  83. package/dist/lib/workspace-app-layout.js +196 -41
  84. package/dist/lib/workspace-app-layout.js.map +1 -1
  85. package/dist/lib/workspace-app-lists.d.ts +17 -0
  86. package/dist/lib/workspace-app-lists.d.ts.map +1 -0
  87. package/dist/lib/workspace-app-lists.js +21 -0
  88. package/dist/lib/workspace-app-lists.js.map +1 -0
  89. package/dist/lib/workspace-apps.d.ts +3 -1
  90. package/dist/lib/workspace-apps.d.ts.map +1 -1
  91. package/dist/lib/workspace-apps.js +5 -1
  92. package/dist/lib/workspace-apps.js.map +1 -1
  93. package/dist/routes/pages/apps.d.ts.map +1 -1
  94. package/dist/routes/pages/apps.js +23 -35
  95. package/dist/routes/pages/apps.js.map +1 -1
  96. package/dist/routes/pages/settings-page.d.ts.map +1 -1
  97. package/dist/routes/pages/settings-page.js +24 -3
  98. package/dist/routes/pages/settings-page.js.map +1 -1
  99. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  100. package/dist/server/lib/app-creation-store.js +13 -18
  101. package/dist/server/lib/app-creation-store.js.map +1 -1
  102. package/dist/server/lib/app-roles.d.ts +2 -0
  103. package/dist/server/lib/app-roles.d.ts.map +1 -1
  104. package/dist/server/lib/app-roles.js +26 -7
  105. package/dist/server/lib/app-roles.js.map +1 -1
  106. package/dist/server/lib/hosted-apps-settings-schema.d.ts +6 -0
  107. package/dist/server/lib/hosted-apps-settings-schema.d.ts.map +1 -0
  108. package/dist/server/lib/hosted-apps-settings-schema.js +6 -0
  109. package/dist/server/lib/hosted-apps-settings-schema.js.map +1 -0
  110. package/dist/server/lib/hosted-apps-settings.d.ts +27 -0
  111. package/dist/server/lib/hosted-apps-settings.d.ts.map +1 -0
  112. package/dist/server/lib/hosted-apps-settings.js +72 -0
  113. package/dist/server/lib/hosted-apps-settings.js.map +1 -0
  114. package/dist/server/plugins/agent-chat.js +6 -3
  115. package/dist/server/plugins/agent-chat.js.map +1 -1
  116. package/dist/server/plugins/integrations.js +3 -3
  117. package/dist/server/plugins/integrations.js.map +1 -1
  118. package/dist/shared/hosted-apps-settings.d.ts +3 -0
  119. package/dist/shared/hosted-apps-settings.d.ts.map +1 -0
  120. package/dist/shared/hosted-apps-settings.js +3 -0
  121. package/dist/shared/hosted-apps-settings.js.map +1 -0
  122. package/package.json +4 -4
  123. package/src/actions/get-hosted-apps-settings.ts +29 -0
  124. package/src/actions/hosted-apps-settings.spec.ts +100 -0
  125. package/src/actions/index.ts +4 -0
  126. package/src/actions/list-connected-agents.spec.ts +67 -0
  127. package/src/actions/list-connected-agents.ts +14 -15
  128. package/src/actions/list_apps.spec.ts +16 -0
  129. package/src/actions/list_apps.ts +5 -2
  130. package/src/actions/set-hosted-apps-settings.ts +42 -0
  131. package/src/components/app-card-tiers.tsx +85 -0
  132. package/src/components/app-list-row.spec.tsx +54 -0
  133. package/src/components/app-list-row.tsx +50 -8
  134. package/src/components/app-open-actions.tsx +16 -0
  135. package/src/components/app-pin-button.spec.tsx +68 -0
  136. package/src/components/app-pin-button.tsx +66 -0
  137. package/src/components/app-tier-sections.spec.tsx +155 -0
  138. package/src/components/app-tier-sections.tsx +158 -0
  139. package/src/components/chat-home-apps.spec.tsx +135 -29
  140. package/src/components/chat-home-apps.tsx +115 -111
  141. package/src/components/connected-app-card.spec.tsx +18 -2
  142. package/src/components/connected-app-card.tsx +18 -1
  143. package/src/components/create-app-popover.tsx +22 -0
  144. package/src/components/dispatch-control-plane.spec.tsx +89 -20
  145. package/src/components/dispatch-control-plane.tsx +91 -125
  146. package/src/components/hosted-apps-settings-group.spec.tsx +218 -0
  147. package/src/components/hosted-apps-settings-group.tsx +189 -0
  148. package/src/components/layout/Layout.spec.tsx +356 -49
  149. package/src/components/layout/Layout.tsx +189 -142
  150. package/src/components/workspace-app-card.spec.tsx +33 -29
  151. package/src/components/workspace-app-card.tsx +13 -31
  152. package/src/components/workspace-app-layout-notice.spec.tsx +106 -0
  153. package/src/components/workspace-app-layout-notice.tsx +65 -0
  154. package/src/components/workspace-app-search.spec.tsx +53 -0
  155. package/src/components/workspace-app-search.tsx +39 -32
  156. package/src/components/workspace-template-card.spec.tsx +10 -0
  157. package/src/components/workspace-template-card.tsx +7 -2
  158. package/src/db/index.spec.ts +9 -0
  159. package/src/db/index.ts +1 -0
  160. package/src/lib/other-apps.spec.ts +25 -0
  161. package/src/lib/other-apps.ts +9 -0
  162. package/src/lib/workspace-app-layout.spec.ts +61 -3
  163. package/src/lib/workspace-app-layout.store.spec.tsx +261 -0
  164. package/src/lib/workspace-app-layout.ts +265 -48
  165. package/src/lib/workspace-app-lists.ts +32 -0
  166. package/src/lib/workspace-apps.ts +10 -2
  167. package/src/routes/pages/apps.spec.tsx +133 -5
  168. package/src/routes/pages/apps.tsx +79 -140
  169. package/src/routes/pages/chat.spec.tsx +66 -49
  170. package/src/routes/pages/settings-page.tsx +31 -3
  171. package/src/routes/pages/settings.spec.tsx +37 -0
  172. package/src/server/lib/app-creation-store.spec.ts +14 -0
  173. package/src/server/lib/app-creation-store.ts +16 -29
  174. package/src/server/lib/app-creation-store.write-observation.spec.ts +216 -0
  175. package/src/server/lib/app-roles.spec.ts +48 -1
  176. package/src/server/lib/app-roles.ts +44 -12
  177. package/src/server/lib/hosted-apps-settings-schema.ts +6 -0
  178. package/src/server/lib/hosted-apps-settings.spec.ts +154 -0
  179. package/src/server/lib/hosted-apps-settings.ts +108 -0
  180. package/src/server/plugins/agent-chat.spec.ts +16 -0
  181. package/src/server/plugins/agent-chat.ts +7 -2
  182. package/src/server/plugins/integrations.ts +3 -3
  183. package/src/shared/hosted-apps-settings.ts +3 -0
  184. package/src/styles/dispatch.css +40 -11
  185. package/dist/components/available-apps-section.d.ts +0 -9
  186. package/dist/components/available-apps-section.d.ts.map +0 -1
  187. package/dist/components/available-apps-section.js +0 -97
  188. package/dist/components/available-apps-section.js.map +0 -1
  189. package/dist/components/other-apps-section.d.ts +0 -35
  190. package/dist/components/other-apps-section.d.ts.map +0 -1
  191. package/dist/components/other-apps-section.js +0 -79
  192. package/dist/components/other-apps-section.js.map +0 -1
  193. package/src/components/available-apps-section.tsx +0 -239
  194. package/src/components/other-apps-section.spec.ts +0 -50
  195. package/src/components/other-apps-section.tsx +0 -254
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
4
4
  import { MemoryRouter } from "react-router";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
+ import { TooltipProvider } from "../../components/ui/tooltip";
7
8
  import ChatRoute from "./chat";
8
9
 
9
10
  const clientState = vi.hoisted(() => ({
@@ -57,6 +58,7 @@ vi.mock("@agent-native/toolkit/app/chat/AgentChatHome", () => ({
57
58
 
58
59
  vi.mock("../../components/create-app-popover", () => ({
59
60
  CreateAppPopover: () => null,
61
+ NewAppButton: () => null,
60
62
  }));
61
63
 
62
64
  vi.mock("@agent-native/core/client/hooks", () => ({
@@ -75,14 +77,15 @@ vi.mock("../../components/layout/Layout", () => ({
75
77
  useDispatchWorkspaceAppLauncher: () => ({
76
78
  apps: clientState.workspaceApps,
77
79
  workspaceApps: clientState.workspaceApps,
78
- isLoading: false,
80
+ workspaceAppsStatus: "ready",
79
81
  error: clientState.workspaceAppsError,
80
82
  openApp: clientState.openWorkspaceApp,
81
83
  retry: clientState.retryWorkspaceApps,
82
84
  }),
83
85
  }));
84
86
 
85
- vi.mock("../../lib/workspace-app-layout", () => ({
87
+ vi.mock("../../lib/workspace-app-layout", async (importOriginal) => ({
88
+ ...(await importOriginal<typeof import("../../lib/workspace-app-layout")>()),
86
89
  workspaceAppMatchesQuery: (
87
90
  app: { name: string; description?: string },
88
91
  query: string,
@@ -150,9 +153,11 @@ describe("Dispatch ChatRoute", () => {
150
153
  it("keeps the centered hero layout for a direct new Chat", async () => {
151
154
  await act(async () => {
152
155
  root.render(
153
- <MemoryRouter initialEntries={["/chat"]}>
154
- <ChatRoute />
155
- </MemoryRouter>,
156
+ <TooltipProvider>
157
+ <MemoryRouter initialEntries={["/chat"]}>
158
+ <ChatRoute />
159
+ </MemoryRouter>
160
+ </TooltipProvider>,
156
161
  );
157
162
  });
158
163
 
@@ -176,24 +181,26 @@ describe("Dispatch ChatRoute", () => {
176
181
  it("opens a Dispatch run with its Agent Teams parent context", async () => {
177
182
  await act(async () => {
178
183
  root.render(
179
- <MemoryRouter
180
- initialEntries={[
181
- {
182
- pathname: "/chat",
183
- state: {
184
- dispatchThread: {
185
- id: "run-open-1",
186
- threadId: "child-thread",
187
- parentThreadId: "parent-thread",
188
- description: "Review the Slides task",
189
- name: "Slides review",
184
+ <TooltipProvider>
185
+ <MemoryRouter
186
+ initialEntries={[
187
+ {
188
+ pathname: "/chat",
189
+ state: {
190
+ dispatchThread: {
191
+ id: "run-open-1",
192
+ threadId: "child-thread",
193
+ parentThreadId: "parent-thread",
194
+ description: "Review the Slides task",
195
+ name: "Slides review",
196
+ },
190
197
  },
191
198
  },
192
- },
193
- ]}
194
- >
195
- <ChatRoute />
196
- </MemoryRouter>,
199
+ ]}
200
+ >
201
+ <ChatRoute />
202
+ </MemoryRouter>
203
+ </TooltipProvider>,
197
204
  );
198
205
  });
199
206
 
@@ -213,9 +220,11 @@ describe("Dispatch ChatRoute", () => {
213
220
  it("renders the colored app directory below the empty chat composer", async () => {
214
221
  await act(async () => {
215
222
  root.render(
216
- <MemoryRouter initialEntries={["/chat"]}>
217
- <ChatRoute />
218
- </MemoryRouter>,
223
+ <TooltipProvider>
224
+ <MemoryRouter initialEntries={["/chat"]}>
225
+ <ChatRoute />
226
+ </MemoryRouter>
227
+ </TooltipProvider>,
219
228
  );
220
229
  });
221
230
 
@@ -240,9 +249,11 @@ describe("Dispatch ChatRoute", () => {
240
249
 
241
250
  await act(async () => {
242
251
  root.render(
243
- <MemoryRouter initialEntries={["/chat"]}>
244
- <ChatRoute />
245
- </MemoryRouter>,
252
+ <TooltipProvider>
253
+ <MemoryRouter initialEntries={["/chat"]}>
254
+ <ChatRoute />
255
+ </MemoryRouter>
256
+ </TooltipProvider>,
246
257
  );
247
258
  });
248
259
 
@@ -263,22 +274,24 @@ describe("Dispatch ChatRoute", () => {
263
274
  it("starts bottom-pinned when an Overview prompt is transitioning in", async () => {
264
275
  await act(async () => {
265
276
  root.render(
266
- <MemoryRouter
267
- initialEntries={[
268
- {
269
- pathname: "/chat",
270
- state: {
271
- dispatchPrompt: {
272
- id: "overview-prompt",
273
- message: "Route this across my apps",
274
- selectedModel: "auto",
277
+ <TooltipProvider>
278
+ <MemoryRouter
279
+ initialEntries={[
280
+ {
281
+ pathname: "/chat",
282
+ state: {
283
+ dispatchPrompt: {
284
+ id: "overview-prompt",
285
+ message: "Route this across my apps",
286
+ selectedModel: "auto",
287
+ },
275
288
  },
276
289
  },
277
- },
278
- ]}
279
- >
280
- <ChatRoute />
281
- </MemoryRouter>,
290
+ ]}
291
+ >
292
+ <ChatRoute />
293
+ </MemoryRouter>
294
+ </TooltipProvider>,
282
295
  );
283
296
  });
284
297
 
@@ -320,11 +333,13 @@ describe("Dispatch ChatRoute", () => {
320
333
 
321
334
  await act(async () => {
322
335
  root.render(
323
- <MemoryRouter
324
- initialEntries={["/chat?agent=agents/research-partner.md"]}
325
- >
326
- <ChatRoute />
327
- </MemoryRouter>,
336
+ <TooltipProvider>
337
+ <MemoryRouter
338
+ initialEntries={["/chat?agent=agents/research-partner.md"]}
339
+ >
340
+ <ChatRoute />
341
+ </MemoryRouter>
342
+ </TooltipProvider>,
328
343
  );
329
344
  });
330
345
 
@@ -349,9 +364,11 @@ describe("Dispatch ChatRoute", () => {
349
364
  it("keeps threaded chats free of request ID chrome", async () => {
350
365
  await act(async () => {
351
366
  root.render(
352
- <MemoryRouter initialEntries={["/chat/chat-123"]}>
353
- <ChatRoute />
354
- </MemoryRouter>,
367
+ <TooltipProvider>
368
+ <MemoryRouter initialEntries={["/chat/chat-123"]}>
369
+ <ChatRoute />
370
+ </MemoryRouter>
371
+ </TooltipProvider>,
355
372
  );
356
373
  });
357
374
 
@@ -10,7 +10,11 @@ import {
10
10
  import { useT } from "@agent-native/core/client/i18n";
11
11
  import { SETTINGS_REDESIGN_FLAG } from "@agent-native/core/feature-flags/registry";
12
12
  import { ChangelogSettingsCard } from "@agent-native/toolkit/app/changelog";
13
- import { OrgMembersPage, TeamPage } from "@agent-native/toolkit/app/org";
13
+ import {
14
+ OrgAppsPage,
15
+ OrgMembersPage,
16
+ TeamPage,
17
+ } from "@agent-native/toolkit/app/org";
14
18
  import {
15
19
  AccountSettingsCard,
16
20
  CORE_SETTINGS_PAGES,
@@ -26,6 +30,7 @@ import { IconShield } from "@tabler/icons-react";
26
30
  import { useMemo, useState } from "react";
27
31
  import { Link } from "react-router";
28
32
 
33
+ import { HostedAppsSettingsGroup } from "../../components/hosted-apps-settings-group";
29
34
  import { Button } from "../../components/ui/button";
30
35
  import { Switch } from "../../components/ui/switch";
31
36
  import { dispatchAccessDescriptor } from "../../shared/app-roles.js";
@@ -38,14 +43,37 @@ if (!coreMembersPage) {
38
43
  throw new Error("Core Settings has no members page for Dispatch app roles");
39
44
  }
40
45
 
46
+ const coreAppsPage = CORE_SETTINGS_PAGES.find((page) => page.id === "apps");
47
+ if (!coreAppsPage) {
48
+ throw new Error("Core Settings has no apps page for Dispatch hosted apps");
49
+ }
50
+
41
51
  function DispatchMembersSettingsPage() {
42
52
  return <OrgMembersPage appRoles={dispatchAccessDescriptor} />;
43
53
  }
44
54
 
45
- // The redesigned Settings shows Members from core; Dispatch's replaces it so
46
- // the app-role column stays.
55
+ function DispatchAppsSettingsPage() {
56
+ return <OrgAppsPage extraGroups={<HostedAppsSettingsGroup />} />;
57
+ }
58
+
59
+ // The redesigned Settings shows Members and Apps from core; Dispatch's
60
+ // replace them so the app-role column and the hosted apps group stay.
47
61
  registerSettingsPages([
48
62
  { ...coreMembersPage, component: DispatchMembersSettingsPage },
63
+ {
64
+ ...coreAppsPage,
65
+ component: DispatchAppsSettingsPage,
66
+ searchEntries: [
67
+ ...(coreAppsPage.searchEntries ?? []),
68
+ {
69
+ id: "hosted-apps",
70
+ labelKey: "dispatch.pages.showHostedApps",
71
+ keywords:
72
+ "agent-native.com hosted apps built-in agent-native mail calendar hide",
73
+ anchor: "show-hosted-apps",
74
+ },
75
+ ],
76
+ },
49
77
  ]);
50
78
 
51
79
  export function meta() {
@@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({
15
15
  searchEntries?: unknown;
16
16
  }>,
17
17
  membersAppRoles: undefined as unknown,
18
+ appsExtraGroups: undefined as ReactNode,
18
19
  pageProps: null as null | {
19
20
  general?: ReactNode;
20
21
  generalGroups?: ReactNode;
@@ -61,6 +62,14 @@ vi.mock("@agent-native/toolkit/app/org", () => ({
61
62
  state.membersAppRoles = appRoles;
62
63
  return <div data-members-page />;
63
64
  },
65
+ OrgAppsPage: ({ extraGroups }: { extraGroups?: ReactNode }) => {
66
+ state.appsExtraGroups = extraGroups;
67
+ return <div data-apps-page />;
68
+ },
69
+ }));
70
+
71
+ vi.mock("../../components/hosted-apps-settings-group", () => ({
72
+ HostedAppsSettingsGroup: () => <section data-hosted-apps-group />,
64
73
  }));
65
74
 
66
75
  vi.mock("@agent-native/toolkit/app/settings", () => ({
@@ -72,6 +81,11 @@ vi.mock("@agent-native/toolkit/app/settings", () => ({
72
81
  component: () => null,
73
82
  searchEntries: [{ id: "invite-members" }],
74
83
  },
84
+ {
85
+ id: "apps",
86
+ component: () => null,
87
+ searchEntries: [{ id: "new-app-privacy" }],
88
+ },
75
89
  ],
76
90
  registerSettingsPages: (pages: typeof state.registered) => {
77
91
  state.registered.push(...pages);
@@ -145,6 +159,29 @@ describe("Dispatch settings route", () => {
145
159
  expect(state.membersAppRoles).toBe(dispatchAccessDescriptor);
146
160
  });
147
161
 
162
+ it("replaces core Apps with one that adds the hosted apps group", () => {
163
+ const apps = state.registered.find((page) => page.id === "apps");
164
+ expect(apps?.searchEntries).toEqual([
165
+ { id: "new-app-privacy" },
166
+ expect.objectContaining({
167
+ id: "hosted-apps",
168
+ labelKey: "dispatch.pages.showHostedApps",
169
+ anchor: "show-hosted-apps",
170
+ }),
171
+ ]);
172
+
173
+ const Apps = apps!.component;
174
+ act(() => {
175
+ root.render(<Apps />);
176
+ });
177
+ expect(container.querySelector("[data-apps-page]")).not.toBeNull();
178
+
179
+ act(() => {
180
+ root.render(<>{state.appsExtraGroups}</>);
181
+ });
182
+ expect(container.querySelector("[data-hosted-apps-group]")).not.toBeNull();
183
+ });
184
+
148
185
  it("keeps the language row and Admin link on today's General tab", () => {
149
186
  render();
150
187
 
@@ -209,6 +209,20 @@ vi.mock("@agent-native/core/sharing", async (importOriginal) => {
209
209
  return {
210
210
  ...actual,
211
211
  resolveAccess: (...args: any[]) => mocks.resolveAccess(...args),
212
+ // core's own tests prove resolveAccessMany agrees with resolveAccess, so
213
+ // this double answers through the same per-app mock the cases configure.
214
+ resolveAccessMany: async (
215
+ resourceType: string,
216
+ resourceIds: string[],
217
+ ...rest: any[]
218
+ ) => {
219
+ const access = new Map<string, unknown>();
220
+ for (const id of resourceIds) {
221
+ const resolved = await mocks.resolveAccess(resourceType, id, ...rest);
222
+ if (resolved) access.set(id, resolved);
223
+ }
224
+ return access;
225
+ },
212
226
  };
213
227
  });
214
228
 
@@ -11,6 +11,7 @@ import {
11
11
  import {
12
12
  ActionContractError,
13
13
  isActionContractError,
14
+ noteExternalWrite,
14
15
  } from "@agent-native/core/action";
15
16
  import { describeErrorWithCauses } from "@agent-native/core/agent/engine/error-detail";
16
17
  import { getAppConfig } from "@agent-native/core/app-config";
@@ -55,7 +56,7 @@ import {
55
56
  parseAgentProfileMetadata,
56
57
  type ConnectRequiredCard,
57
58
  } from "@agent-native/core/shared";
58
- import { resolveAccess } from "@agent-native/core/sharing";
59
+ import { resolveAccessMany } from "@agent-native/core/sharing";
59
60
 
60
61
  import { getDb } from "../../db/index.js";
61
62
  import { workspaceAppRoute } from "../../shared/workspace-app-route.js";
@@ -95,7 +96,6 @@ const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
95
96
  const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
96
97
  const WORKSPACE_APPS_GATEWAY_PATH = "/_workspace/apps";
97
98
  const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS = 2_500;
98
- const WORKSPACE_APP_ACCESS_CONCURRENCY = 8;
99
99
  const MAX_PENDING_APPS = 50;
100
100
  const PENDING_WORKSPACE_APP_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
101
101
  const AGENT_CARD_PATH = "/.well-known/agent-card.json";
@@ -1889,33 +1889,17 @@ async function filterWorkspaceAppsByAccess(
1889
1889
  candidates.push(app);
1890
1890
  }
1891
1891
 
1892
- for (
1893
- let start = 0;
1894
- start < candidates.length;
1895
- start += WORKSPACE_APP_ACCESS_CONCURRENCY
1896
- ) {
1897
- const batch = candidates.slice(
1898
- start,
1899
- start + WORKSPACE_APP_ACCESS_CONCURRENCY,
1900
- );
1901
- const decisions = await Promise.all(
1902
- batch.map(async (app) => {
1903
- try {
1904
- const access = await resolveAccess(
1905
- "workspace-app",
1906
- app.id,
1907
- { userEmail, orgId },
1908
- { skipResourceBody: true },
1909
- );
1910
- return { app, allowed: Boolean(access) };
1911
- } catch (error) {
1912
- console.warn("[dispatch] workspace app access lookup failed", error);
1913
- return { app, allowed: false };
1914
- }
1915
- }),
1916
- );
1917
- for (const decision of decisions) {
1918
- if (decision.allowed) visibleIds.add(decision.app.id);
1892
+ if (candidates.length > 0) {
1893
+ try {
1894
+ const access = await resolveAccessMany(
1895
+ "workspace-app",
1896
+ candidates.map((app) => app.id),
1897
+ { userEmail, ...(orgId ? { orgId } : {}) },
1898
+ { skipResourceBody: true },
1899
+ );
1900
+ for (const id of access.keys()) visibleIds.add(id);
1901
+ } catch (error) {
1902
+ console.warn("[dispatch] workspace app access lookup failed", error);
1919
1903
  }
1920
1904
  }
1921
1905
 
@@ -2829,6 +2813,7 @@ export async function scaffoldWorkspaceAppFromTemplate(input: {
2829
2813
  });
2830
2814
  } catch (error) {
2831
2815
  if (fs.existsSync(appDir)) {
2816
+ noteExternalWrite("local-disk");
2832
2817
  fs.rmSync(appDir, { recursive: true, force: true });
2833
2818
  }
2834
2819
  throw error;
@@ -2845,6 +2830,7 @@ export async function scaffoldWorkspaceAppFromTemplate(input: {
2845
2830
  });
2846
2831
  } catch (error) {
2847
2832
  if (fs.existsSync(appDir)) {
2833
+ noteExternalWrite("local-disk");
2848
2834
  fs.rmSync(appDir, { recursive: true, force: true });
2849
2835
  }
2850
2836
  throw error;
@@ -2885,6 +2871,7 @@ function runScaffoldCli(input: {
2885
2871
  args: string[];
2886
2872
  }): Promise<string> {
2887
2873
  return new Promise((resolve, reject) => {
2874
+ noteExternalWrite("child-process");
2888
2875
  // pnpm is a pnpm.cmd shim on Windows, which Node starts only through a
2889
2876
  // shell. The app id is validated and the template comes from an allowlist,
2890
2877
  // so one joined command line is safe there.
@@ -0,0 +1,216 @@
1
+ import { EventEmitter } from "node:events";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { runWithRequestContext } from "@agent-native/core/server";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import { scaffoldWorkspaceAppFromTemplate } from "./app-creation-store.js";
10
+
11
+ // Scaffolding runs the `agent-native add-app` CLI as a child process, and when
12
+ // it fails the app directory it left behind is removed. Each of those writes
13
+ // reports itself before it runs, so the framework can tell the call wrote.
14
+ const mocks = vi.hoisted(() => ({
15
+ noteExternalWrite: vi.fn(),
16
+ spawn: vi.fn(),
17
+ getSetting: vi.fn(async (_key: string): Promise<unknown> => null),
18
+ putSetting: vi.fn(async () => {}),
19
+ mutateSetting: vi.fn(async () => null),
20
+ getOrgSetting: vi.fn(
21
+ async (_orgId: string, _key: string): Promise<unknown> => null,
22
+ ),
23
+ execute: vi.fn(async () => ({ rows: [] as unknown[], rowsAffected: 0 })),
24
+ }));
25
+
26
+ vi.mock("@agent-native/core/action", async (importOriginal) => ({
27
+ ...(await importOriginal<typeof import("@agent-native/core/action")>()),
28
+ noteExternalWrite: mocks.noteExternalWrite,
29
+ }));
30
+
31
+ vi.mock("node:child_process", async (importOriginal) => ({
32
+ ...(await importOriginal<typeof import("node:child_process")>()),
33
+ spawn: (...args: unknown[]) => mocks.spawn(...args),
34
+ }));
35
+
36
+ vi.mock("@agent-native/core/db", async (importOriginal) => ({
37
+ ...(await importOriginal<typeof import("@agent-native/core/db")>()),
38
+ getDbExec: () => ({ execute: mocks.execute }),
39
+ }));
40
+
41
+ vi.mock("@agent-native/core/settings", () => ({
42
+ getSetting: mocks.getSetting,
43
+ putSetting: mocks.putSetting,
44
+ mutateSetting: mocks.mutateSetting,
45
+ getOrgSetting: mocks.getOrgSetting,
46
+ }));
47
+
48
+ const ORG_ID = "org-123";
49
+ const METADATA_KEY = "workspace-app-metadata:org:org-123";
50
+
51
+ const roots: string[] = [];
52
+ const spies: { mockRestore: () => void }[] = [];
53
+
54
+ class FakeCli extends EventEmitter {
55
+ readonly stdout = new EventEmitter();
56
+ readonly stderr = new EventEmitter();
57
+ readonly kill = vi.fn();
58
+ }
59
+
60
+ /** The position, within the whole run, of a spy's index-th call. */
61
+ function callOrder(
62
+ spy: { mock: { invocationCallOrder: number[] } },
63
+ index = 0,
64
+ ): number {
65
+ const order = spy.mock.invocationCallOrder[index];
66
+ if (order === undefined) {
67
+ throw new Error(`Expected call ${index} to be recorded.`);
68
+ }
69
+ return order;
70
+ }
71
+
72
+ /** A workspace root that `findWorkspaceRoot` finds from the process cwd. */
73
+ function workspace(): string {
74
+ const root = fs.mkdtempSync(
75
+ path.join(os.tmpdir(), "dispatch-scaffold-observed-"),
76
+ );
77
+ roots.push(root);
78
+ fs.writeFileSync(
79
+ path.join(root, "package.json"),
80
+ JSON.stringify({
81
+ name: "workspace",
82
+ "agent-native": { workspaceCore: "@agent-native/core" },
83
+ }),
84
+ );
85
+ spies.push(vi.spyOn(process, "cwd").mockReturnValue(root));
86
+ return root;
87
+ }
88
+
89
+ function observeRemovals() {
90
+ const spy = vi.spyOn(fs, "rmSync");
91
+ spies.push(spy);
92
+ return spy;
93
+ }
94
+
95
+ /** Stands in for `pnpm exec agent-native`: runs `beforeExit`, then exits. */
96
+ function fakeCli(input: {
97
+ code: number;
98
+ stderr?: string;
99
+ beforeExit?: () => void;
100
+ }): void {
101
+ mocks.spawn.mockImplementation(() => {
102
+ const child = new FakeCli();
103
+ setImmediate(() => {
104
+ input.beforeExit?.();
105
+ if (input.stderr) child.stderr.emit("data", input.stderr);
106
+ child.emit("exit", input.code);
107
+ });
108
+ return child;
109
+ });
110
+ }
111
+
112
+ function scaffold() {
113
+ return runWithRequestContext(
114
+ { userEmail: "dev@example.test", orgId: ORG_ID },
115
+ () => scaffoldWorkspaceAppFromTemplate({ template: "mail" }),
116
+ );
117
+ }
118
+
119
+ beforeEach(() => {
120
+ mocks.spawn.mockReset();
121
+ mocks.spawn.mockImplementation(() => {
122
+ throw new Error("Unexpected CLI spawn in the scaffold write spec.");
123
+ });
124
+ mocks.getSetting.mockReset();
125
+ mocks.getSetting.mockResolvedValue(null);
126
+ mocks.getOrgSetting.mockReset();
127
+ mocks.getOrgSetting.mockResolvedValue(null);
128
+ mocks.execute.mockReset();
129
+ mocks.execute.mockResolvedValue({ rows: [], rowsAffected: 0 });
130
+ });
131
+
132
+ afterEach(() => {
133
+ for (const spy of spies.splice(0)) spy.mockRestore();
134
+ for (const root of roots.splice(0)) {
135
+ fs.rmSync(root, { recursive: true, force: true });
136
+ }
137
+ vi.clearAllMocks();
138
+ });
139
+
140
+ describe("scaffoldWorkspaceAppFromTemplate write observation", () => {
141
+ it("notes the child-process write before the scaffold CLI spawns", async () => {
142
+ const root = workspace();
143
+ fakeCli({ code: 1, stderr: "boom" });
144
+ const rmSync = observeRemovals();
145
+
146
+ await expect(scaffold()).rejects.toThrow("Scaffold failed: boom");
147
+
148
+ expect(mocks.spawn).toHaveBeenCalledWith(
149
+ "pnpm",
150
+ ["exec", "agent-native", "add-app", "mail", "--template", "mail"],
151
+ expect.objectContaining({ cwd: root }),
152
+ );
153
+ expect(mocks.noteExternalWrite).toHaveBeenCalledTimes(1);
154
+ expect(mocks.noteExternalWrite).toHaveBeenCalledWith("child-process");
155
+ expect(callOrder(mocks.noteExternalWrite)).toBeLessThan(
156
+ callOrder(mocks.spawn),
157
+ );
158
+ expect(rmSync).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it("notes the local-disk write before removing a half-made app when the CLI fails", async () => {
162
+ const root = workspace();
163
+ const appDir = path.join(root, "apps", "mail");
164
+ fakeCli({
165
+ code: 1,
166
+ stderr: "boom",
167
+ beforeExit: () => {
168
+ fs.mkdirSync(appDir, { recursive: true });
169
+ fs.writeFileSync(path.join(appDir, "package.json"), "{}");
170
+ },
171
+ });
172
+ const rmSync = observeRemovals();
173
+
174
+ await expect(scaffold()).rejects.toThrow("Scaffold failed: boom");
175
+
176
+ expect(mocks.noteExternalWrite).toHaveBeenNthCalledWith(2, "local-disk");
177
+ expect(rmSync).toHaveBeenCalledWith(appDir, {
178
+ recursive: true,
179
+ force: true,
180
+ });
181
+ expect(callOrder(mocks.noteExternalWrite, 1)).toBeLessThan(
182
+ callOrder(rmSync),
183
+ );
184
+ expect(fs.existsSync(appDir)).toBe(false);
185
+ });
186
+
187
+ it("notes the local-disk write before removing the app when its metadata write fails", async () => {
188
+ const root = workspace();
189
+ const appDir = path.join(root, "apps", "mail");
190
+ fakeCli({
191
+ code: 0,
192
+ beforeExit: () => {
193
+ fs.mkdirSync(appDir, { recursive: true });
194
+ },
195
+ });
196
+ mocks.getSetting.mockImplementation(async (key: string) => {
197
+ if (key === METADATA_KEY) {
198
+ throw new Error("metadata store unavailable");
199
+ }
200
+ return null;
201
+ });
202
+ const rmSync = observeRemovals();
203
+
204
+ await expect(scaffold()).rejects.toThrow("metadata store unavailable");
205
+
206
+ expect(mocks.noteExternalWrite).toHaveBeenNthCalledWith(2, "local-disk");
207
+ expect(rmSync).toHaveBeenCalledWith(appDir, {
208
+ recursive: true,
209
+ force: true,
210
+ });
211
+ expect(callOrder(mocks.noteExternalWrite, 1)).toBeLessThan(
212
+ callOrder(rmSync),
213
+ );
214
+ expect(fs.existsSync(appDir)).toBe(false);
215
+ });
216
+ });