Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/mobile/src/features/usage/UsageRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@ import { AppText as Text } from "../../components/ui/AppText";
import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width";
import { cn } from "../../lib/cn";
import { SettingsScreen } from "../settings/components/SettingsScreen";
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
import { useUsage, useUsageSandboxShares, type EnvironmentUsageStatus } from "../../state/usage";
import { SettingsSection } from "../settings/components/SettingsSection";
import { UsageDailyChart } from "./UsageDailyChart";
import { UsageSandboxShareSection } from "./UsageSandboxShareSection";
import { toggleUsageEnvironment } from "./usageEnvironmentSelection";
import { useRefreshLimits } from "./UsageLimitsSection";
import { UsageLimitsSection } from "./UsageLimitsPooled";
Expand Down Expand Up @@ -106,6 +107,7 @@ function UsageScreen(props: {
window,
selectedEnvironmentIds,
);
const sandboxShares = useUsageSandboxShares(selectedEnvironments, window.timeZone);
const isFocused = useIsFocused();
const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && view === "limits");
const sourceMessages = usageSourceMessages(
Expand Down Expand Up @@ -348,6 +350,13 @@ function UsageScreen(props: {
columns={width >= 900 ? 6 : wide ? 3 : 2}
/>
<CostSection merged={merged} wide={wide} />
{sandboxShares.map((share) => (
<UsageSandboxShareSection
key={share.environmentId}
summary={share.summary}
label={sandboxShares.length > 1 ? share.label : null}
/>
))}
<ModelsSection merged={merged} metric={metric} columns={wide ? 2 : 1} />
</UsageUpdating>
</>
Expand Down
99 changes: 99 additions & 0 deletions apps/mobile/src/features/usage/UsageSandboxShareSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
sandboxShareChangeLabel,
type SandboxShareSummary,
} from "@t3tools/client-runtime/state/usage-sandbox-share";
import { formatCount, formatDayShort, formatPercent } from "@t3tools/shared/usageFormat";
import { View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import { SettingsSection } from "../settings/components/SettingsSection";
import { useUsageMixColors } from "./usageProviders";

function sideLabel(side: { readonly chats: number; readonly turns: number }): string {
return `${formatCount(side.chats)} ${side.chats === 1 ? "chat" : "chats"} · ${formatCount(side.turns)} ${side.turns === 1 ? "turn" : "turns"}`;
}

/** How much of a host's work runs on sandboxes, by week; the web Usage page shows the same. */
export function UsageSandboxShareSection(props: {
readonly summary: SandboxShareSummary;
readonly label: string | null;
}) {
const { summary } = props;
const mix = useUsageMixColors();
const current = summary.current;
if (current === null) return null;
const change = sandboxShareChangeLabel(summary);
const colors = { sandbox: mix.output, local: mix.other };

return (
<SettingsSection
title={props.label === null ? "Local vs sandboxes" : `Local vs sandboxes · ${props.label}`}
>
<View className="gap-4 p-4">
<View className="gap-0.5">
<Text className="text-4xl font-t3-bold tabular-nums text-foreground">
{current.sandboxShare === null ? "-" : formatPercent(current.sandboxShare, 0)}
</Text>
<Text className="text-sm text-foreground-muted">
of this week's turns ran on sandboxes
</Text>
{change === null ? null : <Text className="text-sm text-foreground">{change}</Text>}
</View>
{(
[
{ label: "Sandboxes", color: colors.sandbox, side: current.sandbox },
{ label: "Local", color: colors.local, side: current.local },
] as const
).map((row) => (
<View key={row.label} className="flex-row items-baseline justify-between gap-3">
<View className="flex-row items-center gap-2">
<View className="size-2.5 rounded-full" style={{ backgroundColor: row.color }} />
<Text className="text-base text-foreground">{row.label}</Text>
</View>
<Text className="text-sm tabular-nums text-foreground-muted">
{sideLabel(row.side)}
</Text>
</View>
))}
<View className="gap-1.5">
<View
accessible
accessibilityRole="image"
accessibilityLabel={`Share of turns on sandboxes by week: ${summary.weeks
.map(
(week) =>
`${formatDayShort(week.weekStart)} ${week.sandboxShare === null ? "no turns" : formatPercent(week.sandboxShare, 0)}`,
)
.join("; ")}`}
className="h-20 flex-row items-end gap-1.5"
>
{summary.weeks.map((week) => (
<View
key={week.weekStart}
className="h-full flex-1 flex-col-reverse overflow-hidden rounded-sm bg-subtle"
>
{week.sandboxShare === null ? null : (
<>
<View
style={{
height: `${week.sandboxShare * 100}%`,
backgroundColor: colors.sandbox,
}}
/>
<View className="flex-1" style={{ backgroundColor: colors.local }} />
</>
)}
</View>
))}
</View>
<View className="flex-row justify-between">
<Text className="text-xs text-foreground-tertiary">
{summary.weeks[0] === undefined ? "" : formatDayShort(summary.weeks[0].weekStart)}
</Text>
<Text className="text-xs text-foreground-tertiary">This week</Text>
</View>
</View>
</View>
</SettingsSection>
);
}
51 changes: 51 additions & 0 deletions apps/mobile/src/state/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
} from "@t3tools/contracts";
import { refreshUsage, usageSummaryInput } from "@t3tools/client-runtime/state/usage";
import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
import {
summarizeSandboxShare,
type SandboxShareSummary,
} from "@t3tools/client-runtime/state/usage-sandbox-share";
import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
import { AsyncResult, Atom } from "effect/reactivity";
import { useCallback, useMemo } from "react";
Expand Down Expand Up @@ -168,3 +172,50 @@ export function useUsage(
refresh,
};
}

const sandboxSharesAtom = Atom.family((key: string) =>
Atom.make((get): ReadonlyMap<EnvironmentId, SandboxShareSummary> => {
const { environmentIds, timeZone } = JSON.parse(key) as {
readonly environmentIds: readonly EnvironmentId[];
readonly timeZone: string;
};
const shares = new Map<EnvironmentId, SandboxShareSummary>();
for (const environmentId of environmentIds) {
const share = AsyncResult.getOrElse(
get(serverEnvironment.usageSandboxShare({ environmentId, input: { timeZone } })),
() => null,
);
if (share !== null) shares.set(environmentId, summarizeSandboxShare(share));
}
return shares;
}).pipe(Atom.withLabel(`mobile-usage:sandbox-share:${key}`)),
);

export interface EnvironmentSandboxShare {
readonly environmentId: EnvironmentId;
readonly label: string;
readonly summary: SandboxShareSummary;
}

/**
* Each selected environment's local versus sandbox chats. Only a sandbox host has sandbox chats,
* and environments with none, or servers without the read, are left out.
*/
export function useUsageSandboxShares(
selectedEnvironments: readonly EnvironmentUsageStatus[],
timeZone: string,
): readonly EnvironmentSandboxShare[] {
const readable = selectedEnvironments.filter((environment) => environment.canReadDiagnostics);
const key = JSON.stringify({
// Hermes lacks toSorted.
environmentIds: readable.map((environment) => environment.environmentId).sort(),
timeZone,
});
const shares = useAtomValue(sandboxSharesAtom(key));
return readable.flatMap((environment) => {
const summary = shares.get(environment.environmentId);
return summary?.hasSandboxActivity
? [{ environmentId: environment.environmentId, label: environment.label, summary }]
: [];
});
}
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverGetResourceTelemetryHistory]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRetryResourceTelemetry]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetUsageSummary]: AuthDiagnosticsReadScope,
[WS_METHODS.serverGetUsageSandboxShare]: AuthDiagnosticsReadScope,
[WS_METHODS.serverRefreshUsageRates]: AuthDiagnosticsReadScope,
[WS_METHODS.serverSignalProcess]: AuthEnvironmentMaintainScope,
[WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.serverListTextGeneratorModels]: "server",
[WS_METHODS.serverPrepareTriage]: "server",
[WS_METHODS.serverGetUsageSummary]: "server",
[WS_METHODS.serverGetUsageSandboxShare]: "server",
[WS_METHODS.serverRefreshUsageRates]: "server",
[WS_METHODS.serverSignalProcess]: "server",
[WS_METHODS.serverReportClientActivity]: "server",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAcco
import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts";
import * as UsageService from "./usage/UsageService.ts";
import * as CodexUsageHistory from "./usage/CodexUsageHistory.ts";
import * as UsageSandboxShare from "./usage/UsageSandboxShare.ts";
import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts";
import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts";
Expand Down Expand Up @@ -776,6 +777,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe(

const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(
Layer.provideMerge(layerUsage),
Layer.provideMerge(UsageSandboxShare.layer),
Layer.provideMerge(layerBackground),
Layer.provideMerge(layerResourceDiagnostics),
Layer.provideMerge(TraceDiagnostics.layer),
Expand Down
94 changes: 94 additions & 0 deletions apps/server/src/usage/UsageSandboxShare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { assert, it } from "@effect/vitest";
import { EnvironmentId, ThreadId, type SandboxRecord } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/sql/SqlClient";
import * as TestClock from "effect/testing/TestClock";

import * as SandboxRecords from "../persistence/SandboxRecords.ts";
import * as Sqlite from "../persistence/Sqlite.ts";
import * as UsageSandboxShare from "./UsageSandboxShare.ts";

const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));

