@agent-native/dispatch 0.31.12 → 0.31.14

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 (53) hide show
  1. package/dist/actions/list-dispatch-usage-metrics.js +9 -1
  2. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
  3. package/dist/actions/provider-api-register.d.ts +12 -12
  4. package/dist/components/layout/Layout.d.ts +2 -1
  5. package/dist/components/layout/Layout.d.ts.map +1 -1
  6. package/dist/components/layout/Layout.js +3 -3
  7. package/dist/components/layout/Layout.js.map +1 -1
  8. package/dist/components/other-apps-section.d.ts +5 -3
  9. package/dist/components/other-apps-section.d.ts.map +1 -1
  10. package/dist/components/other-apps-section.js +15 -3
  11. package/dist/components/other-apps-section.js.map +1 -1
  12. package/dist/components/workspace-app-host.d.ts +8 -2
  13. package/dist/components/workspace-app-host.d.ts.map +1 -1
  14. package/dist/components/workspace-app-host.js +46 -8
  15. package/dist/components/workspace-app-host.js.map +1 -1
  16. package/dist/lib/workspace-apps.d.ts +7 -0
  17. package/dist/lib/workspace-apps.d.ts.map +1 -1
  18. package/dist/lib/workspace-apps.js +65 -0
  19. package/dist/lib/workspace-apps.js.map +1 -1
  20. package/dist/routes/index.js +1 -1
  21. package/dist/routes/index.js.map +1 -1
  22. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  23. package/dist/routes/pages/apps.$appId.js +15 -4
  24. package/dist/routes/pages/apps.$appId.js.map +1 -1
  25. package/dist/routes/pages/apps.d.ts.map +1 -1
  26. package/dist/routes/pages/apps.js +16 -4
  27. package/dist/routes/pages/apps.js.map +1 -1
  28. package/dist/server/lib/usage-metrics-store.d.ts +20 -0
  29. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  30. package/dist/server/lib/usage-metrics-store.js +86 -32
  31. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  32. package/dist/server/plugins/agent-chat.js +3 -0
  33. package/dist/server/plugins/agent-chat.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/actions/index.spec.ts +17 -0
  36. package/src/actions/list-dispatch-usage-metrics.ts +9 -1
  37. package/src/components/layout/Layout.spec.tsx +75 -0
  38. package/src/components/layout/Layout.tsx +22 -23
  39. package/src/components/other-apps-section.spec.ts +49 -0
  40. package/src/components/other-apps-section.tsx +31 -6
  41. package/src/components/workspace-app-host.spec.tsx +98 -0
  42. package/src/components/workspace-app-host.tsx +70 -4
  43. package/src/lib/workspace-apps.spec.ts +40 -0
  44. package/src/lib/workspace-apps.ts +80 -0
  45. package/src/routes/index.spec.ts +10 -0
  46. package/src/routes/index.ts +1 -1
  47. package/src/routes/pages/apps.$appId.spec.tsx +50 -1
  48. package/src/routes/pages/apps.$appId.tsx +26 -3
  49. package/src/routes/pages/apps.tsx +42 -26
  50. package/src/server/lib/usage-metrics-store.spec.ts +132 -1
  51. package/src/server/lib/usage-metrics-store.ts +173 -65
  52. package/src/server/plugins/agent-chat.spec.ts +2 -0
  53. package/src/server/plugins/agent-chat.ts +3 -0
@@ -149,6 +149,86 @@ export function workspaceAppRoute(appId: string): string {
149
149
  return `/apps/${encodeURIComponent(appId)}`;
150
150
  }
151
151
 
