@agent-native/dispatch 0.23.3 → 0.24.0
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-connected-agents.d.ts +1 -0
- package/dist/actions/list-connected-agents.js +5 -1
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list-dispatch-usage-metrics.d.ts +2 -0
- package/dist/actions/list-dispatch-usage-metrics.js +11 -1
- package/dist/actions/list-dispatch-usage-metrics.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +11 -11
- package/dist/actions/remix-workspace-template.js +4 -3
- package/dist/actions/remix-workspace-template.js.map +1 -1
- package/dist/actions/scaffold-workspace-app.js +3 -2
- package/dist/actions/scaffold-workspace-app.js.map +1 -1
- package/dist/actions/start-workspace-app-creation.js +3 -2
- package/dist/actions/start-workspace-app-creation.js.map +1 -1
- package/dist/actions/view-screen.js +11 -1
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/app-icon.js +1 -1
- package/dist/components/app-icon.js.map +1 -1
- package/dist/components/app-list-row.d.ts +1 -1
- package/dist/components/app-list-row.d.ts.map +1 -1
- package/dist/components/app-list-row.js +2 -2
- package/dist/components/app-list-row.js.map +1 -1
- package/dist/components/connected-app-card.d.ts.map +1 -1
- package/dist/components/connected-app-card.js +2 -1
- package/dist/components/connected-app-card.js.map +1 -1
- package/dist/components/create-app-popover.d.ts.map +1 -1
- package/dist/components/create-app-popover.js +11 -0
- package/dist/components/create-app-popover.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +3 -2
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +47 -10
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -1
- package/dist/components/layout/workspace-apps-rail.js +2 -2
- package/dist/components/layout/workspace-apps-rail.js.map +1 -1
- package/dist/components/other-apps-section.d.ts.map +1 -1
- package/dist/components/other-apps-section.js +5 -1
- package/dist/components/other-apps-section.js.map +1 -1
- package/dist/components/workspace-template-card.d.ts.map +1 -1
- package/dist/components/workspace-template-card.js +8 -14
- package/dist/components/workspace-template-card.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks/use-navigation-state.d.ts +2 -0
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +10 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/lib/embed-session-recovery.d.ts +3 -0
- package/dist/lib/embed-session-recovery.d.ts.map +1 -0
- package/dist/lib/embed-session-recovery.js +18 -0
- package/dist/lib/embed-session-recovery.js.map +1 -0
- package/dist/lib/other-apps.d.ts +2 -0
- package/dist/lib/other-apps.d.ts.map +1 -1
- package/dist/lib/other-apps.js +2 -0
- package/dist/lib/other-apps.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 +17 -0
- package/dist/lib/workspace-apps.js.map +1 -1
- package/dist/lib/workspace-apps.test.d.ts +2 -0
- package/dist/lib/workspace-apps.test.d.ts.map +1 -0
- package/dist/lib/workspace-apps.test.js +16 -0
- package/dist/lib/workspace-apps.test.js.map +1 -0
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +10 -11
- 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 +2 -1
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/chat.d.ts.map +1 -1
- package/dist/routes/pages/chat.js +7 -2
- package/dist/routes/pages/chat.js.map +1 -1
- package/dist/routes/pages/metrics.d.ts.map +1 -1
- package/dist/routes/pages/metrics.js +143 -17
- package/dist/routes/pages/metrics.js.map +1 -1
- package/dist/routes/pages/settings.d.ts.map +1 -1
- package/dist/routes/pages/settings.js +13 -1
- package/dist/routes/pages/settings.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts +17 -0
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +187 -30
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +2 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/dist/server/plugins/auth.d.ts.map +1 -1
- package/dist/server/plugins/auth.js +1 -0
- package/dist/server/plugins/auth.js.map +1 -1
- package/dist/server/plugins/integrations.d.ts.map +1 -1
- package/dist/server/plugins/integrations.js +1 -0
- package/dist/server/plugins/integrations.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/list-connected-agents.spec.ts +73 -0
- package/src/actions/list-connected-agents.ts +6 -2
- package/src/actions/list-dispatch-usage-metrics.ts +11 -1
- package/src/actions/remix-workspace-template.spec.ts +29 -0
- package/src/actions/remix-workspace-template.ts +13 -6
- package/src/actions/scaffold-workspace-app.ts +12 -5
- package/src/actions/start-workspace-app-creation.ts +8 -2
- package/src/actions/view-screen.ts +13 -1
- package/src/components/app-icon.spec.tsx +34 -0
- package/src/components/app-icon.tsx +1 -1
- package/src/components/app-list-row.tsx +3 -2
- package/src/components/connected-app-card.tsx +3 -1
- package/src/components/create-app-popover.spec.tsx +26 -2
- package/src/components/create-app-popover.tsx +10 -0
- package/src/components/dispatch-control-plane.spec.tsx +9 -1
- package/src/components/dispatch-control-plane.tsx +8 -2
- package/src/components/layout/Layout.spec.tsx +12 -0
- package/src/components/layout/Layout.tsx +84 -13
- package/src/components/layout/workspace-apps-rail.tsx +2 -1
- package/src/components/other-apps-section.tsx +8 -1
- package/src/components/workspace-template-card.spec.tsx +1 -1
- package/src/components/workspace-template-card.tsx +9 -16
- package/src/config.ts +2 -0
- package/src/hooks/use-navigation-state.ts +12 -0
- package/src/lib/embed-session-recovery.spec.ts +63 -0
- package/src/lib/embed-session-recovery.ts +26 -0
- package/src/lib/other-apps.spec.ts +26 -0
- package/src/lib/other-apps.ts +5 -0
- package/src/lib/workspace-apps.spec.ts +24 -1
- package/src/lib/workspace-apps.test.ts +20 -0
- package/src/lib/workspace-apps.ts +25 -0
- package/src/routes/pages/apps.$appId.tsx +10 -11
- package/src/routes/pages/apps.tsx +5 -2
- package/src/routes/pages/chat.spec.tsx +10 -0
- package/src/routes/pages/chat.tsx +8 -0
- package/src/routes/pages/metrics.tsx +452 -102
- package/src/routes/pages/settings.tsx +13 -1
- package/src/server/lib/usage-metrics-store.spec.ts +117 -0
- package/src/server/lib/usage-metrics-store.ts +249 -30
- package/src/server/plugins/agent-chat.spec.ts +4 -0
- package/src/server/plugins/agent-chat.ts +2 -0
- package/src/server/plugins/auth.spec.ts +45 -0
- package/src/server/plugins/auth.ts +1 -0
- package/src/server/plugins/integrations.ts +1 -0
- package/src/styles/dispatch.css +9 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
SettingsTabsPage,
|
|
11
11
|
useAgentSettingsTabs,
|
|
12
12
|
} from "@agent-native/core/client/settings";
|
|
13
|
+
import { IconShield } from "@tabler/icons-react";
|
|
13
14
|
import { useState } from "react";
|
|
14
15
|
import { Link } from "react-router";
|
|
15
16
|
|
|
@@ -33,6 +34,17 @@ export function meta() {
|
|
|
33
34
|
export default function SettingsRoute() {
|
|
34
35
|
const t = useT();
|
|
35
36
|
const agentSettingsTabs = useAgentSettingsTabs();
|
|
37
|
+
const settingsTabs = [
|
|
38
|
+
...agentSettingsTabs.filter((tab) => tab.id !== "integrations"),
|
|
39
|
+
{
|
|
40
|
+
id: "admin",
|
|
41
|
+
label: t("dispatch.nav.admin", { defaultValue: "Admin" }),
|
|
42
|
+
icon: IconShield,
|
|
43
|
+
group: "Admin",
|
|
44
|
+
href: "/admin",
|
|
45
|
+
content: null,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
36
48
|
const [chatFirstModeState] = useState(() => readChatFirstModeState());
|
|
37
49
|
const [chatFirstMode, setChatFirstMode] = useState(
|
|
38
50
|
() => chatFirstModeState.enabled,
|
|
@@ -68,7 +80,7 @@ export default function SettingsRoute() {
|
|
|
68
80
|
description={t("settings.description")}
|
|
69
81
|
>
|
|
70
82
|
<SettingsTabsPage
|
|
71
|
-
extraTabs={
|
|
83
|
+
extraTabs={settingsTabs}
|
|
72
84
|
general={
|
|
73
85
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
|
74
86
|
<Card>
|
|
@@ -120,4 +120,121 @@ describe("listDispatchUsageMetrics", () => {
|
|
|
120
120
|
expect(metrics.recent).toEqual([]);
|
|
121
121
|
expect(metrics.appAccess).toHaveLength(1);
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it("allows personal scope and hydrates a linked thread prompt", async () => {
|
|
125
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
126
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
127
|
+
mocks.getUsageSummary.mockResolvedValue(null);
|
|
128
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
129
|
+
mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
|
|
130
|
+
if (sql.includes("SELECT id, created_at")) {
|
|
131
|
+
return {
|
|
132
|
+
rows: [
|
|
133
|
+
{
|
|
134
|
+
id: 7,
|
|
135
|
+
created_at: Date.now(),
|
|
136
|
+
owner_email: "owner@example.test",
|
|
137
|
+
app: "dispatch",
|
|
138
|
+
label: "chat",
|
|
139
|
+
model: "test-model",
|
|
140
|
+
input_tokens: 10,
|
|
141
|
+
output_tokens: 20,
|
|
142
|
+
cache_read_tokens: 0,
|
|
143
|
+
cache_write_tokens: 0,
|
|
144
|
+
cost_cents_x100: 250,
|
|
145
|
+
thread_id: "thread-1",
|
|
146
|
+
run_id: "run-1",
|
|
147
|
+
task_id: null,
|
|
148
|
+
source_platform: "dispatch",
|
|
149
|
+
source_id: "dispatch",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (sql.includes("FROM chat_threads WHERE id IN")) {
|
|
155
|
+
return {
|
|
156
|
+
rows: [
|
|
157
|
+
{
|
|
158
|
+
id: "thread-1",
|
|
159
|
+
preview: "Fallback preview",
|
|
160
|
+
thread_data: JSON.stringify({
|
|
161
|
+
messages: [
|
|
162
|
+
{
|
|
163
|
+
message: {
|
|
164
|
+
role: "user",
|
|
165
|
+
content: "Find the repeated background work.",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { rows: [] };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const metrics = await listDispatchUsageMetrics({
|
|
178
|
+
sinceDays: 7,
|
|
179
|
+
scope: "me",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(metrics.viewScope).toBe("me");
|
|
183
|
+
expect(metrics.recent[0]).toMatchObject({
|
|
184
|
+
prompt: "Find the repeated background work.",
|
|
185
|
+
promptSource: "thread",
|
|
186
|
+
threadId: "thread-1",
|
|
187
|
+
runId: "run-1",
|
|
188
|
+
});
|
|
189
|
+
expect(
|
|
190
|
+
mocks.execute.mock.calls.some(([query]) =>
|
|
191
|
+
String((query as { sql?: string }).sql).includes(
|
|
192
|
+
"LOWER(owner_email) = ?",
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("filters workspace usage and prompts to a selected member", async () => {
|
|
199
|
+
mocks.currentOrgId.mockReturnValue("org-a");
|
|
200
|
+
mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
|
|
201
|
+
mocks.getUsageSummary.mockResolvedValue(null);
|
|
202
|
+
mocks.listWorkspaceApps.mockResolvedValue([]);
|
|
203
|
+
mocks.execute.mockImplementation(async ({ sql }: { sql: string }) => {
|
|
204
|
+
if (sql.includes("SELECT role FROM org_members")) {
|
|
205
|
+
return { rows: [{ role: "owner" }] };
|
|
206
|
+
}
|
|
207
|
+
if (sql.includes("SELECT email, role, joined_at")) {
|
|
208
|
+
return {
|
|
209
|
+
rows: [
|
|
210
|
+
{ email: "owner@example.test", role: "owner", joined_at: null },
|
|
211
|
+
{ email: "member@example.test", role: "member", joined_at: null },
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return { rows: [] };
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const metrics = await listDispatchUsageMetrics({
|
|
219
|
+
sinceDays: 30,
|
|
220
|
+
scope: "workspace",
|
|
221
|
+
userEmail: "member@example.test",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(metrics.selectedUserEmail).toBe("member@example.test");
|
|
225
|
+
expect(metrics.availableUsers).toEqual([
|
|
226
|
+
{ email: "member@example.test", role: "member" },
|
|
227
|
+
{ email: "owner@example.test", role: "owner" },
|
|
228
|
+
]);
|
|
229
|
+
expect(
|
|
230
|
+
mocks.execute.mock.calls.some(([query]) => {
|
|
231
|
+
const sql = String((query as { sql?: string }).sql);
|
|
232
|
+
const args = (query as { args?: unknown[] }).args ?? [];
|
|
233
|
+
return (
|
|
234
|
+
sql.includes("LOWER(owner_email) = ?") &&
|
|
235
|
+
args.includes("member@example.test")
|
|
236
|
+
);
|
|
237
|
+
}),
|
|
238
|
+
).toBe(true);
|
|
239
|
+
});
|
|
123
240
|
});
|
|
@@ -85,10 +85,27 @@ export interface RecentUsageMetric {
|
|
|
85
85
|
cacheReadTokens: number;
|
|
86
86
|
cacheWriteTokens: number;
|
|
87
87
|
costCents: number;
|
|
88
|
+
prompt: string | null;
|
|
89
|
+
promptSource: "thread" | "thread-preview" | "not-captured" | "unavailable";
|
|
90
|
+
threadId: string | null;
|
|
91
|
+
runId: string | null;
|
|
92
|
+
taskId: string | null;
|
|
93
|
+
sourcePlatform: string | null;
|
|
94
|
+
sourceId: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UsageMetricsScope = "me" | "workspace";
|
|
98
|
+
|
|
99
|
+
export interface UsageUserOption {
|
|
100
|
+
email: string;
|
|
101
|
+
role: string | null;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
export interface DispatchUsageMetrics {
|
|
91
105
|
billing: UsageBillingMode;
|
|
106
|
+
viewScope: UsageMetricsScope;
|
|
107
|
+
selectedUserEmail: string | null;
|
|
108
|
+
availableUsers: UsageUserOption[];
|
|
92
109
|
sinceMs: number;
|
|
93
110
|
sinceDays: number;
|
|
94
111
|
generatedAt: number;
|
|
@@ -151,6 +168,148 @@ function nullableNumberField(
|
|
|
151
168
|
return Number.isFinite(numberValue) ? numberValue : null;
|
|
152
169
|
}
|
|
153
170
|
|
|
171
|
+
function nullableStringField(
|
|
172
|
+
row: Record<string, unknown>,
|
|
173
|
+
key: string,
|
|
174
|
+
): string | null {
|
|
175
|
+
const value = row[key];
|
|
176
|
+
if (typeof value !== "string") return null;
|
|
177
|
+
const trimmed = value.trim();
|
|
178
|
+
return trimmed || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type ParsedThreadData =
|
|
182
|
+
| { status: "absent"; value: null }
|
|
183
|
+
| { status: "invalid"; value: null }
|
|
184
|
+
| { status: "parsed"; value: Record<string, unknown> };
|
|
185
|
+
|
|
186
|
+
function parseJson(value: unknown): ParsedThreadData {
|
|
187
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
188
|
+
return { status: "absent", value: null };
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(value);
|
|
192
|
+
return parsed && typeof parsed === "object"
|
|
193
|
+
? { status: "parsed", value: parsed as Record<string, unknown> }
|
|
194
|
+
: { status: "invalid", value: null };
|
|
195
|
+
} catch {
|
|
196
|
+
return { status: "invalid", value: null };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function textFromPromptContent(value: unknown): string {
|
|
201
|
+
if (typeof value === "string") return value.trim();
|
|
202
|
+
if (!Array.isArray(value)) return "";
|
|
203
|
+
return value
|
|
204
|
+
.map((part) => {
|
|
205
|
+
if (!part || typeof part !== "object") return "";
|
|
206
|
+
const item = part as Record<string, unknown>;
|
|
207
|
+
return item.type === "text" && typeof item.text === "string"
|
|
208
|
+
? item.text.trim()
|
|
209
|
+
: "";
|
|
210
|
+
})
|
|
211
|
+
.filter(Boolean)
|
|
212
|
+
.join("\n")
|
|
213
|
+
.trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function truncatePrompt(value: string, maxLength = 360): string {
|
|
217
|
+
if (value.length <= maxLength) return value;
|
|
218
|
+
return `${value.slice(0, maxLength - 1).trimEnd()}…`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function firstUserPrompt(threadData: unknown): string | null {
|
|
222
|
+
const parsed = parseJson(threadData);
|
|
223
|
+
if (parsed.status !== "parsed") return null;
|
|
224
|
+
const messages = parsed.value.messages;
|
|
225
|
+
if (!Array.isArray(messages)) return null;
|
|
226
|
+
|
|
227
|
+
for (const entry of messages) {
|
|
228
|
+
if (!entry || typeof entry !== "object") continue;
|
|
229
|
+
const record = entry as Record<string, unknown>;
|
|
230
|
+
const message =
|
|
231
|
+
record.message && typeof record.message === "object"
|
|
232
|
+
? (record.message as Record<string, unknown>)
|
|
233
|
+
: record;
|
|
234
|
+
const role = typeof message.role === "string" ? message.role : "";
|
|
235
|
+
if (role !== "user" && role !== "human") continue;
|
|
236
|
+
const text = textFromPromptContent(message.content);
|
|
237
|
+
if (text) return truncatePrompt(text);
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
interface ThreadPromptRow {
|
|
243
|
+
id?: unknown;
|
|
244
|
+
preview?: unknown;
|
|
245
|
+
thread_data?: unknown;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function hydrateRecentPrompts(
|
|
249
|
+
rows: Array<Record<string, unknown>>,
|
|
250
|
+
): Promise<RecentUsageMetric[]> {
|
|
251
|
+
const threadIds = [
|
|
252
|
+
...new Set(
|
|
253
|
+
rows
|
|
254
|
+
.map((row) => nullableStringField(row, "thread_id"))
|
|
255
|
+
.filter((value): value is string => Boolean(value)),
|
|
256
|
+
),
|
|
257
|
+
];
|
|
258
|
+
const threads = new Map<string, ThreadPromptRow>();
|
|
259
|
+
let threadQueryUnavailable = false;
|
|
260
|
+
|
|
261
|
+
if (threadIds.length > 0) {
|
|
262
|
+
try {
|
|
263
|
+
const result = await getDbExec().execute({
|
|
264
|
+
sql: `SELECT id, preview, thread_data FROM chat_threads WHERE id IN (${threadIds.map(() => "?").join(", ")})`,
|
|
265
|
+
args: threadIds,
|
|
266
|
+
});
|
|
267
|
+
for (const row of result.rows as ThreadPromptRow[]) {
|
|
268
|
+
const id = typeof row.id === "string" ? row.id : "";
|
|
269
|
+
if (id) threads.set(id, row);
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
threadQueryUnavailable = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return rows.map((row) => {
|
|
277
|
+
const threadId = nullableStringField(row, "thread_id");
|
|
278
|
+
const thread = threadId ? threads.get(threadId) : undefined;
|
|
279
|
+
const prompt = thread ? firstUserPrompt(thread.thread_data) : null;
|
|
280
|
+
const preview =
|
|
281
|
+
typeof thread?.preview === "string" ? thread.preview.trim() : "";
|
|
282
|
+
const promptSource = prompt
|
|
283
|
+
? "thread"
|
|
284
|
+
: preview
|
|
285
|
+
? "thread-preview"
|
|
286
|
+
: threadQueryUnavailable && threadId
|
|
287
|
+
? "unavailable"
|
|
288
|
+
: "not-captured";
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
id: numberField(row, "id"),
|
|
292
|
+
createdAt: numberField(row, "created_at"),
|
|
293
|
+
ownerEmail: stringField(row, "owner_email"),
|
|
294
|
+
app: stringField(row, "app") || "unattributed",
|
|
295
|
+
label: stringField(row, "label") || "chat",
|
|
296
|
+
model: stringField(row, "model") || "unknown",
|
|
297
|
+
inputTokens: numberField(row, "input_tokens"),
|
|
298
|
+
outputTokens: numberField(row, "output_tokens"),
|
|
299
|
+
cacheReadTokens: numberField(row, "cache_read_tokens"),
|
|
300
|
+
cacheWriteTokens: numberField(row, "cache_write_tokens"),
|
|
301
|
+
costCents: numberField(row, "cost_cents_x100") / 100,
|
|
302
|
+
prompt: prompt ?? (preview ? truncatePrompt(preview) : null),
|
|
303
|
+
promptSource,
|
|
304
|
+
threadId,
|
|
305
|
+
runId: nullableStringField(row, "run_id"),
|
|
306
|
+
taskId: nullableStringField(row, "task_id"),
|
|
307
|
+
sourcePlatform: nullableStringField(row, "source_platform"),
|
|
308
|
+
sourceId: nullableStringField(row, "source_id"),
|
|
309
|
+
} satisfies RecentUsageMetric;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
154
313
|
function labelForKey(value: string): string {
|
|
155
314
|
const trimmed = value.trim();
|
|
156
315
|
return trimmed || "Unattributed";
|
|
@@ -293,8 +452,8 @@ function usageScope(
|
|
|
293
452
|
}
|
|
294
453
|
const placeholders = memberEmails.map(() => "?").join(", ");
|
|
295
454
|
return {
|
|
296
|
-
where: `created_at >= ? AND owner_email IN (${placeholders})`,
|
|
297
|
-
args: [sinceMs, ...memberEmails],
|
|
455
|
+
where: `created_at >= ? AND LOWER(owner_email) IN (${placeholders})`,
|
|
456
|
+
args: [sinceMs, ...memberEmails.map((email) => email.toLowerCase())],
|
|
298
457
|
};
|
|
299
458
|
}
|
|
300
459
|
|
|
@@ -307,8 +466,34 @@ function threadScope(
|
|
|
307
466
|
}
|
|
308
467
|
const placeholders = memberEmails.map(() => "?").join(", ");
|
|
309
468
|
return {
|
|
310
|
-
where: `updated_at >= ? AND owner_email IN (${placeholders})`,
|
|
311
|
-
args: [sinceMs, ...memberEmails],
|
|
469
|
+
where: `updated_at >= ? AND LOWER(owner_email) IN (${placeholders})`,
|
|
470
|
+
args: [sinceMs, ...memberEmails.map((email) => email.toLowerCase())],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function ownerScope(
|
|
475
|
+
sinceMs: number,
|
|
476
|
+
ownerEmail: string,
|
|
477
|
+
): {
|
|
478
|
+
where: string;
|
|
479
|
+
args: unknown[];
|
|
480
|
+
} {
|
|
481
|
+
return {
|
|
482
|
+
where: "created_at >= ? AND LOWER(owner_email) = ?",
|
|
483
|
+
args: [sinceMs, ownerEmail.toLowerCase()],
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function ownerThreadScope(
|
|
488
|
+
sinceMs: number,
|
|
489
|
+
ownerEmail: string,
|
|
490
|
+
): {
|
|
491
|
+
where: string;
|
|
492
|
+
args: unknown[];
|
|
493
|
+
} {
|
|
494
|
+
return {
|
|
495
|
+
where: "updated_at >= ? AND LOWER(owner_email) = ?",
|
|
496
|
+
args: [sinceMs, ownerEmail.toLowerCase()],
|
|
312
497
|
};
|
|
313
498
|
}
|
|
314
499
|
|
|
@@ -382,7 +567,7 @@ async function loadChatStats(
|
|
|
382
567
|
);
|
|
383
568
|
}
|
|
384
569
|
|
|
385
|
-
async function assertCanViewMetrics(): Promise<{
|
|
570
|
+
async function assertCanViewMetrics(viewScope: UsageMetricsScope): Promise<{
|
|
386
571
|
viewerEmail: string;
|
|
387
572
|
orgId: string | null;
|
|
388
573
|
role: string | null;
|
|
@@ -390,7 +575,12 @@ async function assertCanViewMetrics(): Promise<{
|
|
|
390
575
|
const viewerEmail = currentOwnerEmail();
|
|
391
576
|
const orgId = currentOrgId();
|
|
392
577
|
const role = await getViewerOrgRole(orgId, viewerEmail);
|
|
393
|
-
if (
|
|
578
|
+
if (
|
|
579
|
+
viewScope === "me" ||
|
|
580
|
+
isEnvAdmin(viewerEmail) ||
|
|
581
|
+
role === "owner" ||
|
|
582
|
+
role === "admin"
|
|
583
|
+
) {
|
|
394
584
|
return { viewerEmail, orgId, role };
|
|
395
585
|
}
|
|
396
586
|
if (!orgId) {
|
|
@@ -403,27 +593,57 @@ async function assertCanViewMetrics(): Promise<{
|
|
|
403
593
|
|
|
404
594
|
export async function listDispatchUsageMetrics(input: {
|
|
405
595
|
sinceDays?: number;
|
|
596
|
+
scope?: UsageMetricsScope;
|
|
597
|
+
userEmail?: string | null;
|
|
406
598
|
}): Promise<DispatchUsageMetrics> {
|
|
407
|
-
const
|
|
599
|
+
const viewScope: UsageMetricsScope =
|
|
600
|
+
input.scope === "me" ? "me" : "workspace";
|
|
601
|
+
const { viewerEmail, orgId, role } = await assertCanViewMetrics(viewScope);
|
|
408
602
|
const sinceDays = Math.max(1, Math.min(365, input.sinceDays ?? 30));
|
|
409
603
|
const sinceMs = Date.now() - sinceDays * DAY_MS;
|
|
410
604
|
const billing = usageBillingForEngine(await detectUsageEngineName());
|
|
411
605
|
|
|
412
606
|
await initializeUsageMetricsTable(sinceMs);
|
|
413
607
|
|
|
414
|
-
const rawMembers =
|
|
415
|
-
|
|
416
|
-
|
|
608
|
+
const rawMembers =
|
|
609
|
+
viewScope === "me"
|
|
610
|
+
? [{ email: viewerEmail, role, joinedAt: null }]
|
|
611
|
+
: orgId
|
|
612
|
+
? await listOrgMembers(orgId)
|
|
613
|
+
: await listSignedInUsers();
|
|
417
614
|
const members =
|
|
418
|
-
orgId && rawMembers.length === 0
|
|
615
|
+
viewScope === "workspace" && orgId && rawMembers.length === 0
|
|
419
616
|
? [{ email: viewerEmail, role, joinedAt: null }]
|
|
420
617
|
: rawMembers;
|
|
421
|
-
const
|
|
618
|
+
const requestedUserEmail = input.userEmail?.trim() || null;
|
|
619
|
+
const selectedUserEmail =
|
|
620
|
+
viewScope === "me"
|
|
621
|
+
? viewerEmail
|
|
622
|
+
: requestedUserEmail
|
|
623
|
+
? (members.find(
|
|
624
|
+
(member) =>
|
|
625
|
+
member.email.toLowerCase() === requestedUserEmail.toLowerCase(),
|
|
626
|
+
)?.email ?? null)
|
|
627
|
+
: null;
|
|
628
|
+
if (viewScope === "workspace" && requestedUserEmail && !selectedUserEmail) {
|
|
629
|
+
throw new ForbiddenError(
|
|
630
|
+
"The selected user is not available in this workspace.",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const memberEmails = selectedUserEmail
|
|
634
|
+
? [selectedUserEmail]
|
|
635
|
+
: orgId
|
|
636
|
+
? members.map((member) => member.email)
|
|
637
|
+
: [];
|
|
422
638
|
const memberByEmail = new Map(
|
|
423
639
|
members.map((member) => [member.email.toLowerCase(), member]),
|
|
424
640
|
);
|
|
425
|
-
const usage =
|
|
426
|
-
|
|
641
|
+
const usage = selectedUserEmail
|
|
642
|
+
? ownerScope(sinceMs, selectedUserEmail)
|
|
643
|
+
: usageScope(sinceMs, memberEmails);
|
|
644
|
+
const threads = selectedUserEmail
|
|
645
|
+
? ownerThreadScope(sinceMs, selectedUserEmail)
|
|
646
|
+
: threadScope(sinceMs, memberEmails);
|
|
427
647
|
|
|
428
648
|
const [apps, totalsRows, byApp, byUserBase, byLabel, byModel, chatStats] =
|
|
429
649
|
await Promise.all([
|
|
@@ -565,33 +785,27 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
565
785
|
const recentRows = await queryRows<Record<string, unknown>>(
|
|
566
786
|
`SELECT id, created_at, owner_email, app, label, model,
|
|
567
787
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
568
|
-
cost_cents_x100
|
|
788
|
+
cost_cents_x100, thread_id, run_id, task_id, source_platform, source_id
|
|
569
789
|
FROM token_usage
|
|
570
790
|
WHERE ${usage.where}
|
|
571
791
|
ORDER BY created_at DESC
|
|
572
792
|
LIMIT 50`,
|
|
573
793
|
usage.args,
|
|
574
794
|
);
|
|
575
|
-
const recent = recentRows
|
|
576
|
-
id: numberField(row, "id"),
|
|
577
|
-
createdAt: numberField(row, "created_at"),
|
|
578
|
-
ownerEmail: stringField(row, "owner_email"),
|
|
579
|
-
app: stringField(row, "app") || "unattributed",
|
|
580
|
-
label: stringField(row, "label") || "chat",
|
|
581
|
-
model: stringField(row, "model") || "unknown",
|
|
582
|
-
inputTokens: numberField(row, "input_tokens"),
|
|
583
|
-
outputTokens: numberField(row, "output_tokens"),
|
|
584
|
-
cacheReadTokens: numberField(row, "cache_read_tokens"),
|
|
585
|
-
cacheWriteTokens: numberField(row, "cache_write_tokens"),
|
|
586
|
-
costCents: numberField(row, "cost_cents_x100") / 100,
|
|
587
|
-
}));
|
|
795
|
+
const recent = await hydrateRecentPrompts(recentRows);
|
|
588
796
|
|
|
589
797
|
const appUsageByKey = new Map(
|
|
590
798
|
byApp.map((bucket) => [normalizeAppKey(bucket.key), bucket]),
|
|
591
799
|
);
|
|
592
800
|
const accessUsers = members.length || byUserMap.size;
|
|
593
|
-
const accessModel =
|
|
594
|
-
|
|
801
|
+
const accessModel =
|
|
802
|
+
viewScope === "me" ? "solo" : orgId ? "workspace" : "solo";
|
|
803
|
+
const accessLabel =
|
|
804
|
+
viewScope === "me"
|
|
805
|
+
? "Your account"
|
|
806
|
+
: orgId
|
|
807
|
+
? "Workspace members"
|
|
808
|
+
: "Signed-in users";
|
|
595
809
|
const appAccess = apps.map((app) => {
|
|
596
810
|
const usageBucket = appUsageByKey.get(normalizeAppKey(app.id));
|
|
597
811
|
return {
|
|
@@ -623,6 +837,11 @@ export async function listDispatchUsageMetrics(input: {
|
|
|
623
837
|
|
|
624
838
|
return {
|
|
625
839
|
billing,
|
|
840
|
+
viewScope,
|
|
841
|
+
selectedUserEmail,
|
|
842
|
+
availableUsers: members
|
|
843
|
+
.map(({ email, role }) => ({ email, role }))
|
|
844
|
+
.sort((a, b) => a.email.localeCompare(b.email)),
|
|
626
845
|
sinceMs,
|
|
627
846
|
sinceDays,
|
|
628
847
|
generatedAt: Date.now(),
|
|
@@ -24,6 +24,10 @@ describe("Dispatch agent chat plugin", () => {
|
|
|
24
24
|
expect.objectContaining({
|
|
25
25
|
appId: "dispatch",
|
|
26
26
|
durableBackgroundRuns: true,
|
|
27
|
+
initialToolNames: expect.arrayContaining([
|
|
28
|
+
"list-workspace-apps",
|
|
29
|
+
"update-workspace-app-metadata",
|
|
30
|
+
]),
|
|
27
31
|
mcp: {
|
|
28
32
|
connectorCatalog: expect.arrayContaining([
|
|
29
33
|
"start-workspace-app-creation",
|
|
@@ -6,6 +6,7 @@ import { dispatchActions } from "../../actions/index.js";
|
|
|
6
6
|
const INITIAL_TOOL_NAMES = [
|
|
7
7
|
"view-screen",
|
|
8
8
|
"list-workspace-apps",
|
|
9
|
+
"update-workspace-app-metadata",
|
|
9
10
|
"list-connected-agents",
|
|
10
11
|
"ask_app",
|
|
11
12
|
"open_app",
|
|
@@ -65,6 +66,7 @@ Use the standard workspace primitives:
|
|
|
65
66
|
- Hosted/connected A2A neighbors such as Analytics and Content come from the available-apps context or list-connected-agents. list-workspace-apps only inventories apps mounted inside this workspace deployment; never use a missing row there to conclude that a connected agent is unavailable.
|
|
66
67
|
- When answering whether a mounted workspace app exposes an agent card or A2A endpoint, call list-workspace-apps with includeAgentCards=true. If you have not requested that probe, absence of agent-card fields means unchecked, not unavailable.
|
|
67
68
|
- When creating a new workspace app, create a separate app under apps/<app-id> with apps/<app-id>/package.json including a concise generated description, mount it at /<app-id>, use relative /<app-id> links, never hardcode localhost or dev ports, use shadcn/ui with @tabler/icons-react rather than lucide-react, and ensure the React Router client entry preserves APP_BASE_PATH/VITE_APP_BASE_PATH via appBasePath(). There is no separate workspace app registry to edit.
|
|
69
|
+
- When a user asks to rename an existing workspace app or change its Dispatch title/description, call update-workspace-app-metadata with the existing appId from list-workspace-apps. This is a metadata-only edit — never call start-workspace-app-creation, which creates a new app and Builder branch.
|
|
68
70
|
- When an explicit app-creation request already includes a source brief or a concrete repeatable workflow, call start-workspace-app-creation without asking non-blocking product or UX questions. Choose recommended defaults, let the Builder handoff record assumptions, and ask only for authorization, credentials, a destructive action, an ambiguous target workspace, or a genuinely missing workflow.
|
|
69
71
|
- If the chat template is used, treat it as scaffolding only: the finished app must be branded as the requested app with its own home screen/navigation/package metadata/manifest, and must not leave visible "Chat", "Starter", "Blank app", or "New app" UI behind.
|
|
70
72
|
- 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.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
authPlugin: vi.fn(),
|
|
5
|
+
createAuthPlugin: vi.fn(),
|
|
6
|
+
getDispatchConfig: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
10
|
+
createAuthPlugin: mocks.createAuthPlugin,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("../index.js", () => ({
|
|
14
|
+
getDispatchConfig: mocks.getDispatchConfig,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
describe("dispatchAuthPlugin", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
mocks.authPlugin.mockReset();
|
|
21
|
+
mocks.createAuthPlugin.mockReset();
|
|
22
|
+
mocks.createAuthPlugin.mockReturnValue(mocks.authPlugin);
|
|
23
|
+
mocks.getDispatchConfig.mockReset();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("installs template public routes on the primary auth guard", async () => {
|
|
27
|
+
const { default: dispatchAuthPlugin } = await import("./auth.js");
|
|
28
|
+
const nitroApp = {};
|
|
29
|
+
const publicPaths = [
|
|
30
|
+
"/_agent-native/identity/authorize",
|
|
31
|
+
"/_agent-native/org/apps",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
mocks.getDispatchConfig.mockReturnValue({
|
|
35
|
+
auth: { googleOnly: true, publicPaths },
|
|
36
|
+
});
|
|
37
|
+
await dispatchAuthPlugin(nitroApp);
|
|
38
|
+
|
|
39
|
+
expect(mocks.createAuthPlugin).toHaveBeenCalledOnce();
|
|
40
|
+
expect(mocks.createAuthPlugin).toHaveBeenCalledWith(
|
|
41
|
+
expect.objectContaining({ googleOnly: true, publicPaths }),
|
|
42
|
+
);
|
|
43
|
+
expect(mocks.authPlugin).toHaveBeenCalledWith(nitroApp);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -40,6 +40,7 @@ When a user asks for something:
|
|
|
40
40
|
- After call-agent returns an answer, RELAY IT DIRECTLY to the user with at most a one-line preface — do not rephrase, summarize, or add commentary. The downstream agent already crafted the answer; your job is delivery, not editing. This minimizes round-trips and keeps the user-visible reply fast.
|
|
41
41
|
- Exception: if the downstream agent reports a missing model/provider credential, do not name exact env vars, Vault keys, tokens, or secrets. Say the target app needs an LLM connection and recommend connecting Builder/managed LLM for that app; keep bring-your-own provider keys as a secondary option only if the user asks.
|
|
42
42
|
- If the user asks to create, build, make, scaffold, or generate an "agent" from Dispatch chat or by tagging @agent-native in Slack, email, or Telegram, first classify the ask. If it is a simple Dispatch-native behavior like a reminder, digest, monitor, routing rule, saved instruction, or recurring workflow, create or update the recurring job/resource/destination in Dispatch. If it is a robust unique product or teammate that needs its own UI, data model, actions, integrations, or domain workflow, treat it as a new workspace app and call start-workspace-app-creation.
|
|
43
|
+
- If a user asks to rename an existing workspace app or change its Dispatch title/description, call update-workspace-app-metadata with the existing appId from list-workspace-apps. Do not call start-workspace-app-creation for a metadata-only edit because that starts a new app and Builder branch.
|
|
43
44
|
- If a new-app prompt asks for access to Mail, Calendar, Analytics, Brain, Assets, or similar first-party app data/agents, keep using the existing hosted/connected app and A2A path. Do not ask Builder to scaffold those apps as children of the new app unless the user explicitly asks for a customized app from that template.
|
|
44
45
|
- If the chat template is used, treat it as scaffolding only: the finished app must be branded as the requested app with its own home screen/navigation/package metadata/manifest, and must not leave visible "Chat", "Starter", "Blank app", or "New app" UI behind.
|
|
45
46
|
- If the user explicitly asks for a new app or workspace app, call start-workspace-app-creation with their prompt and include a concise generated description by default. Do not satisfy a new-app request by adding a route, page, component, or file inside apps/chat or another existing app unless the user explicitly asks to modify that existing app. When the prompt contains a source brief or concrete repeatable workflow, choose recommended defaults and do not ask non-blocking product or UX questions; ask only for authorization, credentials, a destructive action, an ambiguous target workspace, or a genuinely missing workflow. If the request is too vague to classify, ask one concise follow-up. If the action returns mode "builder", reply with the Builder branch URL; Builder is responsible for creating the separate workspace app under apps/<app-id>, mounting it at /<app-id>, ensuring apps/<app-id>/package.json exists with name/displayName and description so Dispatch discovers it, using relative /<app-id> links instead of hardcoded localhost/dev ports, and preserving APP_BASE_PATH/VITE_APP_BASE_PATH in the React Router client entry. The new app lives at the workspace root /<app-id>, NOT under /dispatch/<app-id>, /apps/<app-id>, or any other Dispatch tab — when telling the user where to find it, link to /<app-id> only. There is no separate workspace app registry to edit. If it returns mode "local-agent", tell the user it is ready for the local code agent and include the returned app path/prompt summary. Dispatch reuses the connected workspace Builder project or provisions it through the Builder Projects API when an organization owner/admin starts the first app; members need an owner/admin to configure the project first. Do not ask the user to use Builder org/beta settings. If it returns mode "builder-unavailable", the action also returns a \`reason\` code plus a user-facing \`message\` and (for some reasons) an operator-facing \`detail\`. Relay the \`message\` to the user as-is; only surface \`detail\` when the user is clearly an operator debugging the deployment (e.g. troubleshooting a broken Builder connection for the workspace), never as routine detail for an end user.
|
package/src/styles/dispatch.css
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
@source "../routes/**/*.{js,mjs,ts,tsx}";
|
|
10
10
|
@source "../../../core/src/client/chat-first/**/*.{ts,tsx}";
|
|
11
11
|
|
|
12
|
+
/* Keep Dispatch data visualizations aligned with Analytics' blue chart accent. */
|
|
13
|
+
:root {
|
|
14
|
+
--dispatch-brand-blue: 200 97% 39%;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.dark {
|
|
18
|
+
--dispatch-brand-blue: 197 100% 50%;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
.dispatch-chat-first-chats .an-chat-history-rail__new-chat {
|
|
13
22
|
display: none;
|
|
14
23
|
}
|