const testLayer = Layer.mergeAll(UsageSandboxShare.layer, SandboxRecords.layer).pipe(
Layer.provideMerge(Sqlite.layerMemory),
);

const seedThread = (threadId: string, lineage: Record<string, unknown> = {}) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`INSERT INTO orchestration_v2_projection_threads ${sql.insert({
thread_id: threadId,
project_id: "project",
title: threadId,
default_provider: "codex",
runtime_mode: "full-access",
interaction_mode: "default",
created_at: "2026-11-01T00:00:00.000Z",
updated_at: "2026-11-01T00:00:00.000Z",
payload_json: encodeJson({
lineage: { parentThreadId: null, relationshipToParent: null, ...lineage },
}),
})}`;
});

const seedRun = (threadId: string, ordinal: number, instanceId: string, startedAt: string | null) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`INSERT INTO orchestration_v2_projection_runs ${sql.insert({
run_id: `${threadId}-run-${ordinal}`,
thread_id: threadId,
ordinal,
provider: instanceId,
provider_instance_id: instanceId,
status: startedAt === null ? "cancelled" : "completed",
requested_at: startedAt ?? "2026-11-12T08:00:00.000Z",
payload_json: encodeJson({ startedAt }),
})}`;
});

const sandboxRecord = (threadId: string, createdAt: string): SandboxRecord => ({
instanceId: `instance-${threadId}`,
environmentId: EnvironmentId.make(`environment-${threadId}`),
threadId: ThreadId.make(threadId),
provider: "boat",
sandboxId: `bx_${threadId}`,
label: threadId,
size: "small",
state: "running",
createdAt,
updatedAt: createdAt,
httpBaseUrl: "",
runningSince: null,
maxRunningMinutes: 480,
});