152
+ function normalizeWorkspaceAppRoutePath(rawPath: string): URL | null {
153
+ if (
154
+ typeof rawPath !== "string" ||
155
+ !rawPath.startsWith("/") ||
156
+ rawPath.startsWith("//") ||
157
+ /[\u0000-\u001f\u007f]/.test(rawPath)
158
+ ) {
159
+ return null;
160
+ }
161
+
162
+ try {
163
+ return new URL(rawPath, "https://agent-native.invalid");
164
+ } catch {
165
+ // coercion-ok: malformed child routes are ignored by the host.
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function normalizedWorkspaceAppMountPath(rawPath: string): string {
171
+ const pathname = rawPath.split(/[?#]/, 1)[0] ?? rawPath;
172
+ const normalized = `/${pathname.replace(/^[/\\]+/, "")}`;
173
+ return normalized.replace(/\/+$/, "") || "/";
174
+ }
175
+
176
+ function workspaceAppMountPath(
177
+ app: Pick<WorkspaceAppSummary, "path" | "url">,
178
+ ): string {
179
+ const rawUrl = app.url?.trim();
180
+ if (rawUrl) {
181
+ try {
182
+ const parsed = new URL(rawUrl);
183
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
184
+ return normalizedWorkspaceAppMountPath(parsed.pathname);
185
+ }
186
+ // coercion-ok: invalid URL falls back to app path.
187
+ } catch {
188
+ // Fall through to the mounted path when optional URL metadata is invalid.
189
+ }
190
+ }
191
+ return normalizedWorkspaceAppMountPath(app.path?.trim() || "/");
192
+ }
193
+
194
+ /**
195
+ * Convert a child app route into Dispatch's shareable workspace-app route.
196
+ * Mounted workspace apps may report their full mount path, while hosted apps
197
+ * normally report only the app-local path, so accept both forms here.
198
+ */
199
+ export function workspaceAppRouteForChildPath(
200
+ app: Pick<WorkspaceAppSummary, "id" | "path" | "url">,
201
+ childPath: string,
202
+ ): string | null {
203
+ const parsed = normalizeWorkspaceAppRoutePath(childPath);
204
+ if (!parsed) return null;
205
+
206
+ const mountPath = workspaceAppMountPath(app);
207
+ const relativePathname =
208
+ mountPath !== "/" &&
209
+ (parsed.pathname === mountPath ||
210
+ parsed.pathname.startsWith(`${mountPath}/`))
211
+ ? parsed.pathname.slice(mountPath.length) || "/"
212
+ : parsed.pathname;
213
+ const routePath = workspaceAppRoute(app.id);
214
+ const suffix = relativePathname === "/" ? "" : relativePathname;
215
+ return `${routePath}${suffix}${parsed.search}${parsed.hash}`;
216
+ }
217
+
218
+ export function workspaceAppInitialPathFromSplat(
219
+ routeSplat: string | undefined,
220
+ search: string,
221
+ hash: string,
222
+ ): string | undefined {
223
+ const pathname = routeSplat?.trim()
224
+ ? `/${routeSplat.trim().replace(/^[/\\]+/, "")}`
225
+ : "/";
226
+ const parsed = normalizeWorkspaceAppRoutePath(`${pathname}${search}${hash}`);
227
+ if (!parsed) return undefined;
228
+ const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
229
+ return path === "/" ? undefined : path;
230
+ }
231
+
152
232
  export function workspaceAppIdFromRoute(pathname: string): string | null {
153
233
  const match = pathname.match(/^\/apps\/([^/]+)(?:\/|$)/);
154
234
  if (!match) return null;
@@ -1,3 +1,4 @@
1
+ import { matchRoutes } from "react-router";
1
2
  import { describe, expect, it } from "vitest";
2
3
 
3
4
  import { dispatchRoutes } from "./index.js";
@@ -19,6 +20,7 @@ describe("Dispatch route registration", () => {
19
20
  expect(paths).toContain("operations");
20
21
  expect(paths).toContain("admin");
21
22
  expect(paths).toContain("agents");
23
+ expect(paths).toContain("apps/:appId/*");
22
24
  expect(paths.indexOf("chat")).toBeLessThan(paths.indexOf(":appId"));
23
25
  expect(paths.indexOf("chat/:threadId")).toBeLessThan(
24
26
  paths.indexOf(":appId"),
@@ -36,4 +38,12 @@ describe("Dispatch route registration", () => {
36
38
  expect(adminPaths).not.toContain("apps/:appId");
37
39
  expect(adminPaths.indexOf("operations")).toBeGreaterThanOrEqual(0);
38
40
  });
41
+
42
+ it("matches both workspace-app roots and nested deep links", () => {
43
+ for (const path of ["/apps/design", "/apps/design/foobar"]) {
44
+ expect(matchRoutes(dispatchRoutes, path)?.at(-1)?.route.path).toBe(
45
+ "apps/:appId/*",
46
+ );
47
+ }
48
+ });
39
49
  });
@@ -62,7 +62,7 @@ export const dispatchRoutes: RouteConfig = [
62
62
  route("metrics", "./pages/metrics.js"),
63
63
  route("operations", "./pages/operations.js"),
64
64
  route("apps", "./pages/apps.js"),
65
- route("apps/:appId", "./pages/apps.$appId.js"),
65
+ route("apps/:appId/*", "./pages/apps.$appId.js"),
66
66
  route("new-app", "./pages/new-app.js"),
67
67
  route("vault", "./pages/vault.js"),
68
68
  route("integrations", "./pages/integrations.js"),
@@ -5,15 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  const routeState = vi.hoisted(() => ({
7
7
  appId: "mail",
8
+ routeSplat: undefined as string | undefined,
9
+ location: { pathname: "/apps/mail", search: "", hash: "" },
8
10
  hostProps: null as {
9
11
  appId?: string;
10
12
  navigateToTopWindow?: (href: string) => boolean | void;
13
+ initialPath?: string;
14
+ onChildRouteChange?: (path: string) => void;
11
15
  } | null,
12
16
  navigateToWorkspaceApp: vi.fn(() => true),
17
+ navigate: vi.fn(),
13
18
  }));
14
19
 
15
20
  vi.mock("react-router", () => ({
16
- useParams: () => ({ appId: routeState.appId }),
21
+ useParams: () => ({ appId: routeState.appId, "*": routeState.routeSplat }),
22
+ useLocation: () => routeState.location,
23
+ useNavigate: () => routeState.navigate,
17
24
  }));
18
25
 
19
26
  vi.mock("../../components/workspace-app-host", () => ({
@@ -25,6 +32,14 @@ vi.mock("../../components/workspace-app-host", () => ({
25
32
 
26
33
  vi.mock("../../lib/workspace-apps", () => ({
27
34
  navigateToWorkspaceApp: routeState.navigateToWorkspaceApp,
35
+ workspaceAppInitialPathFromSplat: (
36
+ routeSplat: string | undefined,
37
+ search: string,
38
+ hash: string,
39
+ ) =>
40
+ routeSplat || search || hash
41
+ ? `/${routeSplat ?? ""}${search}${hash}`
42
+ : undefined,
28
43
  }));
29
44
 
30
45
  import WorkspaceAppRoute from "./apps.$appId";
@@ -36,8 +51,11 @@ describe("WorkspaceAppRoute", () => {
36
51
  beforeEach(() => {
37
52
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
38
53
  routeState.appId = "mail";
54
+ routeState.routeSplat = undefined;
55
+ routeState.location = { pathname: "/apps/mail", search: "", hash: "" };
39
56
  routeState.hostProps = null;
40
57
  routeState.navigateToWorkspaceApp.mockClear();
58
+ routeState.navigate.mockClear();
41
59
  container = document.createElement("div");
42
60
  document.body.appendChild(container);
43
61
  root = createRoot(container);
@@ -63,4 +81,35 @@ describe("WorkspaceAppRoute", () => {
63
81
 
64
82
  expect(routeState.navigateToWorkspaceApp).toHaveBeenCalledWith("/mail");
65
83
  });
84
+
85
+ it("keeps the iframe synchronized with standalone deep-link navigation", async () => {
86
+ routeState.routeSplat = "foobar";
87
+ routeState.location = {
88
+ pathname: "/apps/mail/foobar",
89
+ search: "?view=all",
90
+ hash: "#top",
91
+ };
92
+
93
+ await act(async () => {
94
+ root.render(<WorkspaceAppRoute />);
95
+ });
96
+
97
+ expect(routeState.hostProps?.initialPath).toBe("/foobar?view=all#top");
98
+
99
+ routeState.routeSplat = "sent";
100
+ routeState.location = {
101
+ pathname: "/apps/mail/sent",
102
+ search: "",
103
+ hash: "",
104
+ };
105
+ await act(async () => {
106
+ root.render(<WorkspaceAppRoute />);
107
+ });
108
+
109
+ expect(routeState.hostProps?.initialPath).toBe("/sent");
110
+ routeState.hostProps?.onChildRouteChange?.("/apps/mail/archive");
111
+ expect(routeState.navigate).toHaveBeenCalledWith("/apps/mail/archive", {
112
+ replace: true,
113
+ });
114
+ });
66
115
  });
@@ -1,7 +1,11 @@
1
- import { useParams } from "react-router";
1
+ import { useCallback } from "react";
2
+ import { useLocation, useNavigate, useParams } from "react-router";
2
3
 
3
4
  import { WorkspaceAppHost } from "../../components/workspace-app-host";
4
- import { navigateToWorkspaceApp } from "../../lib/workspace-apps";
5
+ import {
6
+ navigateToWorkspaceApp,
7
+ workspaceAppInitialPathFromSplat,
8
+ } from "../../lib/workspace-apps";
5
9
 
6
10
  export function navigateWorkspaceAppFromRoute(href: string): boolean {
7
11
  return navigateToWorkspaceApp(href);
@@ -12,11 +16,30 @@ export function meta() {
12
16
  }
13
17
 
14
18
  export default function WorkspaceAppRoute() {
15
- const { appId } = useParams();
19
+ const params = useParams();
20
+ const { appId } = params;
21
+ const location = useLocation();
22
+ const navigate = useNavigate();
23
+ const initialPath = workspaceAppInitialPathFromSplat(
24
+ params["*"],
25
+ location.search,
26
+ location.hash,
27
+ );
28
+ const onChildRouteChange = useCallback(
29
+ (path: string) => {
30
+ const currentPath = `${location.pathname}${location.search}${location.hash}`;
31
+ if (path === currentPath) return;
32
+ void navigate(path, { replace: true });
33
+ },
34
+ [location.hash, location.pathname, location.search, navigate],
35
+ );
36
+
16
37
  return (
17
38
  <WorkspaceAppHost
18
39
  appId={appId}
19
40
  navigateToTopWindow={navigateWorkspaceAppFromRoute}
41
+ initialPath={initialPath}
42
+ onChildRouteChange={onChildRouteChange}
20
43
  />
21
44
  );
22
45
  }
@@ -18,7 +18,11 @@ import {
18
18
  } from "../../components/app-list-row";
19
19
  import { CreateAppPopover } from "../../components/create-app-popover";
20
20
  import { DispatchShell } from "../../components/dispatch-shell";
21
- import { OtherAppsSection } from "../../components/other-apps-section";
21
+ import {
22
+ mergeOtherAppEntries,
23
+ otherAppEntryMatchesQuery,
24
+ OtherAppsSection,
25
+ } from "../../components/other-apps-section";
22
26
  import { Button } from "../../components/ui/button";
23
27
  import {
24
28
  Collapsible,
@@ -92,6 +96,24 @@ function AppsRoute() {
92
96
  const activeApps = visibleApps.filter((app) => app.status !== "pending");
93
97
  const pendingApps = visibleApps.filter((app) => app.status === "pending");
94
98
  const archivedApps = allApps.filter((app) => app.archived);
99
+ const connectedApps =
100
+ (connectedAppsQuery.data as ConnectedAppSummary[] | undefined) ?? [];
101
+ const curatedTemplates = curatedTemplatesQuery.data as
102
+ | CuratedWorkspaceTemplatesResult
103
+ | undefined;
104
+ const otherAppEntries = mergeOtherAppEntries({
105
+ templates: curatedTemplates,
106
+ connectedApps,
107
+ workspaceApps: allApps,
108
+ });
109
+ const otherAppsSearchReady =
110
+ !connectedAppsQuery.isLoading && !curatedTemplatesQuery.isLoading;
111
+ const otherAppsMatchSearch =
112
+ !searchQuery.trim() ||
113
+ !otherAppsSearchReady ||
114
+ otherAppEntries.some((entry) =>
115
+ otherAppEntryMatchesQuery(entry, searchQuery),
116
+ );
95
117
  const orderedActiveApps = orderWorkspaceApps(activeApps, layout);
96
118
  const orderedPendingApps = orderWorkspaceApps(pendingApps, layout);
97
119
  const orderedArchivedApps = orderWorkspaceApps(archivedApps, layout);
@@ -107,7 +129,8 @@ function AppsRoute() {
107
129
  const hasSearchResults =
108
130
  filteredActiveApps.length > 0 ||
109
131
  filteredPendingApps.length > 0 ||
110
- filteredArchivedApps.length > 0;
132
+ filteredArchivedApps.length > 0 ||
133
+ otherAppsMatchSearch;
111
134
  const showAppSkeletons = appsLoading && allApps.length === 0;
112
135
  const templateLabels: WorkspaceTemplateLabels = {
113
136
  appId: t("dispatch.pages.remixAppIdLabel"),
@@ -277,30 +300,23 @@ function AppsRoute() {
277
300
  </Collapsible>
278
301
  ) : null}
279
302
 
280
- {!searchQuery.trim() ? (
281
- <OtherAppsSection
282
- templates={
283
- curatedTemplatesQuery.data as
284
- | CuratedWorkspaceTemplatesResult
285
- | undefined
286
- }
287
- connectedApps={
288
- connectedAppsQuery.data as ConnectedAppSummary[] | undefined
289
- }
290
- workspaceApps={allApps}
291
- templatesLoading={curatedTemplatesQuery.isLoading}
292
- connectedAppsLoading={connectedAppsQuery.isLoading}
293
- templatesError={curatedTemplatesQuery.error}
294
- connectedAppsError={connectedAppsQuery.error}
295
- onRetryTemplates={() => void curatedTemplatesQuery.refetch()}
296
- onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
297
- templateLabels={templateLabels}
298
- onRemixSuccess={() => {
299
- void appsQuery.refetch();
300
- void curatedTemplatesQuery.refetch();
301
- }}
302
- />
303
- ) : null}
303
+ <OtherAppsSection
304
+ templates={curatedTemplates}
305
+ connectedApps={connectedApps}
306
+ workspaceApps={allApps}
307
+ query={searchQuery}
308
+ templatesLoading={curatedTemplatesQuery.isLoading}
309
+ connectedAppsLoading={connectedAppsQuery.isLoading}
310
+ templatesError={curatedTemplatesQuery.error}
311
+ connectedAppsError={connectedAppsQuery.error}
312
+ onRetryTemplates={() => void curatedTemplatesQuery.refetch()}
313
+ onRetryConnectedApps={() => void connectedAppsQuery.refetch()}
314
+ templateLabels={templateLabels}
315
+ onRemixSuccess={() => {
316
+ void appsQuery.refetch();
317
+ void curatedTemplatesQuery.refetch();
318
+ }}
319
+ />
304
320
 
305
321
  {archivedApps.length > 0 &&
306
322
  (!searchQuery.trim() || filteredArchivedApps.length > 0) ? (
@@ -33,6 +33,7 @@ vi.mock("@agent-native/core/settings", () => ({
33
33
  }));
34
34
 
35
35
  vi.mock("@agent-native/core/usage", () => ({
36
+ builderCreditsFromCostCents: (cents: number) => cents / 4,
36
37
  getUsageSummary: (...args: any[]) => mocks.getUsageSummary(...args),
37
38
  usageBillingForEngine: () => ({
38
39
  unit: "usd",
@@ -101,7 +102,7 @@ describe("listDispatchUsageMetrics", () => {
101
102
  orgId: null,
102
103
  role: null,
103
104
  scope: "solo",
104
- totalUsers: 0,
105
+ totalUsers: 1,
105
106
  });
106
107
  expect(metrics.totals).toEqual({
107
108
  costCents: 0,
@@ -195,6 +196,35 @@ describe("listDispatchUsageMetrics", () => {
195
196
  ).toBe(true);
196
197
  });
197
198
 
199
+ it("keeps an unaffiliated workspace request scoped to the signed-in user", async () => {
200
+ mocks.getUsageSummary.mockResolvedValue(null);
201
+ mocks.listWorkspaceApps.mockResolvedValue([]);
202
+ mocks.execute.mockResolvedValue({ rows: [] });
203
+
204
+ const metrics = await listDispatchUsageMetrics({
205
+ sinceDays: 30,
206
+ scope: "workspace",
207
+ });
208
+
209
+ expect(metrics.access).toMatchObject({
210
+ viewerEmail: "owner@example.test",
211
+ orgId: null,
212
+ scope: "solo",
213
+ totalUsers: 1,
214
+ });
215
+ const scopedTokenQueries = mocks.execute.mock.calls
216
+ .map(([query]) => String((query as { sql?: string }).sql))
217
+ .filter(
218
+ (sql) => sql.includes("FROM token_usage") && sql.includes("WHERE"),
219
+ );
220
+ expect(scopedTokenQueries.length).toBeGreaterThan(0);
221
+ expect(
222
+ scopedTokenQueries.every((sql) =>
223
+ sql.includes("LOWER(owner_email) IN (?)"),
224
+ ),
225
+ ).toBe(true);
226
+ });
227
+
198
228
  it("filters workspace usage and prompts to a selected member", async () => {
199
229
  mocks.currentOrgId.mockReturnValue("org-a");
200
230
  mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
@@ -237,4 +267,105 @@ describe("listDispatchUsageMetrics", () => {
237
267
  }),
238
268
  ).toBe(true);
239
269
  });
270
+
271
+ it("returns monthly credits and workspace app creation rows from shared tables", async () => {
272
+ const firstUsageAt = Date.UTC(2026, 6, 1, 12);
273
+ const secondUsageAt = Date.UTC(2026, 6, 15, 12);
274
+ const firstCreationAt = Date.UTC(2026, 6, 2, 12);
275
+ const secondCreationAt = Date.UTC(2026, 6, 20, 12);
276
+
277
+ mocks.currentOrgId.mockReturnValue("org-a");
278
+ mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
279
+ mocks.getUsageSummary.mockResolvedValue(null);
280
+ mocks.listWorkspaceApps.mockResolvedValue([]);
281
+ mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
282
+ if (sql.includes("SELECT role FROM org_members")) {
283
+ return { rows: [{ role: "owner" }] };
284
+ }
285
+ if (sql.includes("SELECT email, role, joined_at")) {
286
+ return {
287
+ rows: [
288
+ { email: "owner@example.test", role: "owner", joined_at: null },
289
+ { email: "member@example.test", role: "member", joined_at: null },
290
+ ],
291
+ };
292
+ }
293
+ if (
294
+ sql.includes("FROM token_usage") &&
295
+ sql.includes("ORDER BY created_at ASC")
296
+ ) {
297
+ return {
298
+ rows: [
299
+ {
300
+ created_at: firstUsageAt,
301
+ owner_email: "member@example.test",
302
+ label: "chat",
303
+ input_tokens: 10,
304
+ output_tokens: 20,
305
+ cache_read_tokens: 0,
306
+ cache_write_tokens: 0,
307
+ cost_cents_x100: 10000,
308
+ },
309
+ {
310
+ created_at: secondUsageAt,
311
+ owner_email: "member@example.test",
312
+ label: "tool",
313
+ input_tokens: 30,
314
+ output_tokens: 40,
315
+ cache_read_tokens: 5,
316
+ cache_write_tokens: 2,
317
+ cost_cents_x100: 20000,
318
+ },
319
+ ],
320
+ };
321
+ }
322
+ if (sql.includes("FROM dispatch_audit_events")) {
323
+ return {
324
+ rows: [
325
+ {
326
+ created_at: firstCreationAt,
327
+ owner_email: "member@example.test",
328
+ actor: "member@example.test",
329
+ target_id: "app-one",
330
+ },
331
+ {
332
+ created_at: secondCreationAt,
333
+ owner_email: "member@example.test",
334
+ actor: "member@example.test",
335
+ target_id: "app-two",
336
+ },
337
+ ],
338
+ };
339
+ }
340
+ return { rows: [] };
341
+ });
342
+
343
+ const metrics = await listDispatchUsageMetrics({
344
+ sinceDays: 365,
345
+ scope: "workspace",
346
+ });
347
+
348
+ expect(metrics.monthlyByUser).toEqual([
349
+ {
350
+ month: "2026-07",
351
+ ownerEmail: "member@example.test",
352
+ costCents: 300,
353
+ credits: 75,
354
+ calls: 2,
355
+ chatCalls: 1,
356
+ inputTokens: 40,
357
+ outputTokens: 60,
358
+ cacheReadTokens: 5,
359
+ cacheWriteTokens: 2,
360
+ },
361
+ ]);
362
+ expect(metrics.workspaceAppCreationsByUserMonth).toEqual([
363
+ {
364
+ month: "2026-07",
365
+ ownerEmail: "member@example.test",
366
+ count: 2,
367
+ appIds: ["app-one", "app-two"],
368
+ },
369
+ ]);
370
+ });
240
371
  });