@agent-native/dispatch 0.1.1 → 0.2.3

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 (146) hide show
  1. package/dist/actions/index.d.ts.map +1 -1
  2. package/dist/actions/index.js +2 -0
  3. package/dist/actions/index.js.map +1 -1
  4. package/dist/actions/list-dispatch-usage-metrics.d.ts +3 -0
  5. package/dist/actions/list-dispatch-usage-metrics.d.ts.map +1 -0
  6. package/dist/actions/list-dispatch-usage-metrics.js +18 -0
  7. package/dist/actions/list-dispatch-usage-metrics.js.map +1 -0
  8. package/dist/actions/navigate.d.ts +1 -0
  9. package/dist/actions/navigate.d.ts.map +1 -1
  10. package/dist/actions/navigate.js +3 -17
  11. package/dist/actions/navigate.js.map +1 -1
  12. package/dist/actions/view-screen.d.ts.map +1 -1
  13. package/dist/actions/view-screen.js +19 -0
  14. package/dist/actions/view-screen.js.map +1 -1
  15. package/dist/components/agents-panel.js +3 -3
  16. package/dist/components/app-keys-popover.js +2 -2
  17. package/dist/components/create-app-popover.js +2 -2
  18. package/dist/components/dispatch-shell.js +2 -2
  19. package/dist/components/index.d.ts +1 -0
  20. package/dist/components/index.d.ts.map +1 -1
  21. package/dist/components/index.js.map +1 -1
  22. package/dist/components/layout/Header.js +5 -5
  23. package/dist/components/layout/Header.js.map +1 -1
  24. package/dist/components/layout/Layout.d.ts +28 -3
  25. package/dist/components/layout/Layout.d.ts.map +1 -1
  26. package/dist/components/layout/Layout.js +138 -28
  27. package/dist/components/layout/Layout.js.map +1 -1
  28. package/dist/components/messaging-setup-panel.js +4 -4
  29. package/dist/components/ui/accordion.js +1 -1
  30. package/dist/components/ui/alert-dialog.js +2 -2
  31. package/dist/components/ui/alert.js +1 -1
  32. package/dist/components/ui/avatar.js +1 -1
  33. package/dist/components/ui/badge.js +1 -1
  34. package/dist/components/ui/breadcrumb.js +1 -1
  35. package/dist/components/ui/button.js +1 -1
  36. package/dist/components/ui/calendar.js +2 -2
  37. package/dist/components/ui/card.js +1 -1
  38. package/dist/components/ui/carousel.d.ts +2 -2
  39. package/dist/components/ui/carousel.js +2 -2
  40. package/dist/components/ui/chart.js +1 -1
  41. package/dist/components/ui/checkbox.js +1 -1
  42. package/dist/components/ui/command.js +2 -2
  43. package/dist/components/ui/context-menu.js +1 -1
  44. package/dist/components/ui/dialog.js +1 -1
  45. package/dist/components/ui/drawer.js +1 -1
  46. package/dist/components/ui/dropdown-menu.js +1 -1
  47. package/dist/components/ui/form.js +2 -2
  48. package/dist/components/ui/hover-card.js +1 -1
  49. package/dist/components/ui/input-otp.js +1 -1
  50. package/dist/components/ui/input.js +1 -1
  51. package/dist/components/ui/label.js +1 -1
  52. package/dist/components/ui/menubar.js +1 -1
  53. package/dist/components/ui/navigation-menu.js +1 -1
  54. package/dist/components/ui/pagination.d.ts +1 -1
  55. package/dist/components/ui/pagination.js +2 -2
  56. package/dist/components/ui/popover.js +1 -1
  57. package/dist/components/ui/progress.js +1 -1
  58. package/dist/components/ui/radio-group.js +1 -1
  59. package/dist/components/ui/resizable.js +1 -1
  60. package/dist/components/ui/scroll-area.js +1 -1
  61. package/dist/components/ui/select.js +1 -1
  62. package/dist/components/ui/separator.js +1 -1
  63. package/dist/components/ui/sheet.js +1 -1
  64. package/dist/components/ui/sidebar.d.ts +2 -2
  65. package/dist/components/ui/sidebar.d.ts.map +1 -1
  66. package/dist/components/ui/sidebar.js +9 -9
  67. package/dist/components/ui/sidebar.js.map +1 -1
  68. package/dist/components/ui/skeleton.js +1 -1
  69. package/dist/components/ui/slider.js +1 -1
  70. package/dist/components/ui/sonner.js +1 -1
  71. package/dist/components/ui/spinner.js +1 -1
  72. package/dist/components/ui/switch.js +1 -1
  73. package/dist/components/ui/table.js +1 -1
  74. package/dist/components/ui/tabs.js +1 -1
  75. package/dist/components/ui/textarea.js +1 -1
  76. package/dist/components/ui/toast.js +1 -1
  77. package/dist/components/ui/toaster.js +2 -2
  78. package/dist/components/ui/toggle-group.js +2 -2
  79. package/dist/components/ui/toggle.js +1 -1
  80. package/dist/components/ui/tooltip.js +1 -1
  81. package/dist/components/ui/use-toast.d.ts +1 -1
  82. package/dist/components/ui/use-toast.js +1 -1
  83. package/dist/hooks/use-navigation-state.d.ts +2 -1
  84. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  85. package/dist/hooks/use-navigation-state.js +36 -8
  86. package/dist/hooks/use-navigation-state.js.map +1 -1
  87. package/dist/hooks/use-toast.d.ts +1 -1
  88. package/dist/routes/index.d.ts.map +1 -1
  89. package/dist/routes/index.js +3 -2
  90. package/dist/routes/index.js.map +1 -1
  91. package/dist/routes/pages/_index.js +1 -1
  92. package/dist/routes/pages/agents.js +2 -2
  93. package/dist/routes/pages/approval.js +2 -2
  94. package/dist/routes/pages/approvals.js +4 -4
  95. package/dist/routes/pages/apps.$appId.js +3 -3
  96. package/dist/routes/pages/apps.js +5 -5
  97. package/dist/routes/pages/audit.js +1 -1
  98. package/dist/routes/pages/destinations.js +6 -6
  99. package/dist/routes/pages/extensions.$id.d.ts +2 -0
  100. package/dist/routes/pages/extensions.$id.d.ts.map +1 -0
  101. package/dist/routes/pages/extensions.$id.js +6 -0
  102. package/dist/routes/pages/extensions.$id.js.map +1 -0
  103. package/dist/routes/pages/extensions._index.d.ts +2 -0
  104. package/dist/routes/pages/extensions._index.d.ts.map +1 -0
  105. package/dist/routes/pages/extensions._index.js +6 -0
  106. package/dist/routes/pages/extensions._index.js.map +1 -0
  107. package/dist/routes/pages/identities.js +2 -2
  108. package/dist/routes/pages/integrations.js +4 -4
  109. package/dist/routes/pages/messaging.js +2 -2
  110. package/dist/routes/pages/metrics.d.ts +5 -0
  111. package/dist/routes/pages/metrics.d.ts.map +1 -0
  112. package/dist/routes/pages/metrics.js +135 -0
  113. package/dist/routes/pages/metrics.js.map +1 -0
  114. package/dist/routes/pages/new-app.js +1 -1
  115. package/dist/routes/pages/overview.d.ts.map +1 -1
  116. package/dist/routes/pages/overview.js +9 -17
  117. package/dist/routes/pages/overview.js.map +1 -1
  118. package/dist/routes/pages/team.js +1 -1
  119. package/dist/routes/pages/vault.js +10 -10
  120. package/dist/routes/pages/workspace.js +10 -10
  121. package/dist/server/lib/pre-auth-routing.d.ts.map +1 -1
  122. package/dist/server/lib/pre-auth-routing.js +9 -2
  123. package/dist/server/lib/pre-auth-routing.js.map +1 -1
  124. package/dist/server/lib/usage-metrics-store.d.ts +93 -0
  125. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -0
  126. package/dist/server/lib/usage-metrics-store.js +386 -0
  127. package/dist/server/lib/usage-metrics-store.js.map +1 -0
  128. package/package.json +11 -6
  129. package/src/actions/index.ts +2 -0
  130. package/src/actions/list-dispatch-usage-metrics.ts +19 -0
  131. package/src/actions/navigate.ts +5 -17
  132. package/src/actions/view-screen.ts +18 -0
  133. package/src/components/index.ts +6 -0
  134. package/src/components/layout/Header.tsx +2 -2
  135. package/src/components/layout/Layout.tsx +197 -48
  136. package/src/components/ui/sidebar.tsx +22 -18
  137. package/src/hooks/use-navigation-state.ts +57 -8
  138. package/src/routes/index.ts +3 -2
  139. package/src/routes/pages/extensions.$id.tsx +5 -0
  140. package/src/routes/pages/extensions._index.tsx +5 -0
  141. package/src/routes/pages/metrics.tsx +667 -0
  142. package/src/routes/pages/overview.tsx +0 -10
  143. package/src/server/lib/pre-auth-routing.ts +10 -2
  144. package/src/server/lib/usage-metrics-store.ts +605 -0
  145. package/src/styles/dispatch-css.spec.ts +55 -0
  146. package/src/styles/dispatch.css +9 -0
