@agent-native/dispatch 0.31.13 → 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/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/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
|
@@ -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
|
});
|
|
@@ -10,6 +10,7 @@ import { getDbExec } from "@agent-native/core/db";
|
|
|
10
10
|
import { getSetting } from "@agent-native/core/settings";
|
|
11
11
|
import { ForbiddenError } from "@agent-native/core/sharing";
|
|
12
12
|
import {
|
|
13
|
+
builderCreditsFromCostCents,
|
|
13
14
|
getUsageSummary,
|
|
14
15
|
usageBillingForEngine,
|
|
15
16
|
type UsageBillingMode,
|
|
@@ -73,6 +74,26 @@ export interface DailyUsageMetric {
|
|
|
73
74
|
activeUsers: number;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
export interface MonthlyUserUsageMetric {
|
|
78
|
+
month: string;
|
|
79
|
+
ownerEmail: string;
|
|
80
|
+
costCents: number;
|
|
81
|
+
credits: number;
|
|
82
|
+
calls: number;
|
|
83
|
+
chatCalls: number;
|
|
84
|
+
inputTokens: number;
|
|
85
|
+
outputTokens: number;
|
|
86
|
+
cacheReadTokens: number;
|
|
87
|
+
cacheWriteTokens: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface WorkspaceAppCreationMetric {
|
|
91
|
+
month: string;
|
|
92
|
+
ownerEmail: string;
|
|
93
|
+
count: number;
|
|
94
|
+
appIds: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
export interface RecentUsageMetric {
|
|
77
98
|
id: number;
|
|
78
99
|
createdAt: number;
|
|
@@ -134,6 +155,8 @@ export interface DispatchUsageMetrics {
|
|
|
134
155
|
byLabel: UsageMetricBucket[];
|
|
135
156
|
byModel: UsageMetricBucket[];
|
|
136
157
|
daily: DailyUsageMetric[];
|
|
158
|
+
monthlyByUser: MonthlyUserUsageMetric[];
|
|
159
|
+
workspaceAppCreationsByUserMonth: WorkspaceAppCreationMetric[];
|
|
137
160
|
appAccess: AppAccessMetric[];
|
|
138
161
|
recent: RecentUsageMetric[];
|
|
139
162
|
}
|
|
@@ -412,37 +435,6 @@ async function listOrgMembers(orgId: string | null): Promise<MemberRecord[]> {
|
|
|
412
435
|
.filter((member) => member.email);
|
|
413
436
|
}
|
|
414
437
|
|
|
415
|
-
async function listSignedInUsers(): Promise<MemberRecord[]> {
|
|
416
|
-
const authRows = await queryRows<Record<string, unknown>>(
|
|
417
|
-
`SELECT email, created_at AS joined_at FROM "user" ORDER BY created_at ASC`,
|
|
418
|
-
);
|
|
419
|
-
if (authRows.length > 0) {
|
|
420
|
-
return authRows
|
|
421
|
-
.map((row) => ({
|
|
422
|
-
email: stringField(row, "email").trim(),
|
|
423
|
-
role: null,
|
|
424
|
-
joinedAt: nullableNumberField(row, "joined_at"),
|
|
425
|
-
}))
|
|
426
|
-
.filter((member) => member.email);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const usageRows = await queryRows<{ email?: string }>(
|
|
430
|
-
`SELECT DISTINCT owner_email AS email FROM token_usage`,
|
|
431
|
-
);
|
|
432
|
-
const threadRows = await queryRows<{ email?: string }>(
|
|
433
|
-
`SELECT DISTINCT owner_email AS email FROM chat_threads`,
|
|
434
|
-
);
|
|
435
|
-
const emails = new Set<string>();
|
|
436
|
-
for (const row of [...usageRows, ...threadRows]) {
|
|
437
|
-
if (row.email) emails.add(row.email);
|
|
438
|
-
}
|
|
439
|
-
return [...emails].sort().map((email) => ({
|
|
440
|
-
email,
|
|
441
|
-
role: null,
|
|
442
|
-
joinedAt: null,
|
|
443
|
-
}));
|
|
444
|
-
}
|
|
445
|
-
|
|
446
438
|
function usageScope(
|
|
447
439
|
sinceMs: number,
|
|
448
440
|
memberEmails: string[],
|
|
@@ -497,6 +489,28 @@ function ownerThreadScope(
|
|
|
497
489
|
};
|
|
498
490
|
}
|
|
499
491
|
|
|
492
|
+
function workspaceAppCreationScope(
|
|
493
|
+
sinceMs: number,
|
|
494
|
+
orgId: string | null,
|
|
495
|
+
memberEmails: string[],
|
|
496
|
+
): { where: string; args: unknown[] } {
|
|
497
|
+
const filters = ["created_at >= ?", "action = ?"];
|
|
498
|
+
const args: unknown[] = [sinceMs, "workspace-app.pending"];
|
|
499
|
+
|
|
500
|
+
if (orgId) {
|
|
501
|
+
filters.push("org_id = ?");
|
|
502
|
+
args.push(orgId);
|
|
503
|
+
}
|
|
504
|
+
if (memberEmails.length > 0) {
|
|
505
|
+
filters.push(
|
|
506
|
+
`LOWER(owner_email) IN (${memberEmails.map(() => "?").join(", ")})`,
|
|
507
|
+
);
|
|
508
|
+
args.push(...memberEmails.map((email) => email.toLowerCase()));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return { where: filters.join(" AND "), args };
|
|
512
|
+
}
|
|
513
|
+
|
|
500
514
|
function bucketFromRow(row: Record<string, unknown>): UsageMetricBucket {
|
|
501
515
|
const key = stringField(row, "k");
|
|
502
516
|
return {
|
|
@@ -610,7 +624,7 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
610
624
|
? [{ email: viewerEmail, role, joinedAt: null }]
|
|
611
625
|
: orgId
|
|
612
626
|
? await listOrgMembers(orgId)
|
|
613
|
-
:
|
|
627
|
+
: [{ email: viewerEmail, role: null, joinedAt: null }];
|
|
614
628
|
const members =
|
|
615
629
|
viewScope === "workspace" && orgId && rawMembers.length === 0
|
|
616
630
|
? [{ email: viewerEmail, role, joinedAt: null }]
|
|
@@ -632,9 +646,7 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
632
646
|
}
|
|
633
647
|
const memberEmails = selectedUserEmail
|
|
634
648
|
? [selectedUserEmail]
|
|
635
|
-
:
|
|
636
|
-
? members.map((member) => member.email)
|
|
637
|
-
: [];
|
|
649
|
+
: members.map((member) => member.email);
|
|
638
650
|
const memberByEmail = new Map(
|
|
639
651
|
members.map((member) => [member.email.toLowerCase(), member]),
|
|
640
652
|
);
|
|
@@ -645,11 +657,25 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
645
657
|
? ownerThreadScope(sinceMs, selectedUserEmail)
|
|
646
658
|
: threadScope(sinceMs, memberEmails);
|
|
647
659
|
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
660
|
+
const workspaceAppCreation = workspaceAppCreationScope(
|
|
661
|
+
sinceMs,
|
|
662
|
+
orgId,
|
|
663
|
+
memberEmails,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const [
|
|
667
|
+
apps,
|
|
668
|
+
totalsRows,
|
|
669
|
+
byApp,
|
|
670
|
+
byUserBase,
|
|
671
|
+
byLabel,
|
|
672
|
+
byModel,
|
|
673
|
+
chatStats,
|
|
674
|
+
workspaceAppCreationRows,
|
|
675
|
+
] = await Promise.all([
|
|
676
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
677
|
+
queryRows<Record<string, unknown>>(
|
|
678
|
+
`SELECT
|
|
653
679
|
COALESCE(SUM(cost_cents_x100), 0) AS cost_x100,
|
|
654
680
|
COUNT(*) AS calls,
|
|
655
681
|
SUM(CASE WHEN label = 'chat' THEN 1 ELSE 0 END) AS chat_calls,
|
|
@@ -660,29 +686,36 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
660
686
|
COUNT(DISTINCT owner_email) AS active_users
|
|
661
687
|
FROM token_usage
|
|
662
688
|
WHERE ${usage.where}`,
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
689
|
+
usage.args,
|
|
690
|
+
),
|
|
691
|
+
usageBuckets(
|
|
692
|
+
`COALESCE(NULLIF(app, ''), 'unattributed')`,
|
|
693
|
+
usage.where,
|
|
694
|
+
usage.args,
|
|
695
|
+
20,
|
|
696
|
+
),
|
|
697
|
+
usageBuckets("owner_email", usage.where, usage.args, 50),
|
|
698
|
+
usageBuckets(
|
|
699
|
+
`COALESCE(NULLIF(label, ''), 'chat')`,
|
|
700
|
+
usage.where,
|
|
701
|
+
usage.args,
|
|
702
|
+
20,
|
|
703
|
+
),
|
|
704
|
+
usageBuckets(
|
|
705
|
+
`COALESCE(NULLIF(model, ''), 'unknown')`,
|
|
706
|
+
usage.where,
|
|
707
|
+
usage.args,
|
|
708
|
+
20,
|
|
709
|
+
),
|
|
710
|
+
loadChatStats(threads.where, threads.args),
|
|
711
|
+
queryRows<Record<string, unknown>>(
|
|
712
|
+
`SELECT owner_email, actor, target_id, created_at
|
|
713
|
+
FROM dispatch_audit_events
|
|
714
|
+
WHERE ${workspaceAppCreation.where}
|
|
715
|
+
ORDER BY created_at ASC`,
|
|
716
|
+
workspaceAppCreation.args,
|
|
717
|
+
),
|
|
718
|
+
]);
|
|
686
719
|
|
|
687
720
|
const topAppRows = await queryRows<Record<string, unknown>>(
|
|
688
721
|
`SELECT owner_email AS owner_email,
|
|
@@ -746,7 +779,8 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
746
779
|
}
|
|
747
780
|
|
|
748
781
|
const dayRows = await queryRows<Record<string, unknown>>(
|
|
749
|
-
`SELECT created_at, owner_email, label,
|
|
782
|
+
`SELECT created_at, owner_email, label, input_tokens, output_tokens,
|
|
783
|
+
cache_read_tokens, cache_write_tokens, cost_cents_x100
|
|
750
784
|
FROM token_usage
|
|
751
785
|
WHERE ${usage.where}
|
|
752
786
|
ORDER BY created_at ASC`,
|
|
@@ -756,10 +790,16 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
756
790
|
string,
|
|
757
791
|
{ costX100: number; calls: number; chatCalls: number; users: Set<string> }
|
|
758
792
|
>();
|
|
793
|
+
const monthlyByUserMap = new Map<
|
|
794
|
+
string,
|
|
795
|
+
Omit<MonthlyUserUsageMetric, "credits">
|
|
796
|
+
>();
|
|
759
797
|
for (const row of dayRows) {
|
|
760
798
|
const date = new Date(numberField(row, "created_at"))
|
|
761
799
|
.toISOString()
|
|
762
800
|
.slice(0, 10);
|
|
801
|
+
const month = date.slice(0, 7);
|
|
802
|
+
const ownerEmail = stringField(row, "owner_email");
|
|
763
803
|
const current = dailyMap.get(date) ?? {
|
|
764
804
|
costX100: 0,
|
|
765
805
|
calls: 0,
|
|
@@ -769,8 +809,29 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
769
809
|
current.costX100 += numberField(row, "cost_cents_x100");
|
|
770
810
|
current.calls += 1;
|
|
771
811
|
if (stringField(row, "label") === "chat") current.chatCalls += 1;
|
|
772
|
-
current.users.add(
|
|
812
|
+
current.users.add(ownerEmail);
|
|
773
813
|
dailyMap.set(date, current);
|
|
814
|
+
|
|
815
|
+
const monthlyKey = `${ownerEmail}\u0000${month}`;
|
|
816
|
+
const monthly = monthlyByUserMap.get(monthlyKey) ?? {
|
|
817
|
+
month,
|
|
818
|
+
ownerEmail,
|
|
819
|
+
costCents: 0,
|
|
820
|
+
calls: 0,
|
|
821
|
+
chatCalls: 0,
|
|
822
|
+
inputTokens: 0,
|
|
823
|
+
outputTokens: 0,
|
|
824
|
+
cacheReadTokens: 0,
|
|
825
|
+
cacheWriteTokens: 0,
|
|
826
|
+
};
|
|
827
|
+
monthly.costCents += numberField(row, "cost_cents_x100") / 100;
|
|
828
|
+
monthly.calls += 1;
|
|
829
|
+
if (stringField(row, "label") === "chat") monthly.chatCalls += 1;
|
|
830
|
+
monthly.inputTokens += numberField(row, "input_tokens");
|
|
831
|
+
monthly.outputTokens += numberField(row, "output_tokens");
|
|
832
|
+
monthly.cacheReadTokens += numberField(row, "cache_read_tokens");
|
|
833
|
+
monthly.cacheWriteTokens += numberField(row, "cache_write_tokens");
|
|
834
|
+
monthlyByUserMap.set(monthlyKey, monthly);
|
|
774
835
|
}
|
|
775
836
|
const daily = [...dailyMap.entries()]
|
|
776
837
|
.map(([date, value]) => ({
|
|
@@ -782,6 +843,51 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
782
843
|
}))
|
|
783
844
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
784
845
|
|
|
846
|
+
const monthlyByUser = [...monthlyByUserMap.values()]
|
|
847
|
+
.map((row) => ({
|
|
848
|
+
...row,
|
|
849
|
+
credits: builderCreditsFromCostCents(row.costCents),
|
|
850
|
+
}))
|
|
851
|
+
.sort(
|
|
852
|
+
(a, b) =>
|
|
853
|
+
a.month.localeCompare(b.month) ||
|
|
854
|
+
a.ownerEmail.localeCompare(b.ownerEmail),
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
const workspaceAppCreationMap = new Map<
|
|
858
|
+
string,
|
|
859
|
+
{ month: string; ownerEmail: string; count: number; appIds: Set<string> }
|
|
860
|
+
>();
|
|
861
|
+
for (const row of workspaceAppCreationRows) {
|
|
862
|
+
const month = new Date(numberField(row, "created_at"))
|
|
863
|
+
.toISOString()
|
|
864
|
+
.slice(0, 7);
|
|
865
|
+
const ownerEmail =
|
|
866
|
+
stringField(row, "owner_email") || stringField(row, "actor");
|
|
867
|
+
if (!ownerEmail) continue;
|
|
868
|
+
const key = `${ownerEmail}\u0000${month}`;
|
|
869
|
+
const current = workspaceAppCreationMap.get(key) ?? {
|
|
870
|
+
month,
|
|
871
|
+
ownerEmail,
|
|
872
|
+
count: 0,
|
|
873
|
+
appIds: new Set<string>(),
|
|
874
|
+
};
|
|
875
|
+
current.count += 1;
|
|
876
|
+
const appId = stringField(row, "target_id");
|
|
877
|
+
if (appId) current.appIds.add(appId);
|
|
878
|
+
workspaceAppCreationMap.set(key, current);
|
|
879
|
+
}
|
|
880
|
+
const workspaceAppCreationsByUserMonth = [...workspaceAppCreationMap.values()]
|
|
881
|
+
.map(({ appIds, ...row }) => ({
|
|
882
|
+
...row,
|
|
883
|
+
appIds: [...appIds].sort(),
|
|
884
|
+
}))
|
|
885
|
+
.sort(
|
|
886
|
+
(a, b) =>
|
|
887
|
+
a.month.localeCompare(b.month) ||
|
|
888
|
+
a.ownerEmail.localeCompare(b.ownerEmail),
|
|
889
|
+
);
|
|
890
|
+
|
|
785
891
|
const recentRows = await queryRows<Record<string, unknown>>(
|
|
786
892
|
`SELECT id, created_at, owner_email, app, label, model,
|
|
787
893
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
@@ -873,6 +979,8 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
873
979
|
byLabel,
|
|
874
980
|
byModel,
|
|
875
981
|
daily,
|
|
982
|
+
monthlyByUser,
|
|
983
|
+
workspaceAppCreationsByUserMonth,
|
|
876
984
|
appAccess,
|
|
877
985
|
recent,
|
|
878
986
|
};
|
|
@@ -27,10 +27,12 @@ describe("Dispatch agent chat plugin", () => {
|
|
|
27
27
|
initialToolNames: expect.arrayContaining([
|
|
28
28
|
"list-workspace-apps",
|
|
29
29
|
"update-workspace-app-metadata",
|
|
30
|
+
"list-dispatch-usage-metrics",
|
|
30
31
|
]),
|
|
31
32
|
mcp: {
|
|
32
33
|
connectorCatalog: expect.arrayContaining([
|
|
33
34
|
"start-workspace-app-creation",
|
|
35
|
+
"list-dispatch-usage-metrics",
|
|
34
36
|
]),
|
|
35
37
|
},
|
|
36
38
|
actionRoutePublicPaths: ["/_agent-native/actions/list-workspace-apps"],
|
|
@@ -28,6 +28,7 @@ const INITIAL_TOOL_NAMES = [
|
|
|
28
28
|
"upsert-destination",
|
|
29
29
|
"list-dreams",
|
|
30
30
|
"start-workspace-app-creation",
|
|
31
|
+
"list-dispatch-usage-metrics",
|
|
31
32
|
"list-available-workspace-templates",
|
|
32
33
|
"provider-api-catalog",
|
|
33
34
|
"provider-api-docs",
|
|
@@ -44,6 +45,7 @@ export default createAgentChatPlugin({
|
|
|
44
45
|
connectorCatalog: [
|
|
45
46
|
"resolve-integration-source-context",
|
|
46
47
|
"start-workspace-app-creation",
|
|
48
|
+
"list-dispatch-usage-metrics",
|
|
47
49
|
],
|
|
48
50
|
},
|
|
49
51
|
// Without this, AGENT_ORG_ID is never set on agent action calls and every
|
|
@@ -91,6 +93,7 @@ Use the standard workspace primitives:
|
|
|
91
93
|
- Treat first-party apps such as Mail, Calendar, Analytics, Brain, Assets, and Dispatch as existing hosted/connected neighbors available through links and A2A/default connected agents. Do not create wrapper apps, child apps, nested routes, or cloned template copies just to give a new app access to them; build only the genuinely new workflow and delegate cross-app work to those existing apps.
|
|
92
94
|
- Integration grants are not provider capability limits. For ad hoc provider inspection, querying, reporting, or troubleshooting, call provider-api-catalog/provider-api-docs, then provider-api-request against the provider's real HTTP API. Use connectionId for a specific shared grant and accountId for a specific OAuth account. Never expose secret values or silently widen app access while doing this.
|
|
93
95
|
- For broad provider searches, joins, classification, corpus counts, or absence claims, fetch every relevant page or an explicitly bounded cohort, stage/save large responses with stageAs/saveToFile/fetchAllPages, and reduce them with query-staged-dataset or run-code. Report source, filters, row counts, pagination, truncation, failed pages, and uncovered gaps.
|
|
96
|
+
- For Builder.io or AI credit spend, LLM usage by workspace member or month, or workspace app/Builder branch creation history, call list-dispatch-usage-metrics with scope=workspace and the requested sinceDays. Its monthlyByUser and workspaceAppCreationsByUserMonth fields are the authoritative shared-database result; do not ask for a user export or BigQuery schema.
|
|
94
97
|
|
|
95
98
|
When a user asks for something like a digest, reminder, routing rule, or saved behavior:
|
|
96
99
|
- First decide whether it should be a resource, a recurring job, a destination, or a delegated task.
|