@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.
- package/dist/actions/list-dispatch-usage-metrics.js +9 -1
- package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +12 -12
- package/dist/components/layout/Layout.d.ts +2 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -3
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/other-apps-section.d.ts +5 -3
- package/dist/components/other-apps-section.d.ts.map +1 -1
- package/dist/components/other-apps-section.js +15 -3
- package/dist/components/other-apps-section.js.map +1 -1
- package/dist/components/workspace-app-host.d.ts +8 -2
- package/dist/components/workspace-app-host.d.ts.map +1 -1
- package/dist/components/workspace-app-host.js +46 -8
- package/dist/components/workspace-app-host.js.map +1 -1
- package/dist/lib/workspace-apps.d.ts +7 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +65 -0
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/routes/index.js +1 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +15 -4
- package/dist/routes/pages/apps.$appId.js.map +1 -1
- package/dist/routes/pages/apps.d.ts.map +1 -1
- package/dist/routes/pages/apps.js +16 -4
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts +20 -0
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +86 -32
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +3 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/index.spec.ts +17 -0
- package/src/actions/list-dispatch-usage-metrics.ts +9 -1
- package/src/components/layout/Layout.spec.tsx +75 -0
- package/src/components/layout/Layout.tsx +22 -23
- package/src/components/other-apps-section.spec.ts +49 -0
- package/src/components/other-apps-section.tsx +31 -6
- package/src/components/workspace-app-host.spec.tsx +98 -0
- package/src/components/workspace-app-host.tsx +70 -4
- package/src/lib/workspace-apps.spec.ts +40 -0
- package/src/lib/workspace-apps.ts +80 -0
- package/src/routes/index.spec.ts +10 -0
- package/src/routes/index.ts +1 -1
- package/src/routes/pages/apps.$appId.spec.tsx +50 -1
- package/src/routes/pages/apps.$appId.tsx +26 -3
- package/src/routes/pages/apps.tsx +42 -26
- package/src/server/lib/usage-metrics-store.spec.ts +132 -1
- package/src/server/lib/usage-metrics-store.ts +173 -65
- package/src/server/plugins/agent-chat.spec.ts +2 -0
- 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;
|
package/src/routes/index.spec.ts
CHANGED
|
@@ -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
|
});
|
package/src/routes/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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:
|
|
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
|
});
|