@@ -12,6 +12,7 @@ function normalizePathname(value: string): string {
12
12
 
13
13
  const DISPATCH_PAGE_PATHS = new Set([
14
14
  "/overview",
15
+ "/metrics",
15
16
  "/login",
16
17
  "/signup",
17
18
  "/apps",
@@ -20,6 +21,7 @@ const DISPATCH_PAGE_PATHS = new Set([
20
21
  "/integrations",
21
22
  "/agents",
22
23
  "/workspace",
24
+ "/tools",
23
25
  "/messaging",
24
26
  "/destinations",
25
27
  "/identities",
@@ -31,6 +33,7 @@ const DISPATCH_PAGE_PATHS = new Set([
31
33
  const DISPATCH_ROOT_ALIASES = new Map<string, string>([
32
34
  ...Array.from(DISPATCH_PAGE_PATHS, (path) => [path, path] as const),
33
35
  ["/approval", "/approval"],
36
+ ["/extensions", "/extensions"],
34
37
  ["/tools", "/tools"],
35
38
  ["/apps/new-app", "/new-app"],
36
39
  ]);
@@ -41,8 +44,13 @@ const MOUNTED_DISPATCH_ALIASES = new Map<string, string>([
41
44
 
42
45
  function isDispatchPagePath(pathname: string): boolean {
43
46
  if (DISPATCH_PAGE_PATHS.has(pathname)) return true;
44
- if (pathname === "/approval" || pathname === "/tools") return true;
45
- return /^\/tools\/[^/]+$/.test(pathname) || /^\/apps\/[^/]+$/.test(pathname);
47
+ if (pathname === "/approval" || pathname === "/extensions") return true;
48
+ if (pathname === "/tools") return true;
49
+ return (
50
+ /^\/extensions\/[^/]+$/.test(pathname) ||
51
+ /^\/tools\/[^/]+$/.test(pathname) ||
52
+ /^\/apps\/[^/]+$/.test(pathname)
53
+ );
46
54
  }
47
55
 
48
56
  function isDispatchAssetOrFrameworkPath(pathname: string): boolean {
@@ -0,0 +1,605 @@
1
+ import { getUsageSummary } from "@agent-native/core/usage";
2
+ import { getDbExec } from "@agent-native/core/db";
3
+ import { currentOrgId, currentOwnerEmail } from "./dispatch-store.js";
4
+ import {
5
+ listWorkspaceApps,
6
+ type WorkspaceAppSummary,
7
+ } from "./app-creation-store.js";
8
+
9
+ const DAY_MS = 86_400_000;
10
+
11
+ export interface UsageMetricBucket {
12
+ key: string;
13
+ label: string;
14
+ costCents: number;
15
+ calls: number;
16
+ chatCalls: number;
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ cacheReadTokens: number;
20
+ cacheWriteTokens: number;
21
+ activeUsers: number;
22
+ lastActiveAt: number | null;
23
+ }
24
+
25
+ export interface UserUsageMetric extends UsageMetricBucket {
26
+ ownerEmail: string;
27
+ chatThreads: number;
28
+ chatMessages: number;
29
+ lastChatAt: number | null;
30
+ topApp: string | null;
31
+ role: string | null;
32
+ }
33
+
34
+ export interface AppAccessMetric {
35
+ id: string;
36
+ name: string;
37
+ path: string;
38
+ status: WorkspaceAppSummary["status"];
39
+ isDispatch: boolean;
40
+ accessModel: "workspace" | "solo";
41
+ accessLabel: string;
42
+ accessUsers: number;
43
+ usersWithUsage: number;
44
+ usageCalls: number;
45
+ chatCalls: number;
46
+ costCents: number;
47
+ lastActiveAt: number | null;
48
+ }
49
+
50
+ export interface DailyUsageMetric {
51
+ date: string;
52
+ costCents: number;
53
+ calls: number;
54
+ chatCalls: number;
55
+ activeUsers: number;
56
+ }
57
+
58
+ export interface RecentUsageMetric {
59
+ id: number;
60
+ createdAt: number;
61
+ ownerEmail: string;
62
+ app: string;
63
+ label: string;
64
+ model: string;
65
+ inputTokens: number;
66
+ outputTokens: number;
67
+ cacheReadTokens: number;
68
+ cacheWriteTokens: number;
69
+ costCents: number;
70
+ }
71
+
72
+ export interface DispatchUsageMetrics {
73
+ sinceMs: number;
74
+ sinceDays: number;
75
+ generatedAt: number;
76
+ access: {
77
+ viewerEmail: string;
78
+ orgId: string | null;
79
+ role: string | null;
80
+ scope: "organization" | "solo";
81
+ totalUsers: number;
82
+ };
83
+ totals: {
84
+ costCents: number;
85
+ calls: number;
86
+ chatCalls: number;
87
+ inputTokens: number;
88
+ outputTokens: number;
89
+ cacheReadTokens: number;
90
+ cacheWriteTokens: number;
91
+ activeUsers: number;
92
+ chatThreads: number;
93
+ chatMessages: number;
94
+ workspaceApps: number;
95
+ };
96
+ byApp: UsageMetricBucket[];
97
+ byUser: UserUsageMetric[];
98
+ byLabel: UsageMetricBucket[];
99
+ byModel: UsageMetricBucket[];
100
+ daily: DailyUsageMetric[];
101
+ appAccess: AppAccessMetric[];
102
+ recent: RecentUsageMetric[];
103
+ }
104
+
105
+ interface MemberRecord {
106
+ email: string;
107
+ role: string | null;
108
+ joinedAt: number | null;
109
+ }
110
+
111
+ interface ChatStats {
112
+ threads: number;
113
+ messages: number;
114
+ lastChatAt: number | null;
115
+ }
116
+
117
+ function numberField(row: Record<string, unknown>, key: string): number {
118
+ return Number(row[key] ?? 0) || 0;
119
+ }
120
+
121
+ function stringField(row: Record<string, unknown>, key: string): string {
122
+ return String(row[key] ?? "");
123
+ }
124
+
125
+ function nullableNumberField(
126
+ row: Record<string, unknown>,
127
+ key: string,
128
+ ): number | null {
129
+ const value = row[key];
130
+ if (value == null) return null;
131
+ const numberValue = Number(value);
132
+ return Number.isFinite(numberValue) ? numberValue : null;
133
+ }
134
+
135
+ function labelForKey(value: string): string {
136
+ const trimmed = value.trim();
137
+ return trimmed || "Unattributed";
138
+ }
139
+
140
+ function normalizeAppKey(value: string | null | undefined): string {
141
+ const raw = (value ?? "").trim().toLowerCase();
142
+ if (!raw) return "unattributed";
143
+ return raw.replace(/^agent-native-/, "");
144
+ }
145
+
146
+ function envEmails(name: string): string[] {
147
+ return (process.env[name] ?? "")
148
+ .split(",")
149
+ .map((value) => value.trim().toLowerCase())
150
+ .filter(Boolean);
151
+ }
152
+
153
+ function isEnvAdmin(email: string): boolean {
154
+ const normalized = email.trim().toLowerCase();
155
+ return [
156
+ ...envEmails("DISPATCH_ADMIN_EMAILS"),
157
+ ...envEmails("WORKSPACE_OWNER_EMAIL"),
158
+ ...envEmails("DISPATCH_DEFAULT_OWNER_EMAIL"),
159
+ ].includes(normalized);
160
+ }
161
+
162
+ async function queryRows<T extends Record<string, unknown>>(
163
+ sql: string,
164
+ args: unknown[] = [],
165
+ ): Promise<T[]> {
166
+ try {
167
+ const result = await getDbExec().execute({ sql, args });
168
+ return result.rows as T[];
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ async function getViewerOrgRole(
175
+ orgId: string | null,
176
+ email: string,
177
+ ): Promise<string | null> {
178
+ if (!orgId) return null;
179
+ const rows = await queryRows<{ role?: string }>(
180
+ `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
181
+ [orgId, email.toLowerCase()],
182
+ );
183
+ const role = rows[0]?.role;
184
+ return typeof role === "string" ? role : null;
185
+ }
186
+
187
+ async function listOrgMembers(orgId: string | null): Promise<MemberRecord[]> {
188
+ if (!orgId) return [];
189
+ const rows = await queryRows<Record<string, unknown>>(
190
+ `SELECT email, role, joined_at AS joined_at FROM org_members WHERE org_id = ? ORDER BY joined_at ASC`,
191
+ [orgId],
192
+ );
193
+ return rows
194
+ .map((row) => ({
195
+ email: stringField(row, "email").trim(),
196
+ role: stringField(row, "role") || null,
197
+ joinedAt: nullableNumberField(row, "joined_at"),
198
+ }))
199
+ .filter((member) => member.email);
200
+ }
201
+
202
+ async function listSignedInUsers(): Promise<MemberRecord[]> {
203
+ const authRows = await queryRows<Record<string, unknown>>(
204
+ `SELECT email, created_at AS joined_at FROM "user" ORDER BY created_at ASC`,
205
+ );
206
+ if (authRows.length > 0) {
207
+ return authRows
208
+ .map((row) => ({
209
+ email: stringField(row, "email").trim(),
210
+ role: null,
211
+ joinedAt: nullableNumberField(row, "joined_at"),
212
+ }))
213
+ .filter((member) => member.email);
214
+ }
215
+
216
+ const usageRows = await queryRows<{ email?: string }>(
217
+ `SELECT DISTINCT owner_email AS email FROM token_usage`,
218
+ );
219
+ const threadRows = await queryRows<{ email?: string }>(
220
+ `SELECT DISTINCT owner_email AS email FROM chat_threads`,
221
+ );
222
+ const emails = new Set<string>();
223
+ for (const row of [...usageRows, ...threadRows]) {
224
+ if (row.email) emails.add(row.email);
225
+ }
226
+ return [...emails].sort().map((email) => ({
227
+ email,
228
+ role: null,
229
+ joinedAt: null,
230
+ }));
231
+ }
232
+
233
+ function usageScope(
234
+ sinceMs: number,
235
+ memberEmails: string[],
236
+ ): { where: string; args: unknown[] } {
237
+ if (memberEmails.length === 0) {
238
+ return { where: "created_at >= ?", args: [sinceMs] };
239
+ }
240
+ const placeholders = memberEmails.map(() => "?").join(", ");
241
+ return {
242
+ where: `created_at >= ? AND owner_email IN (${placeholders})`,
243
+ args: [sinceMs, ...memberEmails],
244
+ };
245
+ }
246
+
247
+ function threadScope(
248
+ sinceMs: number,
249
+ memberEmails: string[],
250
+ ): { where: string; args: unknown[] } {
251
+ if (memberEmails.length === 0) {
252
+ return { where: "updated_at >= ?", args: [sinceMs] };
253
+ }
254
+ const placeholders = memberEmails.map(() => "?").join(", ");
255
+ return {
256
+ where: `updated_at >= ? AND owner_email IN (${placeholders})`,
257
+ args: [sinceMs, ...memberEmails],
258
+ };
259
+ }
260
+
261
+ function bucketFromRow(row: Record<string, unknown>): UsageMetricBucket {
262
+ const key = stringField(row, "k");
263
+ return {
264
+ key,
265
+ label: labelForKey(key),
266
+ costCents: numberField(row, "cost_x100") / 100,
267
+ calls: numberField(row, "calls"),
268
+ chatCalls: numberField(row, "chat_calls"),
269
+ inputTokens: numberField(row, "input_tokens"),
270
+ outputTokens: numberField(row, "output_tokens"),
271
+ cacheReadTokens: numberField(row, "cache_read_tokens"),
272
+ cacheWriteTokens: numberField(row, "cache_write_tokens"),
273
+ activeUsers: numberField(row, "active_users"),
274
+ lastActiveAt: nullableNumberField(row, "last_active_at"),
275
+ };
276
+ }
277
+
278
+ async function usageBuckets(
279
+ columnExpression: string,
280
+ where: string,
281
+ args: unknown[],
282
+ limit: number,
283
+ ): Promise<UsageMetricBucket[]> {
284
+ const rows = await queryRows<Record<string, unknown>>(
285
+ `SELECT ${columnExpression} AS k,
286
+ COALESCE(SUM(cost_cents_x100), 0) AS cost_x100,
287
+ COUNT(*) AS calls,
288
+ SUM(CASE WHEN label = 'chat' THEN 1 ELSE 0 END) AS chat_calls,
289
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
290
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
291
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
292
+ COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
293
+ COUNT(DISTINCT owner_email) AS active_users,
294
+ MAX(created_at) AS last_active_at
295
+ FROM token_usage
296
+ WHERE ${where}
297
+ GROUP BY ${columnExpression}
298
+ ORDER BY cost_x100 DESC
299
+ LIMIT ?`,
300
+ [...args, limit],
301
+ );
302
+ return rows.map(bucketFromRow);
303
+ }
304
+
305
+ async function loadChatStats(
306
+ where: string,
307
+ args: unknown[],
308
+ ): Promise<Map<string, ChatStats>> {
309
+ const rows = await queryRows<Record<string, unknown>>(
310
+ `SELECT owner_email AS owner_email,
311
+ COUNT(*) AS threads,
312
+ COALESCE(SUM(message_count), 0) AS messages,
313
+ MAX(updated_at) AS last_chat_at
314
+ FROM chat_threads
315
+ WHERE ${where}
316
+ GROUP BY owner_email`,
317
+ args,
318
+ );
319
+ return new Map(
320
+ rows.map((row) => [
321
+ stringField(row, "owner_email"),
322
+ {
323
+ threads: numberField(row, "threads"),
324
+ messages: numberField(row, "messages"),
325
+ lastChatAt: nullableNumberField(row, "last_chat_at"),
326
+ },
327
+ ]),
328
+ );
329
+ }
330
+
331
+ async function assertCanViewMetrics(): Promise<{
332
+ viewerEmail: string;
333
+ orgId: string | null;
334
+ role: string | null;
335
+ }> {
336
+ const viewerEmail = currentOwnerEmail();
337
+ const orgId = currentOrgId();
338
+ const role = await getViewerOrgRole(orgId, viewerEmail);
339
+ if (isEnvAdmin(viewerEmail) || role === "owner" || role === "admin") {
340
+ return { viewerEmail, orgId, role };
341
+ }
342
+ if (!orgId) {
343
+ return { viewerEmail, orgId, role };
344
+ }
345
+ throw new Error(
346
+ "Only organization owners and admins can view workspace usage metrics.",
347
+ );
348
+ }
349
+
350
+ export async function listDispatchUsageMetrics(input: {
351
+ sinceDays?: number;
352
+ }): Promise<DispatchUsageMetrics> {
353
+ const { viewerEmail, orgId, role } = await assertCanViewMetrics();
354
+ const sinceDays = Math.max(1, Math.min(365, input.sinceDays ?? 30));
355
+ const sinceMs = Date.now() - sinceDays * DAY_MS;
356
+
357
+ // Initializes token_usage on fresh deployments before the read-only
358
+ // aggregate queries below. The fake owner avoids changing visible data.
359
+ await getUsageSummary({ ownerEmail: "__dispatch_metrics_init__", sinceMs });
360
+
361
+ const rawMembers = orgId
362
+ ? await listOrgMembers(orgId)
363
+ : await listSignedInUsers();
364
+ const members =
365
+ orgId && rawMembers.length === 0
366
+ ? [{ email: viewerEmail, role, joinedAt: null }]
367
+ : rawMembers;
368
+ const memberEmails = orgId ? members.map((member) => member.email) : [];
369
+ const memberByEmail = new Map(
370
+ members.map((member) => [member.email.toLowerCase(), member]),
371
+ );
372
+ const usage = usageScope(sinceMs, memberEmails);
373
+ const threads = threadScope(sinceMs, memberEmails);
374
+
375
+ const [apps, totalsRows, byApp, byUserBase, byLabel, byModel, chatStats] =
376
+ await Promise.all([
377
+ listWorkspaceApps({ includeAgentCards: false }),
378
+ queryRows<Record<string, unknown>>(
379
+ `SELECT
380
+ COALESCE(SUM(cost_cents_x100), 0) AS cost_x100,
381
+ COUNT(*) AS calls,
382
+ SUM(CASE WHEN label = 'chat' THEN 1 ELSE 0 END) AS chat_calls,
383
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
384
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
385
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
386
+ COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
387
+ COUNT(DISTINCT owner_email) AS active_users
388
+ FROM token_usage
389
+ WHERE ${usage.where}`,
390
+ usage.args,
391
+ ),
392
+ usageBuckets(
393
+ `COALESCE(NULLIF(app, ''), 'unattributed')`,
394
+ usage.where,
395
+ usage.args,
396
+ 20,
397
+ ),
398
+ usageBuckets("owner_email", usage.where, usage.args, 50),
399
+ usageBuckets(
400
+ `COALESCE(NULLIF(label, ''), 'chat')`,
401
+ usage.where,
402
+ usage.args,
403
+ 20,
404
+ ),
405
+ usageBuckets(
406
+ `COALESCE(NULLIF(model, ''), 'unknown')`,
407
+ usage.where,
408
+ usage.args,
409
+ 20,
410
+ ),
411
+ loadChatStats(threads.where, threads.args),
412
+ ]);
413
+
414
+ const topAppRows = await queryRows<Record<string, unknown>>(
415
+ `SELECT owner_email AS owner_email,
416
+ COALESCE(NULLIF(app, ''), 'unattributed') AS app,
417
+ COALESCE(SUM(cost_cents_x100), 0) AS cost_x100
418
+ FROM token_usage
419
+ WHERE ${usage.where}
420
+ GROUP BY owner_email, COALESCE(NULLIF(app, ''), 'unattributed')
421
+ ORDER BY owner_email ASC, cost_x100 DESC`,
422
+ usage.args,
423
+ );
424
+ const topAppByUser = new Map<string, string>();
425
+ for (const row of topAppRows) {
426
+ const email = stringField(row, "owner_email");
427
+ if (!topAppByUser.has(email)) {
428
+ topAppByUser.set(email, stringField(row, "app"));
429
+ }
430
+ }
431
+
432
+ const byUserMap = new Map<string, UserUsageMetric>();
433
+ for (const bucket of byUserBase) {
434
+ const ownerEmail = bucket.key;
435
+ const stats = chatStats.get(ownerEmail) ?? {
436
+ threads: 0,
437
+ messages: 0,
438
+ lastChatAt: null,
439
+ };
440
+ const member = memberByEmail.get(ownerEmail.toLowerCase());
441
+ byUserMap.set(ownerEmail, {
442
+ ...bucket,
443
+ ownerEmail,
444
+ chatThreads: stats.threads,
445
+ chatMessages: stats.messages,
446
+ lastChatAt: stats.lastChatAt,
447
+ topApp: topAppByUser.get(ownerEmail) ?? null,
448
+ role: member?.role ?? null,
449
+ });
450
+ }
451
+ for (const [ownerEmail, stats] of chatStats) {
452
+ if (byUserMap.has(ownerEmail)) continue;
453
+ const member = memberByEmail.get(ownerEmail.toLowerCase());
454
+ byUserMap.set(ownerEmail, {
455
+ key: ownerEmail,
456
+ label: ownerEmail,
457
+ ownerEmail,
458
+ costCents: 0,
459
+ calls: 0,
460
+ chatCalls: 0,
461
+ inputTokens: 0,
462
+ outputTokens: 0,
463
+ cacheReadTokens: 0,
464
+ cacheWriteTokens: 0,
465
+ activeUsers: 1,
466
+ lastActiveAt: stats.lastChatAt,
467
+ chatThreads: stats.threads,
468
+ chatMessages: stats.messages,
469
+ lastChatAt: stats.lastChatAt,
470
+ topApp: null,
471
+ role: member?.role ?? null,
472
+ });
473
+ }
474
+
475
+ const dayRows = await queryRows<Record<string, unknown>>(
476
+ `SELECT created_at, owner_email, label, cost_cents_x100
477
+ FROM token_usage
478
+ WHERE ${usage.where}
479
+ ORDER BY created_at ASC`,
480
+ usage.args,
481
+ );
482
+ const dailyMap = new Map<
483
+ string,
484
+ { costX100: number; calls: number; chatCalls: number; users: Set<string> }
485
+ >();
486
+ for (const row of dayRows) {
487
+ const date = new Date(numberField(row, "created_at"))
488
+ .toISOString()
489
+ .slice(0, 10);
490
+ const current = dailyMap.get(date) ?? {
491
+ costX100: 0,
492
+ calls: 0,
493
+ chatCalls: 0,
494
+ users: new Set<string>(),
495
+ };
496
+ current.costX100 += numberField(row, "cost_cents_x100");
497
+ current.calls += 1;
498
+ if (stringField(row, "label") === "chat") current.chatCalls += 1;
499
+ current.users.add(stringField(row, "owner_email"));
500
+ dailyMap.set(date, current);
501
+ }
502
+ const daily = [...dailyMap.entries()]
503
+ .map(([date, value]) => ({
504
+ date,
505
+ costCents: value.costX100 / 100,
506
+ calls: value.calls,
507
+ chatCalls: value.chatCalls,
508
+ activeUsers: value.users.size,
509
+ }))
510
+ .sort((a, b) => a.date.localeCompare(b.date));
511
+
512
+ const recentRows = await queryRows<Record<string, unknown>>(
513
+ `SELECT id, created_at, owner_email, app, label, model,
514
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
515
+ cost_cents_x100
516
+ FROM token_usage
517
+ WHERE ${usage.where}
518
+ ORDER BY created_at DESC
519
+ LIMIT 50`,
520
+ usage.args,
521
+ );
522
+ const recent = recentRows.map((row) => ({
523
+ id: numberField(row, "id"),
524
+ createdAt: numberField(row, "created_at"),
525
+ ownerEmail: stringField(row, "owner_email"),
526
+ app: stringField(row, "app") || "unattributed",
527
+ label: stringField(row, "label") || "chat",
528
+ model: stringField(row, "model") || "unknown",
529
+ inputTokens: numberField(row, "input_tokens"),
530
+ outputTokens: numberField(row, "output_tokens"),
531
+ cacheReadTokens: numberField(row, "cache_read_tokens"),
532
+ cacheWriteTokens: numberField(row, "cache_write_tokens"),
533
+ costCents: numberField(row, "cost_cents_x100") / 100,
534
+ }));
535
+
536
+ const appUsageByKey = new Map(
537
+ byApp.map((bucket) => [normalizeAppKey(bucket.key), bucket]),
538
+ );
539
+ const accessUsers = members.length || byUserMap.size;
540
+ const accessModel = orgId ? "workspace" : "solo";
541
+ const accessLabel = orgId ? "Workspace members" : "Signed-in users";
542
+ const appAccess = apps.map((app) => {
543
+ const usageBucket = appUsageByKey.get(normalizeAppKey(app.id));
544
+ return {
545
+ id: app.id,
546
+ name: app.name,
547
+ path: app.path,
548
+ status: app.status,
549
+ isDispatch: app.isDispatch,
550
+ accessModel,
551
+ accessLabel,
552
+ accessUsers,
553
+ usersWithUsage: usageBucket?.activeUsers ?? 0,
554
+ usageCalls: usageBucket?.calls ?? 0,
555
+ chatCalls: usageBucket?.chatCalls ?? 0,
556
+ costCents: usageBucket?.costCents ?? 0,
557
+ lastActiveAt: usageBucket?.lastActiveAt ?? null,
558
+ } satisfies AppAccessMetric;
559
+ });
560
+
561
+ const totals = totalsRows[0] ?? {};
562
+ const chatThreadTotals = [...chatStats.values()].reduce(
563
+ (acc, value) => ({
564
+ threads: acc.threads + value.threads,
565
+ messages: acc.messages + value.messages,
566
+ }),
567
+ { threads: 0, messages: 0 },
568
+ );
569
+
570
+ return {
571
+ sinceMs,
572
+ sinceDays,
573
+ generatedAt: Date.now(),
574
+ access: {
575
+ viewerEmail,
576
+ orgId,
577
+ role,
578
+ scope: orgId ? "organization" : "solo",
579
+ totalUsers: accessUsers,
580
+ },
581
+ totals: {
582
+ costCents: numberField(totals, "cost_x100") / 100,
583
+ calls: numberField(totals, "calls"),
584
+ chatCalls: numberField(totals, "chat_calls"),
585
+ inputTokens: numberField(totals, "input_tokens"),
586
+ outputTokens: numberField(totals, "output_tokens"),
587
+ cacheReadTokens: numberField(totals, "cache_read_tokens"),
588
+ cacheWriteTokens: numberField(totals, "cache_write_tokens"),
589
+ activeUsers: numberField(totals, "active_users"),
590
+ chatThreads: chatThreadTotals.threads,
591
+ chatMessages: chatThreadTotals.messages,
592
+ workspaceApps: apps.filter((app) => !app.isDispatch).length,
593
+ },
594
+ byApp,
595
+ byUser: [...byUserMap.values()].sort((a, b) => {
596
+ if (b.costCents !== a.costCents) return b.costCents - a.costCents;
597
+ return (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0);
598
+ }),
599
+ byLabel,
600
+ byModel,
601
+ daily,
602
+ appAccess,
603
+ recent,
604
+ };
605
+ }
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const packageRoot = path.resolve(
7
+ path.dirname(fileURLToPath(import.meta.url)),
8
+ "../..",
9
+ );
10
+ const repoRoot = path.resolve(packageRoot, "../..");
11
+
12
+ describe("dispatch Tailwind styles", () => {
13
+ it("exports package source directives for consuming apps", () => {
14
+ const pkg = JSON.parse(
15
+ fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"),
16
+ ) as { exports?: Record<string, string> };
17
+ const stylesheet = fs.readFileSync(
18
+ path.join(packageRoot, "src/styles/dispatch.css"),
19
+ "utf-8",
20
+ );
21
+
22
+ expect(pkg.exports?.["./styles/dispatch.css"]).toBe(
23
+ "./src/styles/dispatch.css",
24
+ );
25
+ expect(stylesheet).toContain(
26
+ '@source "../components/**/*.{js,mjs,ts,tsx}"',
27
+ );
28
+ expect(stylesheet).toContain('@source "../routes/**/*.{js,mjs,ts,tsx}"');
29
+ });
30
+
31
+ it("imports package source directives from the Dispatch template", () => {
32
+ const globalCss = fs.readFileSync(
33
+ path.join(repoRoot, "templates/dispatch/app/global.css"),
34
+ "utf-8",
35
+ );
36
+
37
+ expect(globalCss).toContain(
38
+ '@import "@agent-native/dispatch/styles/dispatch.css";',
39
+ );
40
+ });
41
+ });
42
+
43
+ describe("dispatch route shells", () => {
44
+ it("re-exports the index route redirects from the Dispatch template", () => {
45
+ const indexRoute = fs.readFileSync(
46
+ path.join(repoRoot, "templates/dispatch/app/routes/_index.tsx"),
47
+ "utf-8",
48
+ );
49
+
50
+ expect(indexRoute).toContain("loader");
51
+ expect(indexRoute).toContain("clientLoader");
52
+ expect(indexRoute).toContain("HydrateFallback");
53
+ expect(indexRoute).toContain("@agent-native/dispatch/routes/pages/_index");
54
+ });
55
+ });
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Tailwind v4 does not scan package sources in node_modules unless the
3
+ * consuming app opts in. Import this stylesheet from a Dispatch app's global
4
+ * CSS so Tailwind includes the utilities used by packaged Dispatch routes and
5
+ * components.
6
+ */
7
+ @source "../components/**/*.{js,mjs,ts,tsx}";
8
+ @source "../hooks/**/*.{js,mjs,ts,tsx}";
9
+ @source "../routes/**/*.{js,mjs,ts,tsx}";