it.effect("reads this host's chats on the computer and on sandboxes, once each", () =>
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse("2026-11-13T12:00:00Z"));
yield* seedThread("local");
yield* seedRun("local", 1, "codex", "2026-11-10T08:00:00.000Z");
// Never started: not a turn.
yield* seedRun("local", 2, "codex", null);
yield* seedThread("subagent", { relationshipToParent: "subagent", parentThreadId: "local" });
yield* seedRun("subagent", 1, "codex", "2026-11-10T09:00:00.000Z");
yield* seedThread("mirror");
yield* seedRun("mirror", 1, "sandboxes", "2026-11-11T08:00:00.000Z");
yield* seedRun("mirror", 2, "sandboxes", "2026-11-11T09:00:00.000Z");

const records = yield* SandboxRecords.SandboxRecordRepository;
yield* records.put(sandboxRecord("mirror", "2026-11-11T07:00:00.000Z"));
yield* records.put(sandboxRecord("unmirrored", "2026-11-12T07:00:00.000Z"));

const service = yield* UsageSandboxShare.UsageSandboxShare;
const share = yield* service.read({ timeZone: "UTC" });
assert.strictEqual(share.weeks.length, 8);
assert.deepStrictEqual(share.weeks.at(-1), {
weekStart: "2026-11-09",
local: { chats: 1, turns: 1 },
sandbox: { chats: 2, turns: 2 },
});
}).pipe(Effect.provide(testLayer)),
);
Loading