Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ describe("createSyncRemoteCommandService", () => {
await expect(service.execute(makePayload("usage.getAdeStats", { scope: "galaxy" }))).rejects.toThrow(
"usage.getAdeStats scope must be machine or project.",
);
getAdeUsageStats.mockClear();
await service.execute(makePayload("usage.getAdeStats", {
since: "2026-07-01T00:00:00.000Z",
until: "2026-07-08T00:00:00.000Z",
}));
expect(getAdeUsageStats).toHaveBeenCalledWith({
since: "2026-07-01T00:00:00.000Z",
until: "2026-07-08T00:00:00.000Z",
});
await expect(service.execute(makePayload("usage.getAdeStats", { since: "not-a-date" }))).rejects.toThrow(
"usage.getAdeStats since must be an ISO timestamp.",
);
await expect(service.execute(makePayload("usage.getAdeStats", { until: "not-a-date" }))).rejects.toThrow(
"usage.getAdeStats until must be an ISO timestamp.",
);
});

it("advertises and executes machine personal chats as runtime commands", async () => {
Expand Down
10 changes: 10 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4531,9 +4531,19 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg
if (scope && !isAdeUsageScope(scope)) {
throw new Error("usage.getAdeStats scope must be machine or project.");
}
const since = asTrimmedString(payload.since);
if (since && Number.isNaN(Date.parse(since))) {
throw new Error("usage.getAdeStats since must be an ISO timestamp.");
}
const until = asTrimmedString(payload.until);
if (until && Number.isNaN(Date.parse(until))) {
throw new Error("usage.getAdeStats until must be an ISO timestamp.");
}
return await args.usageTrackingService.getAdeUsageStats({
...(isAdeUsageRangePreset(preset) ? { preset } : {}),
...(isAdeUsageScope(scope) ? { scope } : {}),
...(since ? { since } : {}),
...(until ? { until } : {}),
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});

Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { areAutomationsEnabledForPackagedState } from "../../../shared/automatio
import { findRecentProjectForRepo } from "../projects/repoProjectResolver";
import { getModelById } from "../../../shared/modelRegistry";
import { appendEvent as perfAppend, isRunActive as isPerfRunActive } from "../perf/perfLog";
import { buildPrAiResolutionContextKey, isAdeUsageScope } from "../../../shared/types";
import { buildPrAiResolutionContextKey, isAdeUsageRangePreset, isAdeUsageScope } from "../../../shared/types";
import { detectCliAuthStatuses } from "../ai/authDetector";
import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable";
import { buildProviderConnections } from "../ai/providerConnectionStatus";
Expand Down Expand Up @@ -4877,9 +4877,18 @@ export function registerIpc({
ipcMain.handle(IPC.usageGetAdeStats, async (_event, arg: GetAdeUsageStatsArgs | undefined): Promise<AdeUsageStats | null> => {
const ctx = getCtx();
if (arg != null && !isRecord(arg)) throw new Error("usage stats expects an object payload.");
if (arg?.preset != null && !isAdeUsageRangePreset(arg.preset)) {
throw new Error("usage stats preset must be today, 7d, 30d, year, or all.");
}
if (arg?.scope != null && !isAdeUsageScope(arg.scope)) {
throw new Error("usage stats scope must be machine or project.");
}
if (arg?.since != null && Number.isNaN(Date.parse(arg.since))) {
throw new Error("usage stats since must be an ISO timestamp.");
}
if (arg?.until != null && Number.isNaN(Date.parse(arg.until))) {
throw new Error("usage stats until must be an ISO timestamp.");
}
return ctx.usageTrackingService?.getAdeUsageStats(arg ?? {}) ?? null;
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,47 @@ describe("registerIpc sync bridge", () => {
vi.useRealTimers();
});

it("validates usage range arguments before forwarding renderer IPC", async () => {
const getAdeUsageStats = vi.fn(async () => ({ generatedAt: "2026-07-09T12:00:00.000Z" }));
registerIpc({
getCtx: () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
usageTrackingService: { getAdeUsageStats },
}) as any,
getWindowSession: () => ({
windowId: 7,
project: { rootPath: "/repo", displayName: "Repo" } as any,
binding: localBinding("/repo"),
}),
switchProjectFromDialog: vi.fn(),
closeCurrentProject: vi.fn(),
closeProjectByPath: vi.fn(),
globalStatePath: "/tmp/ade-state.json",
});
const handler = ipcHandlers.get(IPC.usageGetAdeStats)!;

await expect(handler(eventForSender(), { preset: "decade" })).rejects.toThrow(
"usage stats preset must be today, 7d, 30d, year, or all.",
);
await expect(handler(eventForSender(), { since: "not-a-date" })).rejects.toThrow(
"usage stats since must be an ISO timestamp.",
);
await expect(handler(eventForSender(), { until: "not-a-date" })).rejects.toThrow(
"usage stats until must be an ISO timestamp.",
);
expect(getAdeUsageStats).not.toHaveBeenCalled();

const args = {
preset: "30d",
since: "2026-07-01T12:00:00.000Z",
until: "2026-07-02T12:00:00.000Z",
};
await expect(handler(eventForSender(), args)).resolves.toEqual({
generatedAt: "2026-07-09T12:00:00.000Z",
});
expect(getAdeUsageStats).toHaveBeenCalledWith(args);
});

it("uses the sender window's bound local project for iOS Simulator window sources", async () => {
const repoGetStatus = vi.fn(async () => ({
platform: "darwin",
Expand Down
16 changes: 6 additions & 10 deletions apps/desktop/src/main/services/usage/usageEndToEnd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,11 @@ describe("usage ledger end-to-end accuracy", () => {
const originalClaudeConfigDirs = process.env.CLAUDE_CONFIG_DIRS;
const originalCodexHome = process.env.CODEX_HOME;
const originalFactoryDir = process.env.FACTORY_DIR;
const originalTimezone = process.env.TZ;

try {
process.env.TZ = "America/New_York";
const beforeMidnight = new Date(2026, 9, 31, 23, 59, 0, 0);
const afterMidnight = new Date(2026, 10, 1, 0, 1, 0, 0);
// Use explicit offsets so the DST assertion is portable even when a
// test worker cannot apply a runtime TZ change (as on Linux CI).
const beforeDstChange = new Date("2026-11-01T00:30:00-04:00");
const afterDstChange = new Date("2026-11-01T03:30:00-05:00");
const beforeDstChange = new Date(2026, 10, 1, 0, 30, 0, 0);
const afterDstChange = new Date(2026, 10, 1, 3, 30, 0, 0);
const today = new Date(2026, 10, 2, 9, 0, 0, 0);
const now = new Date(2026, 10, 2, 12, 0, 0, 0);
vi.useFakeTimers();
Expand Down Expand Up @@ -347,17 +342,18 @@ describe("usage ledger end-to-end accuracy", () => {
droid: { estimation: "distribution", scopeSupported: false },
});

expect(afterDstChange.getTime() - beforeDstChange.getTime()).toBe(4 * 60 * 60 * 1_000);
const wallClockDeltaMs = 3 * 60 * 60 * 1_000;
const offsetChangeMs = (afterDstChange.getTimezoneOffset() - beforeDstChange.getTimezoneOffset()) * 60 * 1_000;
expect(afterDstChange.getTime() - beforeDstChange.getTime()).toBe(wallClockDeltaMs + offsetChangeMs);
} finally {
const restoreEnv = (key: "CLAUDE_CONFIG_DIR" | "CLAUDE_CONFIG_DIRS" | "CODEX_HOME" | "FACTORY_DIR" | "TZ", value: string | undefined) => {
const restoreEnv = (key: "CLAUDE_CONFIG_DIR" | "CLAUDE_CONFIG_DIRS" | "CODEX_HOME" | "FACTORY_DIR", value: string | undefined) => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
};
restoreEnv("CLAUDE_CONFIG_DIR", originalClaudeConfigDir);
restoreEnv("CLAUDE_CONFIG_DIRS", originalClaudeConfigDirs);
restoreEnv("CODEX_HOME", originalCodexHome);
restoreEnv("FACTORY_DIR", originalFactoryDir);
restoreEnv("TZ", originalTimezone);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
Expand Down
67 changes: 50 additions & 17 deletions apps/desktop/src/main/services/usage/usageStatsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export function collectAdeDatabaseUsageStats(
const sessionRange = rangeClause("started_at", range);
const eventRange = rangeClause("occurred_at", range);
const operationRange = rangeClause("started_at", range);
const deltaRange = rangeClause("d.started_at", range);
const artifactRange = rangeClause("created_at", range);

const aiRows = safeAll<{
Expand Down Expand Up @@ -411,24 +412,26 @@ export function collectAdeDatabaseUsageStats(
d.files_changed, d.insertions, d.deletions
from session_deltas d
left join lanes l on l.id = d.lane_id
where ${rangeClause("d.started_at", range).sql}
`, rangeClause("d.started_at", range).params);
where ${deltaRange.sql}
`, deltaRange.params);

const clientRows = safeAll<{
client_surface: AdeUsageClientSurface;
interactions: number;
active_days: number;
sessions: number;
last_active_at: string | null;
}>(db, `
select client_surface, count(*) interactions,
count(distinct date(occurred_at, 'localtime')) active_days,
count(distinct session_id) sessions,
max(occurred_at) last_active_at
from usage_events
where ${eventRange.sql}
group by client_surface
`, eventRange.params);

const clientEventRows = safeAll<{
const clientDailyRows = safeAll<{
occurred_at: string;
client_surface: AdeUsageClientSurface;
}>(db, `
Expand All @@ -439,6 +442,46 @@ export function collectAdeDatabaseUsageStats(
limit ?
`, [...eventRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]);

// Summary day counts and streaks must not depend on the capped chart scans
// below. This query returns at most one row per local calendar day even when
// the underlying event tables contain millions of rows.
const activeDateRows = safeAll<{ active_date: string }>(db, `
select active_date
from (
select date(timestamp, 'localtime') active_date
from ai_usage_log
where ${aiRange.sql}
and (coalesce(input_tokens, 0) > 0 or coalesce(output_tokens, 0) > 0)
union
select date(started_at, 'localtime') active_date
from terminal_sessions
where ${sessionRange.sql}
union
select date(d.started_at, 'localtime') active_date
from session_deltas d
where ${deltaRange.sql}
and (coalesce(d.insertions, 0) > 0 or coalesce(d.deletions, 0) > 0 or coalesce(d.files_changed, 0) > 0)
union
select date(occurred_at, 'localtime') active_date
from usage_events
where ${eventRange.sql}
union
select date(started_at, 'localtime') active_date
from operations
where ${operationRange.sql}
and status = 'succeeded'
and kind in ('git_commit', 'pr_land')
)
where active_date is not null
order by active_date
`, [
...aiRange.params,
...sessionRange.params,
...deltaRange.params,
...eventRange.params,
...operationRange.params,
]);

const interactionRows = safeAll<{ action: string; count: number }>(db, `
select action, count(*) count
from usage_events
Expand Down Expand Up @@ -614,7 +657,7 @@ export function collectAdeDatabaseUsageStats(
limit ?
`, [...aiRange.params, DAILY_BUCKET_SCAN_MAX_ROWS]);
const cappedDailySources = [
clientEventRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null,
clientDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "usage_events" : null,
operationDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "operations" : null,
aiDailyRows.length === DAILY_BUCKET_SCAN_MAX_ROWS ? "ai_usage_log" : null,
].filter((source): source is string => source !== null);
Expand Down Expand Up @@ -647,8 +690,7 @@ export function collectAdeDatabaseUsageStats(
day.insertions = int(day.insertions) + int(row.insertions);
day.deletions = int(day.deletions) + int(row.deletions);
}
const activeDaysByClient = new Map<AdeUsageClientSurface, Set<string>>();
for (const row of clientEventRows) {
for (const row of clientDailyRows) {
const date = isoDate(row.occurred_at);
if (!date) continue;
const day = ensureDay(date);
Expand All @@ -657,9 +699,6 @@ export function collectAdeDatabaseUsageStats(
...(day.clients ?? {}),
[row.client_surface]: int(day.clients?.[row.client_surface]) + 1,
};
const activeDays = activeDaysByClient.get(row.client_surface) ?? new Set<string>();
activeDays.add(date);
activeDaysByClient.set(row.client_surface, activeDays);
}
for (const row of operationDailyRows) {
const date = isoDate(row.started_at);
Expand All @@ -669,13 +708,7 @@ export function collectAdeDatabaseUsageStats(
if (row.kind === "pr_land") day.prs = int(day.prs) + 1;
}

const activeDates = new Set<string>();
for (const [date, point] of daily) {
if (int(point.totalTokens) + int(point.sessions) + int(point.interactions) + int(point.insertions) + int(point.deletions) + int(point.commits) + int(point.prs) > 0) {
activeDates.add(date);
}
}
const streaks = calculateStreaks(activeDates, range.until);
const streaks = calculateStreaks(activeDateRows.map((row) => row.active_date), range.until);
const longestSessionMs = sessionRows.reduce((max, row) => {
const start = Date.parse(row.started_at);
const end = Date.parse(row.ended_at ?? range.until);
Expand All @@ -694,7 +727,7 @@ export function collectAdeDatabaseUsageStats(
.map((row) => ({
client: row.client_surface,
interactions: int(row.interactions),
activeDays: activeDaysByClient.get(row.client_surface)?.size ?? 0,
activeDays: int(row.active_days),
sessions: int(row.sessions),
lastActiveAt: row.last_active_at,
}))
Expand Down
Loading
Loading