@agent-native/dispatch 0.40.30-nightly-20261021064625 → 0.40.30

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/CHANGELOG.md +7 -47
  2. package/dist/actions/connect-external-agent.d.ts.map +1 -1
  3. package/dist/actions/connect-external-agent.js +12 -13
  4. package/dist/actions/connect-external-agent.js.map +1 -1
  5. package/dist/actions/create-workspace-resource.d.ts +13 -13
  6. package/dist/actions/delete-destination.d.ts +12 -12
  7. package/dist/actions/delete-workspace-resource.d.ts +13 -13
  8. package/dist/actions/import-agent.d.ts +13 -13
  9. package/dist/actions/index.d.ts.map +1 -1
  10. package/dist/actions/index.js +8 -0
  11. package/dist/actions/index.js.map +1 -1
  12. package/dist/actions/inspect-connect-agent-card.d.ts +5 -0
  13. package/dist/actions/inspect-connect-agent-card.d.ts.map +1 -0
  14. package/dist/actions/inspect-connect-agent-card.js +15 -0
  15. package/dist/actions/inspect-connect-agent-card.js.map +1 -0
  16. package/dist/actions/install-curated-community-app.d.ts +36 -0
  17. package/dist/actions/install-curated-community-app.d.ts.map +1 -0
  18. package/dist/actions/install-curated-community-app.js +26 -0
  19. package/dist/actions/install-curated-community-app.js.map +1 -0
  20. package/dist/actions/list-connect-marketplace-apps.d.ts +3 -0
  21. package/dist/actions/list-connect-marketplace-apps.d.ts.map +1 -0
  22. package/dist/actions/list-connect-marketplace-apps.js +15 -0
  23. package/dist/actions/list-connect-marketplace-apps.js.map +1 -0
  24. package/dist/actions/list-connected-agents.d.ts +2 -0
  25. package/dist/actions/list-curated-community-apps.d.ts +3 -0
  26. package/dist/actions/list-curated-community-apps.d.ts.map +1 -0
  27. package/dist/actions/list-curated-community-apps.js +10 -0
  28. package/dist/actions/list-curated-community-apps.js.map +1 -0
  29. package/dist/actions/provider-api-register.d.ts +14 -14
  30. package/dist/actions/remix-workspace-template.d.ts +6 -0
  31. package/dist/actions/update-workspace-app-metadata.d.ts +1 -0
  32. package/dist/actions/update-workspace-app-metadata.js +26 -5
  33. package/dist/actions/update-workspace-app-metadata.js.map +1 -1
  34. package/dist/components/community-apps-section.d.ts +2 -0
  35. package/dist/components/community-apps-section.d.ts.map +1 -0
  36. package/dist/components/community-apps-section.js +90 -0
  37. package/dist/components/community-apps-section.js.map +1 -0
  38. package/dist/components/workspace-app-card.d.ts.map +1 -1
  39. package/dist/components/workspace-app-card.js +63 -9
  40. package/dist/components/workspace-app-card.js.map +1 -1
  41. package/dist/lib/connect-apps.d.ts +1 -2
  42. package/dist/lib/connect-apps.d.ts.map +1 -1
  43. package/dist/lib/connect-apps.js +41 -65
  44. package/dist/lib/connect-apps.js.map +1 -1
  45. package/dist/lib/workspace-apps.d.ts +1 -0
  46. package/dist/lib/workspace-apps.d.ts.map +1 -1
  47. package/dist/lib/workspace-apps.js.map +1 -1
  48. package/dist/routes/pages/apps.d.ts.map +1 -1
  49. package/dist/routes/pages/apps.js +2 -1
  50. package/dist/routes/pages/apps.js.map +1 -1
  51. package/dist/routes/pages/connect.d.ts.map +1 -1
  52. package/dist/routes/pages/connect.js +5 -7
  53. package/dist/routes/pages/connect.js.map +1 -1
  54. package/dist/server/index.d.ts +2 -0
  55. package/dist/server/index.d.ts.map +1 -1
  56. package/dist/server/index.js +2 -0
  57. package/dist/server/index.js.map +1 -1
  58. package/dist/server/lib/app-creation-store.d.ts +11 -1
  59. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  60. package/dist/server/lib/app-creation-store.js +258 -39
  61. package/dist/server/lib/app-creation-store.js.map +1 -1
  62. package/dist/server/lib/community-apps.d.ts +45 -0
  63. package/dist/server/lib/community-apps.d.ts.map +1 -0
  64. package/dist/server/lib/community-apps.js +267 -0
  65. package/dist/server/lib/community-apps.js.map +1 -0
  66. package/dist/server/lib/community-source.d.ts +19 -0
  67. package/dist/server/lib/community-source.d.ts.map +1 -0
  68. package/dist/server/lib/community-source.js +118 -0
  69. package/dist/server/lib/community-source.js.map +1 -0
  70. package/dist/server/lib/connect-apps.d.ts +4 -0
  71. package/dist/server/lib/connect-apps.d.ts.map +1 -0
  72. package/dist/server/lib/connect-apps.js +99 -0
  73. package/dist/server/lib/connect-apps.js.map +1 -0
  74. package/dist/server/lib/org-apps-directory-cache.d.ts +20 -0
  75. package/dist/server/lib/org-apps-directory-cache.d.ts.map +1 -0
  76. package/dist/server/lib/org-apps-directory-cache.js +44 -0
  77. package/dist/server/lib/org-apps-directory-cache.js.map +1 -0
  78. package/dist/server/plugins/integrations.js +3 -3
  79. package/dist/server/plugins/integrations.js.map +1 -1
  80. package/dist/shared/labs.d.ts +2 -0
  81. package/dist/shared/labs.d.ts.map +1 -0
  82. package/dist/shared/labs.js +8 -0
  83. package/dist/shared/labs.js.map +1 -0
  84. package/package.json +5 -4
  85. package/src/actions/connect-external-agent.spec.ts +101 -3
  86. package/src/actions/connect-external-agent.ts +12 -11
  87. package/src/actions/index.spec.ts +13 -0
  88. package/src/actions/index.ts +8 -0
  89. package/src/actions/inspect-connect-agent-card.ts +17 -0
  90. package/src/actions/install-curated-community-app.ts +32 -0
  91. package/src/actions/list-connect-marketplace-apps.ts +17 -0
  92. package/src/actions/list-curated-community-apps.ts +12 -0
  93. package/src/actions/update-workspace-app-metadata.spec.ts +25 -0
  94. package/src/actions/update-workspace-app-metadata.ts +48 -24
  95. package/src/components/community-apps-section.tsx +224 -0
  96. package/src/components/workspace-app-card.spec.tsx +226 -1
  97. package/src/components/workspace-app-card.tsx +115 -12
  98. package/src/lib/connect-apps.spec.ts +118 -66
  99. package/src/lib/connect-apps.ts +47 -82
  100. package/src/lib/workspace-apps.ts +1 -0
  101. package/src/routes/pages/apps.spec.tsx +201 -1
  102. package/src/routes/pages/apps.tsx +3 -0
  103. package/src/routes/pages/connect.spec.tsx +53 -6
  104. package/src/routes/pages/connect.tsx +14 -11
  105. package/src/server/index.ts +9 -0
  106. package/src/server/lib/app-creation-store.spec.ts +539 -11
  107. package/src/server/lib/app-creation-store.ts +332 -41
  108. package/src/server/lib/community-apps.spec.ts +367 -0
  109. package/src/server/lib/community-apps.ts +372 -0
  110. package/src/server/lib/community-source.spec.ts +151 -0
  111. package/src/server/lib/community-source.ts +186 -0
  112. package/src/server/lib/connect-apps.ts +149 -0
  113. package/src/server/lib/org-apps-directory-cache.spec.ts +71 -0
  114. package/src/server/lib/org-apps-directory-cache.ts +66 -0
  115. package/src/server/plugins/integrations.spec.ts +60 -0
  116. package/src/server/plugins/integrations.ts +3 -3
  117. package/src/shared/labs.ts +9 -0
