@agent-native/dispatch 0.23.5 → 0.24.2
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 +12 -12
- 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/admin-navigation.d.ts.map +1 -1
- package/dist/components/admin-navigation.js +21 -5
- package/dist/components/admin-navigation.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 +1 -1
- package/dist/components/app-list-row.js.map +1 -1
- package/dist/components/app-open-actions.d.ts +1 -17
- package/dist/components/app-open-actions.d.ts.map +1 -1
- package/dist/components/app-open-actions.js +1 -20
- package/dist/components/app-open-actions.js.map +1 -1
- package/dist/components/app-row-settings.d.ts +6 -0
- package/dist/components/app-row-settings.d.ts.map +1 -0
- package/dist/components/app-row-settings.js +11 -0
- package/dist/components/app-row-settings.js.map +1 -0
- package/dist/components/automation-run-history-dialog.d.ts.map +1 -1
- package/dist/components/automation-run-history-dialog.js +3 -2
- package/dist/components/automation-run-history-dialog.js.map +1 -1
- package/dist/components/connected-app-card.d.ts.map +1 -1
- package/dist/components/connected-app-card.js +3 -2
- package/dist/components/connected-app-card.js.map +1 -1
- package/dist/components/create-app-popover.js +2 -2
- 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 +23 -7
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/dispatch-shell.d.ts.map +1 -1
- package/dist/components/dispatch-shell.js +5 -1
- package/dist/components/dispatch-shell.js.map +1 -1
- package/dist/components/docs-link.d.ts +16 -0
- package/dist/components/docs-link.d.ts.map +1 -0
- package/dist/components/docs-link.js +34 -0
- package/dist/components/docs-link.js.map +1 -0
- 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 +43 -35
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/messaging-setup-panel.d.ts.map +1 -1
- package/dist/components/messaging-setup-panel.js +3 -2
- package/dist/components/messaging-setup-panel.js.map +1 -1
- package/dist/components/usage-alerts-panel.d.ts +11 -0
- package/dist/components/usage-alerts-panel.d.ts.map +1 -0
- package/dist/components/usage-alerts-panel.js +125 -0
- package/dist/components/usage-alerts-panel.js.map +1 -0
- package/dist/components/workspace-app-card.d.ts +3 -1
- package/dist/components/workspace-app-card.d.ts.map +1 -1
- package/dist/components/workspace-app-card.js +70 -29
- package/dist/components/workspace-app-card.js.map +1 -1
- package/dist/components/workspace-app-search.d.ts +10 -0
- package/dist/components/workspace-app-search.d.ts.map +1 -0
- package/dist/components/workspace-app-search.js +27 -0
- package/dist/components/workspace-app-search.js.map +1 -0
- package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
- package/dist/components/workspace-resource-effective-stack.js +3 -2
- package/dist/components/workspace-resource-effective-stack.js.map +1 -1
- package/dist/components/workspace-template-card.d.ts.map +1 -1
- package/dist/components/workspace-template-card.js +10 -15
- package/dist/components/workspace-template-card.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.map +1 -1
- package/dist/lib/workspace-app-layout.d.ts +19 -0
- package/dist/lib/workspace-app-layout.d.ts.map +1 -0
- package/dist/lib/workspace-app-layout.js +75 -0
- package/dist/lib/workspace-app-layout.js.map +1 -0
- package/dist/lib/workspace-apps.d.ts +14 -0
- package/dist/lib/workspace-apps.d.ts.map +1 -1
- package/dist/lib/workspace-apps.js +66 -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 +33 -0
- package/dist/lib/workspace-apps.test.js.map +1 -0
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +4 -2
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/agents.d.ts.map +1 -1
- package/dist/routes/pages/agents.js +2 -3
- package/dist/routes/pages/agents.js.map +1 -1
- package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
- package/dist/routes/pages/apps.$appId.js +17 -13
- 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 +31 -16
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/routes/pages/audit.d.ts.map +1 -1
- package/dist/routes/pages/audit.js +2 -1
- package/dist/routes/pages/audit.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/dreams.d.ts.map +1 -1
- package/dist/routes/pages/dreams.js +14 -18
- package/dist/routes/pages/dreams.js.map +1 -1
- package/dist/routes/pages/integrations.d.ts.map +1 -1
- package/dist/routes/pages/integrations.js +51 -14
- package/dist/routes/pages/integrations.js.map +1 -1
- package/dist/routes/pages/metrics.d.ts.map +1 -1
- package/dist/routes/pages/metrics.js +146 -17
- package/dist/routes/pages/metrics.js.map +1 -1
- package/dist/routes/pages/operations.js +1 -1
- package/dist/routes/pages/operations.js.map +1 -1
- package/dist/routes/pages/settings.d.ts.map +1 -1
- package/dist/routes/pages/settings.js +19 -2
- package/dist/routes/pages/settings.js.map +1 -1
- package/dist/routes/pages/thread-debug.d.ts.map +1 -1
- package/dist/routes/pages/thread-debug.js +5 -8
- package/dist/routes/pages/thread-debug.js.map +1 -1
- package/dist/routes/pages/vault.d.ts.map +1 -1
- package/dist/routes/pages/vault.js +1 -1
- package/dist/routes/pages/vault.js.map +1 -1
- package/dist/routes/pages/workspace.d.ts.map +1 -1
- package/dist/routes/pages/workspace.js +13 -10
- package/dist/routes/pages/workspace.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +12 -52
- package/dist/server/lib/app-creation-store.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/integrations.d.ts.map +1 -1
- package/dist/server/plugins/integrations.js +1 -0
- package/dist/server/plugins/integrations.js.map +1 -1
- package/dist/shared/app-roles.d.ts +9 -0
- package/dist/shared/app-roles.d.ts.map +1 -0
- package/dist/shared/app-roles.js +7 -0
- package/dist/shared/app-roles.js.map +1 -0
- package/package.json +4 -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/admin-navigation.tsx +57 -11
- package/src/components/app-icon.spec.tsx +34 -0
- package/src/components/app-icon.tsx +1 -1
- package/src/components/app-list-row.tsx +1 -2
- package/src/components/app-open-actions.tsx +5 -114
- package/src/components/app-row-settings.tsx +54 -0
- package/src/components/automation-run-history-dialog.tsx +8 -4
- package/src/components/connected-app-card.tsx +3 -2
- package/src/components/create-app-popover.spec.tsx +1 -1
- package/src/components/create-app-popover.tsx +2 -2
- package/src/components/dispatch-control-plane.spec.tsx +44 -3
- package/src/components/dispatch-control-plane.tsx +83 -27
- package/src/components/dispatch-shell.tsx +7 -1
- package/src/components/docs-link.tsx +52 -0
- package/src/components/layout/Layout.spec.tsx +13 -0
- package/src/components/layout/Layout.tsx +63 -32
- package/src/components/messaging-setup-panel.tsx +10 -3
- package/src/components/usage-alerts-panel.tsx +445 -0
- package/src/components/workspace-app-card.spec.tsx +130 -9
- package/src/components/workspace-app-card.tsx +128 -71
- package/src/components/workspace-app-search.tsx +96 -0
- package/src/components/workspace-resource-effective-stack.tsx +10 -5
- package/src/components/workspace-template-card.spec.tsx +53 -24
- package/src/components/workspace-template-card.tsx +16 -26
- 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.ts +2 -0
- package/src/lib/workspace-app-layout.spec.ts +47 -0
- package/src/lib/workspace-app-layout.ts +121 -0
- package/src/lib/workspace-apps.test.ts +64 -0
- package/src/lib/workspace-apps.ts +82 -0
- package/src/routes/pages/$appId.tsx +3 -6
- package/src/routes/pages/agents.tsx +6 -3
- package/src/routes/pages/apps.$appId.tsx +16 -17
- package/src/routes/pages/apps.tsx +116 -62
- package/src/routes/pages/audit.tsx +28 -8
- package/src/routes/pages/chat.spec.tsx +10 -0
- package/src/routes/pages/chat.tsx +8 -0
- package/src/routes/pages/dreams.tsx +92 -142
- package/src/routes/pages/integrations.tsx +147 -68
- package/src/routes/pages/metrics.tsx +460 -102
- package/src/routes/pages/operations.tsx +2 -2
- package/src/routes/pages/settings.tsx +27 -10
- package/src/routes/pages/thread-debug.tsx +18 -36
- package/src/routes/pages/vault.tsx +7 -2
- package/src/routes/pages/workspace.tsx +92 -56
- package/src/server/index.ts +4 -0
- package/src/server/lib/app-creation-store.spec.ts +90 -43
- package/src/server/lib/app-creation-store.ts +15 -61
- 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/integrations.ts +1 -0
- package/src/shared/app-roles.ts +8 -0
- package/src/styles/dispatch.css +9 -0
|
@@ -4,17 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
import { getDbExec } from "@agent-native/core/db";
|
|
7
|
-
import {
|
|
8
|
-
deleteAppSecret,
|
|
9
|
-
writeAppSecret,
|
|
10
|
-
type SecretScope,
|
|
11
|
-
} from "@agent-native/core/secrets";
|
|
12
7
|
import {
|
|
13
8
|
ensureBuilderProject,
|
|
14
9
|
getBuilderBranchProjectId,
|
|
15
10
|
getRequestContext,
|
|
16
11
|
isIntegrationCallerRequest,
|
|
17
|
-
resolveBuilderBranchProjectId,
|
|
18
12
|
resolveBuilderCredentialsDetailed,
|
|
19
13
|
runBuilderAgent,
|
|
20
14
|
} from "@agent-native/core/server";
|
|
@@ -36,9 +30,6 @@ import {
|
|
|
36
30
|
} from "./workspace-resources-store.js";
|
|
37
31
|
|
|
38
32
|
const SETTINGS_KEY = "dispatch-app-creation-settings";
|
|
39
|
-
const BUILDER_BRANCH_PROJECT_SECRET_KEY = "BUILDER_BRANCH_PROJECT_ID";
|
|
40
|
-
const BUILDER_BRANCH_PROJECT_SECRET_DESCRIPTION =
|
|
41
|
-
"Builder project for cloud code-change branches (set in Dispatch)";
|
|
42
33
|
const BUILDER_WORKSPACE_REPO_URL_ENV = "AGENT_NATIVE_WORKSPACE_REPO_URL";
|
|
43
34
|
const DEFAULT_BUILDER_WORKSPACE_REPO_URL =
|
|
44
35
|
"https://github.com/BuilderIO/builder-agent-native-workspace";
|
|
@@ -61,6 +52,7 @@ const AUTONOMOUS_WORKSPACE_APP_CREATION_CONTRACT = [
|
|
|
61
52
|
"Autonomous Builder handoff contract:",
|
|
62
53
|
"- This is a background implementation run launched by the turn-into-app workflow. Treat the source brief and latest user request as authorization to build the app now; do not return a proposal or wait for another turn.",
|
|
63
54
|
"- Do not ask the user questions during the initial build and do not invoke a clarification, guided-question, or choice flow for a non-blocking decision.",
|
|
55
|
+
"- If the confirmed source brief describes a spreadsheet source-review or input/output confirmation surface, implement that review UI as part of the first-run app experience and seed it with the bounded candidates and mapping; keep the background build autonomous and do not send a question back from the Builder run.",
|
|
64
56
|
"- When the source or a tool presents a recommended option, choose it and continue. When no recommendation is present, choose the most direct, conservative default supported by the source and normal Agent-Native conventions.",
|
|
65
57
|
"- Resolve product, visual, copy, layout, route, data-model, dependency, and integration choices yourself. If an input is missing, use an empty state or clearly labeled representative sample so the workflow is demonstrable; never invent private facts or credentials.",
|
|
66
58
|
"- Treat the source brief's unknowns and follow-up items as assumptions to record in the app README or a visible Assumptions / Review section, not as questions to send back to the user.",
|
|
@@ -74,6 +66,8 @@ const pendingBuilderProjectProvisioning = new Map<
|
|
|
74
66
|
>();
|
|
75
67
|
|
|
76
68
|
class AppCreationSettingsAuthorizationError extends Error {
|
|
69
|
+
statusCode = 403;
|
|
70
|
+
|
|
77
71
|
constructor() {
|
|
78
72
|
super(APP_CREATION_SETTINGS_AUTHORIZATION_MESSAGE);
|
|
79
73
|
this.name = "AppCreationSettingsAuthorizationError";
|
|
@@ -254,16 +248,6 @@ function scopedSettingsKey(): string {
|
|
|
254
248
|
return `${SETTINGS_KEY}:user:${currentOwnerEmail()}`;
|
|
255
249
|
}
|
|
256
250
|
|
|
257
|
-
function builderProjectSecretTarget(): {
|
|
258
|
-
scope: Extract<SecretScope, "org" | "workspace">;
|
|
259
|
-
scopeId: string;
|
|
260
|
-
} | null {
|
|
261
|
-
const orgId = currentOrgId();
|
|
262
|
-
if (orgId) return { scope: "org", scopeId: orgId };
|
|
263
|
-
const email = currentOwnerEmail();
|
|
264
|
-
return email ? { scope: "workspace", scopeId: `solo:${email}` } : null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
251
|
function workspaceAppMetadataSettingsKey(): string {
|
|
268
252
|
const orgId = currentOrgId();
|
|
269
253
|
if (orgId) return `${WORKSPACE_APP_METADATA_SETTINGS_KEY}:org:${orgId}`;
|
|
@@ -1607,26 +1591,28 @@ function runScaffoldCli(input: {
|
|
|
1607
1591
|
|
|
1608
1592
|
export async function getAppCreationSettings(): Promise<AppCreationSettings> {
|
|
1609
1593
|
const envBuilderProjectId = getEnvBuilderProjectId();
|
|
1610
|
-
const resolvedBuilderProjectId = await resolveBuilderBranchProjectId();
|
|
1611
1594
|
const raw = await readSettingsRecord();
|
|
1595
|
+
const hasSavedBuilderProjectId = Object.prototype.hasOwnProperty.call(
|
|
1596
|
+
raw,
|
|
1597
|
+
"builderProjectId",
|
|
1598
|
+
);
|
|
1612
1599
|
const savedBuilderProjectId =
|
|
1613
1600
|
typeof raw?.builderProjectId === "string" && raw.builderProjectId.trim()
|
|
1614
1601
|
? raw.builderProjectId.trim()
|
|
1615
1602
|
: null;
|
|
1616
|
-
const builderProjectId = envBuilderProjectId || savedBuilderProjectId;
|
|
1617
1603
|
const enableBuilder =
|
|
1618
1604
|
process.env.ENABLE_BUILDER === "true" || process.env.ENABLE_BUILDER === "1";
|
|
1619
|
-
const effectiveBuilderProjectId =
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1605
|
+
const effectiveBuilderProjectId = hasSavedBuilderProjectId
|
|
1606
|
+
? savedBuilderProjectId
|
|
1607
|
+
: envBuilderProjectId ||
|
|
1608
|
+
(enableBuilder ? getBuilderBranchProjectId() : null);
|
|
1623
1609
|
|
|
1624
1610
|
return {
|
|
1625
1611
|
builderProjectId: effectiveBuilderProjectId,
|
|
1626
|
-
builderProjectIdSource:
|
|
1627
|
-
? "
|
|
1628
|
-
:
|
|
1629
|
-
? "
|
|
1612
|
+
builderProjectIdSource: hasSavedBuilderProjectId
|
|
1613
|
+
? "dispatch"
|
|
1614
|
+
: envBuilderProjectId
|
|
1615
|
+
? "env"
|
|
1630
1616
|
: effectiveBuilderProjectId
|
|
1631
1617
|
? "default"
|
|
1632
1618
|
: "unset",
|
|
@@ -1647,20 +1633,10 @@ async function persistProvisionedBuilderProjectId(
|
|
|
1647
1633
|
builderProjectId: string,
|
|
1648
1634
|
): Promise<void> {
|
|
1649
1635
|
const raw = await readSettingsRecord();
|
|
1650
|
-
const secretTarget = builderProjectSecretTarget();
|
|
1651
1636
|
|
|
1652
1637
|
// This is an internal consequence of an authorized app-creation request,
|
|
1653
1638
|
// not a user-controlled settings update. Persist the project before the
|
|
1654
1639
|
// branch run so later members reuse the same Builder project.
|
|
1655
|
-
if (secretTarget) {
|
|
1656
|
-
await writeAppSecret({
|
|
1657
|
-
key: BUILDER_BRANCH_PROJECT_SECRET_KEY,
|
|
1658
|
-
scope: secretTarget.scope,
|
|
1659
|
-
scopeId: secretTarget.scopeId,
|
|
1660
|
-
value: builderProjectId,
|
|
1661
|
-
description: BUILDER_BRANCH_PROJECT_SECRET_DESCRIPTION,
|
|
1662
|
-
});
|
|
1663
|
-
}
|
|
1664
1640
|
await putSetting(scopedSettingsKey(), { ...raw, builderProjectId });
|
|
1665
1641
|
await recordAudit({
|
|
1666
1642
|
action: "settings.updated",
|
|
@@ -1712,28 +1688,6 @@ export async function setAppCreationSettings(input: {
|
|
|
1712
1688
|
const builderProjectId = input.builderProjectId?.trim() || null;
|
|
1713
1689
|
const raw = await readSettingsRecord();
|
|
1714
1690
|
|
|
1715
|
-
// The credential store, not this settings row, is what
|
|
1716
|
-
// `resolveBuilderBranchProjectId()` reads. Write it first: a saved setting
|
|
1717
|
-
// whose secret never landed reports the project as configured while cloud
|
|
1718
|
-
// code changes stay silently disabled.
|
|
1719
|
-
const secretTarget = builderProjectSecretTarget();
|
|
1720
|
-
if (secretTarget) {
|
|
1721
|
-
const ref = {
|
|
1722
|
-
key: BUILDER_BRANCH_PROJECT_SECRET_KEY,
|
|
1723
|
-
scope: secretTarget.scope,
|
|
1724
|
-
scopeId: secretTarget.scopeId,
|
|
1725
|
-
};
|
|
1726
|
-
if (builderProjectId) {
|
|
1727
|
-
await writeAppSecret({
|
|
1728
|
-
...ref,
|
|
1729
|
-
value: builderProjectId,
|
|
1730
|
-
description: BUILDER_BRANCH_PROJECT_SECRET_DESCRIPTION,
|
|
1731
|
-
});
|
|
1732
|
-
} else {
|
|
1733
|
-
await deleteAppSecret(ref);
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
1691
|
await putSetting(scopedSettingsKey(), { ...raw, builderProjectId });
|
|
1738
1692
|
await recordAudit({
|
|
1739
1693
|
action: "settings.updated",
|
|
@@ -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.
|
|
@@ -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.
|