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
40 changes: 30 additions & 10 deletions src/daemon/memory/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,10 +411,18 @@ export class SqliteEpisodeStore {
}

dailyUsage(days = 30, sessionIds?: string[]): DailyUsageBucket[] {
const sessionFilter =
sessionIds && sessionIds.length > 0
? `AND session_id IN (${sessionIds.map(() => "?").join(",")})`
: "";
// Scoping contract: `undefined` = unscoped (internal callers only);
// an ARRAY — including an empty one — is a strict ownership filter.
// Treating [] as "no filter" let a zero-session identity read
// EVERYONE's usage.
if (sessionIds && sessionIds.length === 0) return [];
// Filter via json_each over a single JSON-array bind param instead of
// one `?` per session id: `IN (?,?,...)` blows SQLite's bound-variable
// limit at ~1000 sessions (the sessions table lives in a different DB
// file, so a JOIN isn't available here).
const sessionFilter = sessionIds
? "AND session_id IN (SELECT value FROM json_each(?))"
: "";
const rows = this.#db
.prepare(
`SELECT
Expand All @@ -430,7 +438,9 @@ export class SqliteEpisodeStore {
GROUP BY day
ORDER BY day ASC`,
)
.all(days, ...(sessionIds ?? [])) as Array<{
.all(
...(sessionIds ? [days, JSON.stringify(sessionIds)] : [days]),
) as Array<{
day: string;
cost_usd: number;
input_tokens: number;
Expand All @@ -450,10 +460,20 @@ export class SqliteEpisodeStore {
}

lifetimeTotals(sessionIds?: string[]): LifetimeUsageTotals {
const sessionFilter =
sessionIds && sessionIds.length > 0
? `WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
: "";
// Same scoping contract as dailyUsage: [] = strict empty scope → zeros.
if (sessionIds && sessionIds.length === 0) {
return {
costUsd: 0,
inputTokens: 0,
outputTokens: 0,
numTurns: 0,
numSessions: 0,
};
}
// json_each over one JSON bind param — see dailyUsage for rationale.
const sessionFilter = sessionIds
? "WHERE session_id IN (SELECT value FROM json_each(?))"
: "";
const row = this.#db
.prepare(
`SELECT
Expand All @@ -464,7 +484,7 @@ export class SqliteEpisodeStore {
COUNT(DISTINCT session_id) AS num_sessions
FROM turn_usage ${sessionFilter}`,
)
.get(...(sessionIds ?? [])) as {
.get(...(sessionIds ? [JSON.stringify(sessionIds)] : [])) as {
cost_usd: number;
input_tokens: number;
output_tokens: number;
Expand Down
38 changes: 24 additions & 14 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1303,30 +1303,40 @@ export class SessionManager {
return { type: "response.ok", requestId: msg.id };
}

#emptyUsageResponse(requestId: string): DaemonMessage {
return {
type: "response.ok",
requestId,
data: {
daily: [] as DailyUsageBucket[],
lifetime: {
costUsd: 0,
inputTokens: 0,
outputTokens: 0,
numTurns: 0,
numSessions: 0,
} as LifetimeUsageTotals,
},
};
}

#usageDaily(
msg: Extract<ClientMessage, { type: "usage.daily" }>,
auth: AuthContext,
): DaemonMessage {
if (!this.#memory) {
return {
type: "response.ok",
requestId: msg.id,
data: {
daily: [] as DailyUsageBucket[],
lifetime: {
costUsd: 0,
inputTokens: 0,
outputTokens: 0,
numTurns: 0,
numSessions: 0,
} as LifetimeUsageTotals,
},
};
return this.#emptyUsageResponse(msg.id);
}
const days = typeof msg.days === "number" && msg.days > 0 ? Math.min(msg.days, 365) : 30;
const ownedSessionIds = this.#store
.listSessions(auth.accountId, auth.projectId)
.map((s) => s.id);
// An identity that owns no sessions gets zeros — never the unfiltered
// aggregate. (The store also enforces this: an empty array is a strict
// filter, not "no filter". Belt and suspenders around a tenancy leak.)
if (ownedSessionIds.length === 0) {
return this.#emptyUsageResponse(msg.id);
}
const daily = this.#memory.store.dailyUsage(days, ownedSessionIds);
const lifetime = this.#memory.store.lifetimeTotals(ownedSessionIds);
return {
Expand Down
67 changes: 58 additions & 9 deletions src/tests/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,15 +377,14 @@ describe("SqliteEpisodeStore — dailyUsage", () => {
store.close();
});

it("empty sessionIds array behaves identically to undefined (no filter)", () => {
it("empty sessionIds array is a STRICT filter — returns no buckets (zero-session identity must not see everyone's usage)", () => {
const store = new SqliteEpisodeStore(dbPath);
store.recordTurnUsage(makeTurnInput("s-a", 1));
store.recordTurnUsage(makeTurnInput("s-b", 1));

const withEmpty = store.dailyUsage(30, []);
const withUndefined = store.dailyUsage(30, undefined);
expect(withEmpty[0]?.numTurns).toBe(withUndefined[0]?.numTurns);
expect(withEmpty[0]?.numSessions).toBe(2);
expect(store.dailyUsage(30, [])).toEqual([]);
// undefined stays unscoped (internal callers).
expect(store.dailyUsage(30, undefined)[0]?.numSessions).toBe(2);
store.close();
});

Expand All @@ -396,6 +395,30 @@ describe("SqliteEpisodeStore — dailyUsage", () => {
expect(buckets).toEqual([]);
store.close();
});

it("scoping excludes other identities' sessions", () => {
const store = new SqliteEpisodeStore(dbPath);
store.recordTurnUsage(makeTurnInput("mine-1", 1, { totalCostUsd: 0.01 }));
store.recordTurnUsage(makeTurnInput("mine-2", 1, { totalCostUsd: 0.02 }));
store.recordTurnUsage(makeTurnInput("theirs-1", 1, { totalCostUsd: 5 }));

const buckets = store.dailyUsage(30, ["mine-1", "mine-2"]);
expect(buckets).toHaveLength(1);
expect(buckets[0]!.numSessions).toBe(2);
expect(buckets[0]!.costUsd).toBeCloseTo(0.03, 6);
store.close();
});

it("does not throw with more than 1000 session ids (old IN-list hit SQLite's variable limit)", () => {
const store = new SqliteEpisodeStore(dbPath);
store.recordTurnUsage(makeTurnInput("s-500", 1, { totalCostUsd: 0.01 }));
const ids = Array.from({ length: 2500 }, (_, i) => `s-${i}`);
const buckets = store.dailyUsage(30, ids);
expect(buckets).toHaveLength(1);
expect(buckets[0]!.numTurns).toBe(1);
expect(buckets[0]!.costUsd).toBeCloseTo(0.01, 6);
store.close();
});
});

describe("SqliteEpisodeStore — lifetimeTotals", () => {
Expand Down Expand Up @@ -451,15 +474,19 @@ describe("SqliteEpisodeStore — lifetimeTotals", () => {
store.close();
});

it("empty sessionIds array behaves identically to undefined (no filter)", () => {
it("empty sessionIds array is a STRICT filter — returns zeros (zero-session identity must not see everyone's usage)", () => {
const store = new SqliteEpisodeStore(dbPath);
store.recordTurnUsage(makeTurnInput("s-a", 1));
store.recordTurnUsage(makeTurnInput("s-b", 1));

const withEmpty = store.lifetimeTotals([]);
const withUndefined = store.lifetimeTotals(undefined);
expect(withEmpty.numTurns).toBe(withUndefined.numTurns);
expect(withEmpty.numSessions).toBe(2);
expect(withEmpty.numTurns).toBe(0);
expect(withEmpty.numSessions).toBe(0);
expect(withEmpty.costUsd).toBe(0);
expect(withEmpty.inputTokens).toBe(0);
expect(withEmpty.outputTokens).toBe(0);
// undefined stays unscoped (internal callers).
expect(store.lifetimeTotals(undefined).numSessions).toBe(2);
store.close();
});

Expand All @@ -471,6 +498,28 @@ describe("SqliteEpisodeStore — lifetimeTotals", () => {
expect(totals.numSessions).toBe(0);
store.close();
});

it("scoping excludes other identities' sessions", () => {
const store = new SqliteEpisodeStore(dbPath);
store.recordTurnUsage(makeTurnInput("mine-1", 1, { totalCostUsd: 0.01, inputTokens: 100 }));
store.recordTurnUsage(makeTurnInput("theirs-1", 1, { totalCostUsd: 5, inputTokens: 9999 }));

const totals = store.lifetimeTotals(["mine-1"]);
expect(totals.numSessions).toBe(1);
expect(totals.costUsd).toBeCloseTo(0.01, 6);
expect(totals.inputTokens).toBe(100);
store.close();
});

it("does not throw with more than 1000 session ids (old IN-list hit SQLite's variable limit)", () => {
const store = new SqliteEpisodeStore(dbPath);
store.recordTurnUsage(makeTurnInput("s-1234", 1, { totalCostUsd: 0.02 }));
const ids = Array.from({ length: 2500 }, (_, i) => `s-${i}`);
const totals = store.lifetimeTotals(ids);
expect(totals.numTurns).toBe(1);
expect(totals.costUsd).toBeCloseTo(0.02, 6);
store.close();
});
});

// ── test helpers ────────────────────────────────────────────────────────
Expand Down
24 changes: 7 additions & 17 deletions web/src/components/AnalyticsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
import { For, Show, onMount } from "solid-js";
import { analyticsLoading, dailyUsage, fetchAnalytics, lifetimeTotals } from "../state/analytics";
import { formatCostUsd, formatTokens } from "../lib/format";
import type { DailyUsageBucket } from "../protocol/types";

function padDays(data: DailyUsageBucket[], days: number): Array<{ day: string; costUsd: number }> {
const map = new Map(data.map((d) => [d.day, d.costUsd]));
const result = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
result.push({ day: key, costUsd: map.get(key) ?? 0 });
}
return result;
}
import { padDays, utcDayKey } from "../lib/usage-days";

const DAYS = 14;
const BAR_W = 14;
Expand All @@ -28,7 +15,8 @@ const AnalyticsPanel = () => {

const padded = () => padDays(dailyUsage(), DAYS);
const maxCost = () => Math.max(...padded().map((d) => d.costUsd), 0.001);
const today = new Date().toISOString().slice(0, 10);
// UTC to match the daemon's sqlite date() bucketing — see lib/usage-days.
const today = utcDayKey(Date.now());

return (
<div class="px-3 pb-3 pt-1 border-b border-border">
Expand Down Expand Up @@ -71,8 +59,10 @@ const AnalyticsPanel = () => {
const x = () => i() * (BAR_W + BAR_GAP);
const isToday = () => bucket.day === today;
const dayLabel = () => {
const d = new Date(bucket.day + "T00:00:00");
return d.toLocaleDateString(undefined, { weekday: "short" }).slice(0, 1);
const d = new Date(bucket.day + "T00:00:00Z");
return d
.toLocaleDateString(undefined, { weekday: "short", timeZone: "UTC" })
.slice(0, 1);
};
return (
<>
Expand Down
18 changes: 12 additions & 6 deletions web/src/components/transcript/ApprovalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js";

import { newRequestId, send } from "../../state/connection";
import { focusedSessionMessages } from "../../state/messages";
import { epochOf, focusedSessionMessages } from "../../state/messages";
import { focusedSession, focusedSessionId } from "../../state/sessions";
import { findPendingApproval } from "../../lib/approvals";
import type { SessionMessage } from "../../protocol/types";

/** Custom event the prompt listens for so "Refine" can focus + hint. */
Expand Down Expand Up @@ -70,12 +72,16 @@ function extractQuestions(input: unknown): AskQuestion[] {
}

const ApprovalBar: Component = () => {
// Status-gated, turn-bounded scan — see lib/approvals.ts. Tracking the
// session status here also means the memo re-fires when a racing
// status_change lands after the tool delta, so the bar still appears.
// The per-session epoch is tracked too: tool-state deltas mutate message
// fields in place (array identity stays stable), so without it a second
// parallel approval flipping to waiting_confirmation mid-turn — with no
// accompanying status change — would not recompute the memo.
const pending = createMemo<SessionMessage | null>(() => {
for (const m of focusedSessionMessages()) {
if (m.role !== "tool_call" || !m.tool) continue;
if (m.tool.state.phase === "waiting_confirmation") return m;
}
return null;
epochOf(focusedSessionId());
return findPendingApproval(focusedSessionMessages(), focusedSession()?.status);
});

// Resolve the pending state once and gate every callback on its
Expand Down
Loading
Loading