@@ -10,15 +10,21 @@ import {
10
10
  import { afterEach, describe, expect, it, vi } from "vitest";
11
11
 
12
12
  import {
13
+ archiveWorkspaceApp,
13
14
  generateWorkspaceAppDescription,
14
15
  getAppCreationSettings,
15
16
  listAvailableWorkspaceTemplates,
17
+ listOrganizationVisibleWorkspaceApps,
16
18
  listWorkspaceApps,
17
19
  scaffoldWorkspaceAppFromTemplate,
18
20
  setAppCreationSettings,
19
21
  startWorkspaceAppCreation,
20
22
  updateWorkspaceAppMetadata,
21
23
  } from "./app-creation-store.js";
24
+ import {
25
+ orgAppsDirectoryAccessCache,
26
+ orgAppsDirectoryCache,
27
+ } from "./org-apps-directory-cache.js";
22
28
 
23
29
  const originalFetch = globalThis.fetch;
24
30
  const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
@@ -27,6 +33,7 @@ const mocks = vi.hoisted(() => {
27
33
  const settings = new Map<string, unknown>();
28
34
  const state = {
29
35
  orgRole: "admin" as string | null,
36
+ workspaceApps: [] as Record<string, unknown>[],
30
37
  };
31
38
  const executedSql: string[] = [];
32
39
  return {
@@ -62,6 +69,20 @@ const mocks = vi.hoisted(() => {
62
69
  ? statement
63
70
  : String((statement as { sql?: unknown })?.sql ?? "");
64
71
  executedSql.push(sql);
72
+ if (sql.includes("SELECT id, org_id, visibility, org_enabled")) {
73
+ const args = (statement as { args?: unknown[] }).args ?? [];
74
+ return {
75
+ rows: state.workspaceApps.filter((app) => args.includes(app.id)),
76
+ rowsAffected: 0,
77
+ };
78
+ }
79
+ if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
80
+ const args = (statement as { args?: unknown[] }).args ?? [];
81
+ return {
82
+ rows: state.workspaceApps.filter((app) => args.includes(app.id)),
83
+ rowsAffected: 0,
84
+ };
85
+ }
65
86
  if (sql.includes("SELECT id FROM workspace_apps")) {
66
87
  return { rows: [], rowsAffected: 0 };
67
88
  }
@@ -71,6 +92,22 @@ const mocks = vi.hoisted(() => {
71
92
  };
72
93
  }),
73
94
  })),
95
+ getDb: vi.fn(() => ({
96
+ select: vi.fn(() => ({
97
+ from: vi.fn(() => ({
98
+ where: vi.fn(async () =>
99
+ state.workspaceApps
100
+ .filter(
101
+ (app) =>
102
+ app.org_id === "org-123" &&
103
+ app.visibility === "org" &&
104
+ app.org_enabled === 1,
105
+ )
106
+ .map((app) => ({ id: String(app.id) })),
107
+ ),
108
+ })),
109
+ })),
110
+ })),
74
111
  resolveBuilderCredentialsDetailed: vi.fn(async () => ({
75
112
  privateKey: null as string | null,
76
113
  publicKey: null as string | null,
@@ -94,6 +131,11 @@ const mocks = vi.hoisted(() => {
94
131
  };
95
132
  });
96
133
 
134
+ vi.mock("../../db/index.js", async (importOriginal) => {
135
+ const actual = await importOriginal<typeof import("../../db/index.js")>();
136
+ return { ...actual, getDb: mocks.getDb };
137
+ });
138
+
97
139
  vi.mock("@agent-native/core/secrets", async (importOriginal) => {
98
140
  const actual =
99
141
  await importOriginal<typeof import("@agent-native/core/secrets")>();
@@ -187,6 +229,9 @@ afterEach(() => {
187
229
  },
188
230
  );
189
231
  mocks.state.orgRole = "admin";
232
+ mocks.state.workspaceApps = [];
233
+ orgAppsDirectoryCache.clear();
234
+ orgAppsDirectoryAccessCache.clear();
190
235
  mocks.getDbExec.mockReset();
191
236
  mocks.getDbExec.mockImplementation(() => ({
192
237
  execute: vi.fn(async (statement: unknown) => {
@@ -195,6 +240,24 @@ afterEach(() => {
195
240
  ? statement
196
241
  : String((statement as { sql?: unknown })?.sql ?? "");
197
242
  mocks.executedSql.push(sql);
243
+ if (sql.includes("SELECT id, org_id, visibility, org_enabled")) {
244
+ const args = (statement as { args?: unknown[] }).args ?? [];
245
+ return {
246
+ rows: mocks.state.workspaceApps.filter((app) =>
247
+ args.includes(app.id),
248
+ ),
249
+ rowsAffected: 0,
250
+ };
251
+ }
252
+ if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
253
+ const args = (statement as { args?: unknown[] }).args ?? [];
254
+ return {
255
+ rows: mocks.state.workspaceApps.filter((app) =>
256
+ args.includes(app.id),
257
+ ),
258
+ rowsAffected: 0,
259
+ };
260
+ }
198
261
  if (sql.includes("SELECT id FROM workspace_apps")) {
199
262
  return { rows: [], rowsAffected: 0 };
200
263
  }
@@ -373,6 +436,71 @@ describe("listWorkspaceApps", () => {
373
436
  ]);
374
437
  });
375
438
 
439
+ it("lists only ready org-visible apps for an organization principal", async () => {
440
+ stubNoPendingContext();
441
+ stubManifest([
442
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
443
+ { id: "qr", name: "QR", path: "/qr" },
444
+ { id: "private-app", name: "Private", path: "/private-app" },
445
+ { id: "disabled-app", name: "Disabled", path: "/disabled-app" },
446
+ { id: "foreign-app", name: "Foreign", path: "/foreign-app" },
447
+ ]);
448
+ mocks.settings.set("dispatch-app-creation-settings:org:org-123", {
449
+ pendingApps: [pendingApp("pending-app")],
450
+ });
451
+ mocks.state.workspaceApps = [
452
+ {
453
+ id: "dispatch",
454
+ owner_email: "owner@example.test",
455
+ org_id: "org-123",
456
+ visibility: "org",
457
+ org_enabled: 1,
458
+ },
459
+ {
460
+ id: "qr",
461
+ owner_email: "owner@example.test",
462
+ org_id: "org-123",
463
+ visibility: "org",
464
+ org_enabled: 1,
465
+ },
466
+ {
467
+ id: "private-app",
468
+ owner_email: "dev@example.test",
469
+ org_id: "org-123",
470
+ visibility: "private",
471
+ org_enabled: 1,
472
+ },
473
+ {
474
+ id: "disabled-app",
475
+ owner_email: "owner@example.test",
476
+ org_id: "org-123",
477
+ visibility: "org",
478
+ org_enabled: 0,
479
+ },
480
+ {
481
+ id: "foreign-app",
482
+ owner_email: "owner@example.test",
483
+ org_id: "another-org",
484
+ visibility: "org",
485
+ org_enabled: 1,
486
+ },
487
+ ];
488
+
489
+ const apps = await runWithRequestContext({ orgId: "org-123" }, () =>
490
+ listOrganizationVisibleWorkspaceApps(),
491
+ );
492
+
493
+ expect(apps.map((app) => app.id)).toEqual(["dispatch", "qr"]);
494
+ expect(mocks.executedSql.some((sql) => sql.includes("INSERT INTO"))).toBe(
495
+ false,
496
+ );
497
+ expect(
498
+ mocks.executedSql.some((sql) =>
499
+ sql.includes("INSERT INTO workspace_apps"),
500
+ ),
501
+ ).toBe(false);
502
+ });
503
+
376
504
  it.each([401, 403])(
377
505
  "keeps a local gateway denial on the unverified fallback path (%i)",
378
506
  async (status) => {
@@ -1508,6 +1636,51 @@ describe("listWorkspaceApps", () => {
1508
1636
  );
1509
1637
  });
1510
1638
 
1639
+ it("keeps the curated source id on a pending app summary", async () => {
1640
+ stubNoPendingContext();
1641
+ stubManifest();
1642
+ mocks.settings.set(settingsKey, {
1643
+ pendingApps: [
1644
+ pendingApp("community-qr", {
1645
+ communitySourceId: "community-qr",
1646
+ }),
1647
+ ],
1648
+ });
1649
+
1650
+ const apps = await runWithRequestContext(
1651
+ { userEmail: "dev@example.test" },
1652
+ () => listWorkspaceApps({ includeAgentCards: false }),
1653
+ );
1654
+
1655
+ expect(apps.find((app) => app.id === "community-qr")).toMatchObject({
1656
+ status: "pending",
1657
+ communitySourceId: "community-qr",
1658
+ });
1659
+ });
1660
+
1661
+ it("keeps the curated source id on ready app metadata", async () => {
1662
+ stubNoPendingContext();
1663
+ stubManifest([
1664
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
1665
+ { id: "community-qr", name: "QR", path: "/community-qr" },
1666
+ ]);
1667
+ mocks.settings.set("workspace-app-metadata:user:dev@example.test", {
1668
+ apps: {
1669
+ "community-qr": { communitySourceId: "community-qr" },
1670
+ },
1671
+ });
1672
+
1673
+ const apps = await runWithRequestContext(
1674
+ { userEmail: "dev@example.test" },
1675
+ () => listWorkspaceApps({ includeAgentCards: false }),
1676
+ );
1677
+
1678
+ expect(apps.find((app) => app.id === "community-qr")).toMatchObject({
1679
+ status: "ready",
1680
+ communitySourceId: "community-qr",
1681
+ });
1682
+ });
1683
+
1511
1684
  it("keeps unscoped legacy pending rows visible when there is no deploy context", async () => {
1512
1685
  stubNoPendingContext();
1513
1686
  stubManifest();
@@ -1643,7 +1816,7 @@ describe("listWorkspaceApps", () => {
1643
1816
  expect(apps.find((app) => app.id === "mail")?.status).toBe("ready");
1644
1817
  });
1645
1818
 
1646
- it("lets workspace admins update app display metadata", async () => {
1819
+ it("lets workspace admins update app profile metadata", async () => {
1647
1820
  stubNoPendingContext();
1648
1821
  stubManifest([
1649
1822
  { id: "dispatch", name: "Dispatch", path: "/dispatch" },
@@ -1662,24 +1835,26 @@ describe("listWorkspaceApps", () => {
1662
1835
  appId: "todo",
1663
1836
  name: "Todo Board",
1664
1837
  description: "Tracks team work.",
1838
+ whenToUse: ["Create a task from a meeting note"],
1665
1839
  }),
1666
1840
  );
1667
1841
 
1668
1842
  expect(updated.name).toBe("Todo Board");
1669
1843
  expect(updated.description).toBe("Tracks team work.");
1844
+ expect(updated.whenToUse).toEqual(["Create a task from a meeting note"]);
1670
1845
  expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
1671
1846
  apps: {
1672
1847
  todo: expect.objectContaining({
1673
1848
  name: "Todo Board",
1674
1849
  description: "Tracks team work.",
1850
+ whenToUse: ["Create a task from a meeting note"],
1675
1851
  updatedBy: "dev@example.test",
1676
1852
  }),
1677
1853
  },
1678
1854
  });
1679
1855
  });
1680
1856
 
1681
- it("lets workspace members update app display metadata", async () => {
1682
- mocks.state.orgRole = "member";
1857
+ it("does not overwrite app profile metadata when its settings are unreadable", async () => {
1683
1858
  stubNoPendingContext();
1684
1859
  stubManifest([
1685
1860
  {
@@ -1689,30 +1864,262 @@ describe("listWorkspaceApps", () => {
1689
1864
  path: "/todo",
1690
1865
  },
1691
1866
  ]);
1867
+ const key = "workspace-app-metadata:org:org-123";
1868
+ const existing = { apps: { other: { name: "Preserve me" } } };
1869
+ mocks.settings.set(key, existing);
1870
+ let metadataReads = 0;
1871
+ mocks.getSetting.mockImplementation(async (settingKey: string) => {
1872
+ if (settingKey === key && ++metadataReads === 2) {
1873
+ throw new Error("metadata settings unavailable");
1874
+ }
1875
+ return mocks.settings.get(settingKey) ?? null;
1876
+ });
1877
+
1878
+ await expect(
1879
+ runWithRequestContext(
1880
+ { userEmail: "dev@example.test", orgId: "org-123" },
1881
+ () => updateWorkspaceAppMetadata({ appId: "todo", name: "Updated" }),
1882
+ ),
1883
+ ).rejects.toThrow("metadata settings unavailable");
1884
+
1885
+ expect(metadataReads).toBe(2);
1886
+ expect(mocks.putSetting).not.toHaveBeenCalled();
1887
+ expect(mocks.settings.get(key)).toEqual(existing);
1888
+ });
1889
+
1890
+ it("does not overwrite workspace settings when archiving after a read failure", async () => {
1891
+ const key = "dispatch-app-creation-settings:org:org-123";
1892
+ const existing = {
1893
+ builderProjectId: "project-123",
1894
+ pendingCommunityReservations: ["community-qr"],
1895
+ archivedAppIds: [],
1896
+ };
1897
+ mocks.settings.set(key, existing);
1898
+ mocks.getSetting.mockRejectedValueOnce(
1899
+ new Error("workspace settings unavailable"),
1900
+ );
1901
+
1902
+ await expect(
1903
+ runWithRequestContext(
1904
+ { userEmail: "dev@example.test", orgId: "org-123" },
1905
+ () => archiveWorkspaceApp({ appId: "todo" }),
1906
+ ),
1907
+ ).rejects.toThrow("workspace settings unavailable");
1908
+
1909
+ expect(mocks.putSetting).not.toHaveBeenCalled();
1910
+ expect(mocks.settings.get(key)).toEqual(existing);
1911
+ });
1912
+
1913
+ it("preserves generated name and description when only examples change", async () => {
1914
+ stubNoPendingContext();
1915
+ stubManifest([
1916
+ {
1917
+ id: "todo",
1918
+ name: "Todo",
1919
+ description: "Manifest description",
1920
+ path: "/todo",
1921
+ },
1922
+ ]);
1923
+ mocks.settings.set("workspace-app-metadata:org:org-123", {
1924
+ apps: {
1925
+ todo: {
1926
+ description: "Generated description",
1927
+ generated: true,
1928
+ },
1929
+ },
1930
+ });
1692
1931
 
1693
1932
  const updated = await runWithRequestContext(
1694
1933
  { userEmail: "dev@example.test", orgId: "org-123" },
1695
1934
  () =>
1696
1935
  updateWorkspaceAppMetadata({
1697
1936
  appId: "todo",
1698
- name: "Todo Board",
1699
- description: "Tracks team work.",
1937
+ whenToUse: ["Create a task from a meeting note"],
1700
1938
  }),
1701
1939
  );
1702
1940
 
1703
- expect(updated).toMatchObject({
1704
- name: "Todo Board",
1705
- description: "Tracks team work.",
1706
- });
1941
+ expect(updated.name).toBe("Todo");
1942
+ expect(updated.description).toBe("Manifest description");
1943
+ expect(updated.whenToUse).toEqual(["Create a task from a meeting note"]);
1707
1944
  expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
1708
1945
  apps: {
1709
1946
  todo: expect.objectContaining({
1710
- name: "Todo Board",
1711
- description: "Tracks team work.",
1947
+ description: "Generated description",
1948
+ generated: true,
1949
+ whenToUse: ["Create a task from a meeting note"],
1712
1950
  updatedBy: "dev@example.test",
1713
1951
  }),
1714
1952
  },
1715
1953
  });
1954
+ expect(
1955
+ (mocks.settings.get("workspace-app-metadata:org:org-123") as any).apps
1956
+ .todo,
1957
+ ).not.toHaveProperty("name");
1958
+ });
1959
+
1960
+ it("invalidates org directory discovery after a profile edit", async () => {
1961
+ stubNoPendingContext();
1962
+ stubManifest([
1963
+ { id: "todo", name: "Todo", description: "Tasks", path: "/todo" },
1964
+ ]);
1965
+ const load = vi.fn(async () => [
1966
+ {
1967
+ id: "todo",
1968
+ name: "Old profile",
1969
+ url: "https://todo.example.test",
1970
+ },
1971
+ ]);
1972
+ await orgAppsDirectoryCache.get("org-123|organization", load);
1973
+ const loadAccess = vi.fn(async () => new Set(["todo"]));
1974
+ await orgAppsDirectoryAccessCache.get("org-123|organization", loadAccess);
1975
+
1976
+ await runWithRequestContext(
1977
+ { userEmail: "dev@example.test", orgId: "org-123" },
1978
+ () =>
1979
+ updateWorkspaceAppMetadata({
1980
+ appId: "todo",
1981
+ description: "Updated profile",
1982
+ }),
1983
+ );
1984
+
1985
+ const refreshedLoad = vi.fn(async () => [
1986
+ {
1987
+ id: "todo",
1988
+ name: "Updated profile",
1989
+ url: "https://todo.example.test",
1990
+ },
1991
+ ]);
1992
+ await expect(
1993
+ orgAppsDirectoryCache.get("org-123|organization", refreshedLoad),
1994
+ ).resolves.toEqual([
1995
+ {
1996
+ id: "todo",
1997
+ name: "Updated profile",
1998
+ url: "https://todo.example.test",
1999
+ },
2000
+ ]);
2001
+ expect(refreshedLoad).toHaveBeenCalledOnce();
2002
+
2003
+ const refreshedAccess = vi.fn(async () => new Set(["todo"]));
2004
+ await expect(
2005
+ orgAppsDirectoryAccessCache.get("org-123|organization", refreshedAccess),
2006
+ ).resolves.toEqual(new Set(["todo"]));
2007
+ expect(refreshedAccess).toHaveBeenCalledOnce();
2008
+ });
2009
+
2010
+ it("clears explicit null metadata and preserves an empty when-to-use list", async () => {
2011
+ stubNoPendingContext();
2012
+ stubManifest([
2013
+ {
2014
+ id: "todo",
2015
+ name: "Manifest name",
2016
+ description: "Manifest description",
2017
+ path: "/todo",
2018
+ },
2019
+ ]);
2020
+ mocks.settings.set("workspace-app-metadata:org:org-123", {
2021
+ apps: {
2022
+ todo: {
2023
+ name: "Override name",
2024
+ description: "Override description",
2025
+ whenToUse: ["Old example"],
2026
+ },
2027
+ },
2028
+ });
2029
+
2030
+ await runWithRequestContext(
2031
+ { userEmail: "dev@example.test", orgId: "org-123" },
2032
+ () =>
2033
+ updateWorkspaceAppMetadata({
2034
+ appId: "todo",
2035
+ name: null,
2036
+ description: null,
2037
+ whenToUse: [],
2038
+ }),
2039
+ );
2040
+
2041
+ expect(mocks.settings.get("workspace-app-metadata:org:org-123")).toEqual({
2042
+ apps: {
2043
+ todo: expect.objectContaining({ whenToUse: [] }),
2044
+ },
2045
+ });
2046
+ });
2047
+
2048
+ it("reports owner-role lookup failures as unavailable instead of unauthorized", async () => {
2049
+ stubNoPendingContext();
2050
+ stubManifest([{ id: "todo", name: "Todo", path: "/todo" }]);
2051
+ mocks.getDbExec.mockReturnValue({
2052
+ execute: vi.fn(async () => {
2053
+ throw new Error("database unavailable");
2054
+ }),
2055
+ } as any);
2056
+
2057
+ await expect(
2058
+ runWithRequestContext(
2059
+ { userEmail: "dev@example.test", orgId: "org-123" },
2060
+ () => updateWorkspaceAppMetadata({ appId: "todo", name: "Updated" }),
2061
+ ),
2062
+ ).rejects.toMatchObject({
2063
+ name: "AppCreationOwnerRoleUnavailableError",
2064
+ statusCode: 503,
2065
+ });
2066
+ expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
2067
+ false,
2068
+ );
2069
+ });
2070
+
2071
+ it("rejects malformed profile examples before writing", async () => {
2072
+ stubNoPendingContext();
2073
+ stubManifest([
2074
+ { id: "todo", name: "Todo", description: "Tasks", path: "/todo" },
2075
+ ]);
2076
+ const tooManyExamples = Array.from(
2077
+ { length: 7 },
2078
+ (_, index) => `Example ${index}`,
2079
+ );
2080
+
2081
+ await expect(
2082
+ runWithRequestContext(
2083
+ { userEmail: "dev@example.test", orgId: "org-123" },
2084
+ () =>
2085
+ updateWorkspaceAppMetadata({
2086
+ appId: "todo",
2087
+ whenToUse: tooManyExamples,
2088
+ }),
2089
+ ),
2090
+ ).rejects.toThrow(/up to 6 non-empty examples/);
2091
+ expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
2092
+ false,
2093
+ );
2094
+ });
2095
+
2096
+ it("rejects workspace member updates to app profile metadata", async () => {
2097
+ mocks.state.orgRole = "member";
2098
+ stubNoPendingContext();
2099
+ stubManifest([
2100
+ {
2101
+ id: "todo",
2102
+ name: "Todo",
2103
+ description: "Original description",
2104
+ path: "/todo",
2105
+ },
2106
+ ]);
2107
+
2108
+ await expect(
2109
+ runWithRequestContext(
2110
+ { userEmail: "dev@example.test", orgId: "org-123" },
2111
+ () =>
2112
+ updateWorkspaceAppMetadata({
2113
+ appId: "todo",
2114
+ name: "Todo Board",
2115
+ description: "Tracks team work.",
2116
+ whenToUse: ["Create a task from a meeting note"],
2117
+ }),
2118
+ ),
2119
+ ).rejects.toMatchObject({ statusCode: 403 });
2120
+ expect(mocks.settings.has("workspace-app-metadata:org:org-123")).toBe(
2121
+ false,
2122
+ );
1716
2123
  });
1717
2124
 
1718
2125
  it("generates a concise seed description from an app prompt", () => {
@@ -1812,6 +2219,82 @@ describe("startWorkspaceAppCreation", () => {
1812
2219
  });
1813
2220
  });
1814
2221
 
2222
+ it("persists the curated source id for local-agent app creation", async () => {
2223
+ const result = await runWithRequestContext(
2224
+ { userEmail: "dev@example.test", orgId: "org-123" },
2225
+ () =>
2226
+ startWorkspaceAppCreation({
2227
+ prompt: "Install the curated QR template",
2228
+ appId: "community-qr",
2229
+ communitySourceId: "community-qr",
2230
+ attachments: [
2231
+ {
2232
+ type: "upload",
2233
+ contentType: "text/plain",
2234
+ name: "community-qr/README.md",
2235
+ text: "Pinned template source",
2236
+ dataUrl: "",
2237
+ id: "community-qr:README.md",
2238
+ },
2239
+ ],
2240
+ }),
2241
+ );
2242
+
2243
+ expect(result).toMatchObject({
2244
+ mode: "local-agent",
2245
+ attachments: [
2246
+ {
2247
+ type: "file",
2248
+ contentType: "text/plain",
2249
+ name: "community-qr/README.md",
2250
+ text: "Pinned template source",
2251
+ },
2252
+ ],
2253
+ });
2254
+ expect(
2255
+ mocks.settings.get("workspace-app-metadata:org:org-123"),
2256
+ ).toMatchObject({
2257
+ apps: {
2258
+ "community-qr": { communitySourceId: "community-qr" },
2259
+ },
2260
+ });
2261
+ expect(
2262
+ mocks.settings.get("dispatch-app-creation-settings:org:org-123"),
2263
+ ).toMatchObject({
2264
+ pendingApps: [
2265
+ expect.objectContaining({
2266
+ id: "community-qr",
2267
+ communitySourceId: "community-qr",
2268
+ createdBy: "dev@example.test",
2269
+ }),
2270
+ ],
2271
+ });
2272
+ });
2273
+
2274
+ it("atomically reserves local community installs against duplicate requests", async () => {
2275
+ const input = {
2276
+ prompt: "Install the curated QR template",
2277
+ appId: "community-qr",
2278
+ communitySourceId: "community-qr",
2279
+ };
2280
+ const context = { userEmail: "dev@example.test", orgId: "org-123" };
2281
+
2282
+ const first = await runWithRequestContext(context, () =>
2283
+ startWorkspaceAppCreation(input),
2284
+ );
2285
+ const duplicate = await runWithRequestContext(context, () =>
2286
+ startWorkspaceAppCreation(input),
2287
+ );
2288
+
2289
+ expect(first.mode).toBe("local-agent");
2290
+ expect(duplicate).toMatchObject({
2291
+ mode: "app-id-taken",
2292
+ appId: "community-qr",
2293
+ conflict: "pending",
2294
+ owner: "dev@example.test",
2295
+ });
2296
+ });
2297
+
1815
2298
  it("rejects a cross-member collision with an active pending app id", async () => {
1816
2299
  stubHostedRuntime();
1817
2300
  stubBuilderProjectConfigured();
@@ -1961,6 +2444,51 @@ describe("startWorkspaceAppCreation", () => {
1961
2444
  expect(mocks.mutateSetting).toHaveBeenCalled();
1962
2445
  });
1963
2446
 
2447
+ it("persists the curated source id with a hosted pending reservation", async () => {
2448
+ stubHostedRuntime();
2449
+ stubBuilderProjectConfigured();
2450
+ mocks.resolveBuilderCredentialsDetailed.mockResolvedValue(
2451
+ credentials({
2452
+ privateKey: "priv",
2453
+ publicKey: "pub",
2454
+ userId: "builder-user-42",
2455
+ }),
2456
+ );
2457
+ mocks.runBuilderAgent.mockResolvedValue({
2458
+ branchName: "community-qr1",
2459
+ url: "https://builder.io/app/projects/project-1/community-qr1",
2460
+ status: "processing",
2461
+ });
2462
+
2463
+ await runWithRequestContext(
2464
+ { userEmail: "dev@example.test", orgId: "org-123" },
2465
+ () =>
2466
+ startWorkspaceAppCreation({
2467
+ prompt: "Install the curated QR template",
2468
+ appId: "community-qr",
2469
+ communitySourceId: "community-qr",
2470
+ }),
2471
+ );
2472
+
2473
+ expect(
2474
+ mocks.settings.get("dispatch-app-creation-settings:org:org-123"),
2475
+ ).toMatchObject({
2476
+ pendingApps: [
2477
+ expect.objectContaining({
2478
+ id: "community-qr",
2479
+ communitySourceId: "community-qr",
2480
+ }),
2481
+ ],
2482
+ });
2483
+ expect(
2484
+ mocks.settings.get("workspace-app-metadata:org:org-123"),
2485
+ ).toMatchObject({
2486
+ apps: {
2487
+ "community-qr": { communitySourceId: "community-qr" },
2488
+ },
2489
+ });
2490
+ });
2491
+
1964
2492
  it("releases the reservation when Builder handoff fails", async () => {
1965
2493
  stubHostedRuntime();
1966
2494
  stubBuilderProjectConfigured();