From 2c820389a2752ad034e76ac22c4ce02b4025320b Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 02:34:24 +0900 Subject: [PATCH 1/3] feat(dashboard): add usage stats and dashboard message types --- .../__tests__/dashboard-stats-stream.spec.ts | 838 ++++++++++++++++++ .../types/src/__tests__/usage-stats.spec.ts | 401 +++++++++ packages/types/src/index.ts | 1 + packages/types/src/usage-stats.ts | 555 ++++++++++++ packages/types/src/vscode-extension-host.ts | 116 ++- 5 files changed, 1910 insertions(+), 1 deletion(-) create mode 100644 packages/types/src/__tests__/dashboard-stats-stream.spec.ts create mode 100644 packages/types/src/__tests__/usage-stats.spec.ts create mode 100644 packages/types/src/usage-stats.ts diff --git a/packages/types/src/__tests__/dashboard-stats-stream.spec.ts b/packages/types/src/__tests__/dashboard-stats-stream.spec.ts new file mode 100644 index 0000000000..0d1ecc543d --- /dev/null +++ b/packages/types/src/__tests__/dashboard-stats-stream.spec.ts @@ -0,0 +1,838 @@ +import { + DashboardStatsSubscription, + DashboardStatsSnapshot, + DashboardStatsDelta, + DashboardSessionPage, + DashboardStatsError, + DashboardSessionSummary, + DashboardSessionPageRequest, + DashboardSessionUpsert, + DashboardTaskSummary, + DashboardTaskPage, + DashboardTaskUpsert, + DashboardTaskDetail, + DashboardTaskStatsSnapshot, + DashboardTaskStatsDelta, + HeatmapSnapshot, + StatsBucketDelta, +} from "../usage-stats.js" +import { providerIdentifiers } from "../provider-identifiers.js" + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const validStatsQuery = { + timezone: "Asia/Seoul", + groupBy: ["day"], +} + +const validBucket = { + key: { day: "2026-07-29" }, + events: 10, + completedCalls: 8, + failedCalls: 1, + cancelledCalls: 1, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1000, + cacheWriteTokens: 500, + reasoningTokens: 200, + totalTokens: 7500, + costUsd: 0.075, + unknownEventCount: 0, +} + +const validStatsSnapshot = { + query: validStatsQuery, + generatedAt: "2026-07-29T12:00:00.000Z", + buckets: [validBucket], + totals: validBucket, + coverage: { + firstEventAt: "2026-07-01T00:00:00.000Z", + lastEventAt: "2026-07-29T12:00:00.000Z", + recordingPaused: false, + backfilledEventCount: 0, + }, +} + +const validSessionSummary: DashboardSessionSummary = { + rootTaskId: "root-task-001", + title: "Fix authentication bug", + totalCost: 0.15, + totalTokens: 12000, + model: "claude-sonnet-4-20250514", + provider: providerIdentifiers.anthropic, + lastActivity: 1722259200000, + eventCount: 5, +} + +const validTaskSummary: DashboardTaskSummary = { + taskId: "task-001", + rootTaskId: "root-task-001", + parentTaskId: "parent-task-001", + title: "Fix task projection", + taskTimestamp: 1722259100000, + lastUsageAt: 1722259200000, + totalCost: 0.15, + totalTokens: 12000, + inputTokens: 8000, + outputTokens: 4000, + model: "claude-sonnet-4-20250514", + provider: providerIdentifiers.anthropic, + models: ["claude-sonnet-4-20250514"], + modes: ["code"], + eventCount: 5, + childTaskIds: [], +} + +// ── DashboardSessionPageRequest ───────────────────────────────────────────── + +describe("DashboardSessionPageRequest", () => { + it("should parse a valid request with limit and cursor", () => { + const result = DashboardSessionPageRequest.parse({ limit: 50, cursor: "abc123" }) + expect(result.limit).toBe(50) + expect(result.cursor).toBe("abc123") + }) + + it("should default limit to 50 when omitted", () => { + const result = DashboardSessionPageRequest.parse({}) + expect(result.limit).toBe(50) + }) + + it("should accept limit of 1 (minimum)", () => { + const result = DashboardSessionPageRequest.parse({ limit: 1 }) + expect(result.limit).toBe(1) + }) + + it("should accept limit of 100 (maximum)", () => { + const result = DashboardSessionPageRequest.parse({ limit: 100 }) + expect(result.limit).toBe(100) + }) + + it("should reject limit of 0 (below minimum)", () => { + expect(() => DashboardSessionPageRequest.parse({ limit: 0 })).toThrow() + }) + + it("should reject limit of 101 (above maximum)", () => { + expect(() => DashboardSessionPageRequest.parse({ limit: 101 })).toThrow() + }) + + it("should reject non-integer limit", () => { + expect(() => DashboardSessionPageRequest.parse({ limit: 50.5 })).toThrow() + }) + + it("should work without cursor (first page)", () => { + const result = DashboardSessionPageRequest.parse({ limit: 25 }) + expect(result.cursor).toBeUndefined() + }) +}) + +// ── DashboardStatsSubscription ────────────────────────────────────────────── + +describe("DashboardStatsSubscription", () => { + const validSubscription = { + requestId: "sub-001", + range: validStatsQuery, + sessionPageSize: 50, + heatmapRangeDays: 30, + } + + it("should parse a valid subscription", () => { + const result = DashboardStatsSubscription.parse(validSubscription) + expect(result.requestId).toBe("sub-001") + expect(result.heatmapRangeDays).toBe(30) + }) + + it("should accept sessionPageSize of 1 (minimum)", () => { + const result = DashboardStatsSubscription.parse({ ...validSubscription, sessionPageSize: 1 }) + expect(result.sessionPageSize).toBe(1) + }) + + it("should accept sessionPageSize of 100 (maximum)", () => { + const result = DashboardStatsSubscription.parse({ ...validSubscription, sessionPageSize: 100 }) + expect(result.sessionPageSize).toBe(100) + }) + + it("should reject sessionPageSize of 0", () => { + expect(() => DashboardStatsSubscription.parse({ ...validSubscription, sessionPageSize: 0 })).toThrow() + }) + + it("should reject sessionPageSize of 101", () => { + expect(() => DashboardStatsSubscription.parse({ ...validSubscription, sessionPageSize: 101 })).toThrow() + }) + + it("should reject non-integer sessionPageSize", () => { + expect(() => DashboardStatsSubscription.parse({ ...validSubscription, sessionPageSize: 50.5 })).toThrow() + }) + + it("should accept heatmapRangeDays of 1 (minimum)", () => { + const result = DashboardStatsSubscription.parse({ ...validSubscription, heatmapRangeDays: 1 }) + expect(result.heatmapRangeDays).toBe(1) + }) + + it("should accept heatmapRangeDays of 365 (maximum)", () => { + const result = DashboardStatsSubscription.parse({ ...validSubscription, heatmapRangeDays: 365 }) + expect(result.heatmapRangeDays).toBe(365) + }) + + it("should reject heatmapRangeDays of 0", () => { + expect(() => DashboardStatsSubscription.parse({ ...validSubscription, heatmapRangeDays: 0 })).toThrow() + }) + + it("should reject heatmapRangeDays of 366", () => { + expect(() => DashboardStatsSubscription.parse({ ...validSubscription, heatmapRangeDays: 366 })).toThrow() + }) + + it("should reject missing requestId", () => { + const { requestId: _req, ...withoutId } = validSubscription + expect(() => DashboardStatsSubscription.parse(withoutId)).toThrow() + }) + + it("should reject missing range", () => { + const { range: _range, ...withoutRange } = validSubscription + expect(() => DashboardStatsSubscription.parse(withoutRange)).toThrow() + }) +}) + +// ── DashboardSessionSummary ────────────────────────────────────────────────── + +describe("DashboardSessionSummary", () => { + it("should parse a valid session summary", () => { + const result = DashboardSessionSummary.parse(validSessionSummary) + expect(result.rootTaskId).toBe("root-task-001") + expect(result.eventCount).toBe(5) + }) + + it("should reject missing rootTaskId", () => { + const { rootTaskId: _id, ...withoutId } = validSessionSummary + expect(() => DashboardSessionSummary.parse(withoutId)).toThrow() + }) + + it("should reject missing title", () => { + const { title: _title, ...withoutTitle } = validSessionSummary + expect(() => DashboardSessionSummary.parse(withoutTitle)).toThrow() + }) + + it("should reject missing totalCost", () => { + const { totalCost: _cost, ...withoutCost } = validSessionSummary + expect(() => DashboardSessionSummary.parse(withoutCost)).toThrow() + }) + + it("should reject missing lastActivity", () => { + const { lastActivity: _act, ...withoutAct } = validSessionSummary + expect(() => DashboardSessionSummary.parse(withoutAct)).toThrow() + }) +}) + +// ── DashboardSessionPage ──────────────────────────────────────────────────── + +describe("DashboardSessionPage", () => { + const validPage = { + requestId: "sub-001", + sessions: [validSessionSummary], + cursor: "next-page-cursor", + totalEstimate: 100, + } + + it("should parse a valid page with cursor", () => { + const result = DashboardSessionPage.parse(validPage) + expect(result.sessions).toHaveLength(1) + expect(result.cursor).toBe("next-page-cursor") + expect(result.totalEstimate).toBe(100) + }) + + it("should parse a valid page without cursor (last page)", () => { + const { cursor: _cursor, ...withoutCursor } = validPage + const result = DashboardSessionPage.parse(withoutCursor) + expect(result.cursor).toBeUndefined() + }) + + it("should accept empty sessions array", () => { + const result = DashboardSessionPage.parse({ ...validPage, sessions: [] }) + expect(result.sessions).toHaveLength(0) + }) + + it("should reject missing requestId", () => { + const { requestId: _req, ...withoutReq } = validPage + expect(() => DashboardSessionPage.parse(withoutReq)).toThrow() + }) + + it("should reject missing totalEstimate", () => { + const { totalEstimate: _est, ...withoutEst } = validPage + expect(() => DashboardSessionPage.parse(withoutEst)).toThrow() + }) +}) + +// ── DashboardTaskSummary / DashboardTaskPage ──────────────────────────────── + +describe("DashboardTaskSummary", () => { + it("should parse a History-first task summary including zero-usage metadata", () => { + const result = DashboardTaskSummary.parse(validTaskSummary) + expect(result.taskId).toBe("task-001") + expect(result.parentTaskId).toBe("parent-task-001") + expect(result.lastUsageAt).toBe(1722259200000) + }) + + it("should accept a zero-usage task without lastUsageAt", () => { + const { lastUsageAt: _lastUsageAt, ...zeroUsageTask } = validTaskSummary + const result = DashboardTaskSummary.parse({ + ...zeroUsageTask, + totalCost: 0, + totalTokens: 0, + eventCount: 0, + model: "", + provider: "", + }) + expect(result.lastUsageAt).toBeUndefined() + expect(result.eventCount).toBe(0) + }) + + it("should reject a negative event count", () => { + expect(() => DashboardTaskSummary.parse({ ...validTaskSummary, eventCount: -1 })).toThrow() + }) + + it("should carry direct child task ids", () => { + const result = DashboardTaskSummary.parse({ ...validTaskSummary, childTaskIds: ["child-1", "child-2"] }) + expect(result.childTaskIds).toEqual(["child-1", "child-2"]) + }) + + it("should reject a summary missing childTaskIds", () => { + const { childTaskIds: _childTaskIds, ...withoutChildTaskIds } = validTaskSummary + expect(() => DashboardTaskSummary.parse(withoutChildTaskIds)).toThrow() + }) +}) + +describe("DashboardTaskPage", () => { + const validPage = { + requestId: "sub-001", + catalogRevision: 4, + tasks: [validTaskSummary], + cursor: "next-task-page", + totalEstimate: 100, + } + + it("should parse a revisioned task page", () => { + const result = DashboardTaskPage.parse(validPage) + expect(result.catalogRevision).toBe(4) + expect(result.tasks).toHaveLength(1) + }) + + it("should accept direct children of the page's root tasks", () => { + const child = { ...validTaskSummary, taskId: "child-1", childTaskIds: [] } + const result = DashboardTaskPage.parse({ ...validPage, childTasks: [child] }) + expect(result.childTasks).toHaveLength(1) + expect(result.childTasks?.[0]?.taskId).toBe("child-1") + }) + + it("should reject a negative catalog revision", () => { + expect(() => DashboardTaskPage.parse({ ...validPage, catalogRevision: -1 })).toThrow() + }) +}) + +describe("DashboardTaskUpsert", () => { + it("should use the full task summary shape so no client join is required", () => { + const result = DashboardTaskUpsert.parse(validTaskSummary) + expect(result.rootTaskId).toBe("root-task-001") + expect(result.taskTimestamp).toBe(1722259100000) + }) +}) + +describe("DashboardTaskDetail", () => { + it("should parse a successful empty detail for a known zero-usage task", () => { + const result = DashboardTaskDetail.parse({ + taskId: "unused-task", + title: "No API usage", + taskTimestamp: 1234, + models: [], + modes: [], + totalTokens: 0, + totalCost: 0, + callCount: 0, + apiCalls: [], + }) + expect(result.apiCalls).toEqual([]) + }) +}) + +// ── HeatmapSnapshot ──────────────────────────────────────────────────────── + +describe("HeatmapSnapshot", () => { + it("should parse a valid heatmap", () => { + const result = HeatmapSnapshot.parse({ rangeDays: 30, values: [0.1, 0.2, 0.3] }) + expect(result.rangeDays).toBe(30) + expect(result.values).toHaveLength(3) + }) + + it("should accept empty values array", () => { + const result = HeatmapSnapshot.parse({ rangeDays: 30, values: [] }) + expect(result.values).toHaveLength(0) + }) + + it("should reject rangeDays of 0", () => { + expect(() => HeatmapSnapshot.parse({ rangeDays: 0, values: [] })).toThrow() + }) + + it("should reject missing values", () => { + const { values: _v, ...withoutValues } = { rangeDays: 30, values: [1] } + expect(() => HeatmapSnapshot.parse(withoutValues)).toThrow() + }) +}) + +// ── StatsBucketDelta ──────────────────────────────────────────────────────── + +describe("StatsBucketDelta", () => { + const validDelta = { + key: { day: "2026-07-29" }, + events: 1, + completedCalls: 1, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 100, + cacheWriteTokens: 50, + reasoningTokens: 0, + totalTokens: 700, + costUsd: 0.01, + unknownEventCount: 0, + } + + it("should parse a valid delta with positive values", () => { + const result = StatsBucketDelta.parse(validDelta) + expect(result.events).toBe(1) + expect(result.costUsd).toBe(0.01) + }) + + it("should accept negative values (correction/reset)", () => { + const result = StatsBucketDelta.parse({ + ...validDelta, + events: -1, + costUsd: -0.01, + inputTokens: -500, + }) + expect(result.events).toBe(-1) + expect(result.costUsd).toBe(-0.01) + expect(result.inputTokens).toBe(-500) + }) + + it("should accept zero values", () => { + const result = StatsBucketDelta.parse({ + ...validDelta, + events: 0, + costUsd: 0, + }) + expect(result.events).toBe(0) + }) + + it("should reject missing key", () => { + const { key: _key, ...withoutKey } = validDelta + expect(() => StatsBucketDelta.parse(withoutKey)).toThrow() + }) + + it("should reject missing costUsd", () => { + const { costUsd: _cost, ...withoutCost } = validDelta + expect(() => StatsBucketDelta.parse(withoutCost)).toThrow() + }) +}) + +// ── DashboardSessionUpsert ────────────────────────────────────────────────── + +describe("DashboardSessionUpsert", () => { + it("should parse a valid upsert", () => { + const result = DashboardSessionUpsert.parse(validSessionSummary) + expect(result.rootTaskId).toBe("root-task-001") + expect(result.eventCount).toBe(5) + }) + + it("should reject missing rootTaskId", () => { + const { rootTaskId: _id, ...withoutId } = validSessionSummary + expect(() => DashboardSessionUpsert.parse(withoutId)).toThrow() + }) + + it("should reject missing eventCount", () => { + const { eventCount: _count, ...withoutCount } = validSessionSummary + expect(() => DashboardSessionUpsert.parse(withoutCount)).toThrow() + }) +}) + +// ── DashboardStatsSnapshot ────────────────────────────────────────────────── + +describe("DashboardStatsSnapshot", () => { + const validSessionPage = { + requestId: "sub-001", + sessions: [validSessionSummary], + cursor: "next-cursor", + totalEstimate: 50, + } + + const validHeatmap = { + rangeDays: 30, + values: [0.1, 0.2, 0.3], + } + + const validSnapshot = { + requestId: "sub-001", + generation: 1, + sequence: 100, + stats: validStatsSnapshot, + sessions: validSessionPage, + cursor: "next-cursor", + heatmap: validHeatmap, + } + + it("should parse a valid snapshot", () => { + const result = DashboardStatsSnapshot.parse(validSnapshot) + expect(result.requestId).toBe("sub-001") + expect(result.generation).toBe(1) + expect(result.sequence).toBe(100) + expect(result.stats.buckets).toHaveLength(1) + expect(result.sessions.sessions).toHaveLength(1) + expect(result.heatmap.values).toHaveLength(3) + }) + + it("should accept snapshot without cursor (last page)", () => { + const { cursor: _cursor, ...withoutCursor } = validSnapshot + const result = DashboardStatsSnapshot.parse(withoutCursor) + expect(result.cursor).toBeUndefined() + }) + + it("should reject missing generation", () => { + const { generation: _gen, ...withoutGen } = validSnapshot + expect(() => DashboardStatsSnapshot.parse(withoutGen)).toThrow() + }) + + it("should reject missing sequence", () => { + const { sequence: _seq, ...withoutSeq } = validSnapshot + expect(() => DashboardStatsSnapshot.parse(withoutSeq)).toThrow() + }) + + it("should reject non-integer generation", () => { + expect(() => DashboardStatsSnapshot.parse({ ...validSnapshot, generation: 1.5 })).toThrow() + }) + + it("should reject non-integer sequence", () => { + expect(() => DashboardStatsSnapshot.parse({ ...validSnapshot, sequence: 100.5 })).toThrow() + }) + + it("should reject missing stats", () => { + const { stats: _stats, ...withoutStats } = validSnapshot + expect(() => DashboardStatsSnapshot.parse(withoutStats)).toThrow() + }) + + it("should reject missing heatmap", () => { + const { heatmap: _heatmap, ...withoutHeatmap } = validSnapshot + expect(() => DashboardStatsSnapshot.parse(withoutHeatmap)).toThrow() + }) + + it("should reject missing sessions", () => { + const { sessions: _sessions, ...withoutSessions } = validSnapshot + expect(() => DashboardStatsSnapshot.parse(withoutSessions)).toThrow() + }) +}) + +// ── DashboardStatsDelta ───────────────────────────────────────────────────── + +describe("DashboardStatsDelta", () => { + const validBucketDelta = { + key: { day: "2026-07-29" }, + events: 1, + completedCalls: 1, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 100, + cacheWriteTokens: 50, + reasoningTokens: 0, + totalTokens: 700, + costUsd: 0.01, + unknownEventCount: 0, + } + + const validDelta = { + requestId: "sub-001", + generation: 1, + sequence: 101, + totalDelta: validBucketDelta, + breakdownDelta: [validBucketDelta], + heatmapDayDelta: { + dayIndex: 28, + delta: 0.01, + }, + sessionUpsert: [validSessionSummary], + } + + it("should parse a valid delta with all fields", () => { + const result = DashboardStatsDelta.parse(validDelta) + expect(result.requestId).toBe("sub-001") + expect(result.generation).toBe(1) + expect(result.sequence).toBe(101) + expect(result.totalDelta.events).toBe(1) + expect(result.breakdownDelta).toHaveLength(1) + expect(result.heatmapDayDelta?.dayIndex).toBe(28) + expect(result.sessionUpsert).toHaveLength(1) + }) + + it("should parse a delta without heatmapDayDelta", () => { + const { heatmapDayDelta: _h, ...withoutHeatmap } = validDelta + const result = DashboardStatsDelta.parse(withoutHeatmap) + expect(result.heatmapDayDelta).toBeUndefined() + }) + + it("should parse a delta with empty breakdownDelta", () => { + const result = DashboardStatsDelta.parse({ ...validDelta, breakdownDelta: [] }) + expect(result.breakdownDelta).toHaveLength(0) + }) + + it("should parse a delta with empty sessionUpsert", () => { + const result = DashboardStatsDelta.parse({ ...validDelta, sessionUpsert: [] }) + expect(result.sessionUpsert).toHaveLength(0) + }) + + it("should accept negative delta values (correction)", () => { + const result = DashboardStatsDelta.parse({ + ...validDelta, + totalDelta: { ...validBucketDelta, events: -1, costUsd: -0.01 }, + }) + expect(result.totalDelta.events).toBe(-1) + expect(result.totalDelta.costUsd).toBe(-0.01) + }) + + it("should reject missing generation", () => { + const { generation: _gen, ...withoutGen } = validDelta + expect(() => DashboardStatsDelta.parse(withoutGen)).toThrow() + }) + + it("should reject missing sequence", () => { + const { sequence: _seq, ...withoutSeq } = validDelta + expect(() => DashboardStatsDelta.parse(withoutSeq)).toThrow() + }) + + it("should reject non-integer generation", () => { + expect(() => DashboardStatsDelta.parse({ ...validDelta, generation: 1.5 })).toThrow() + }) + + it("should reject non-integer sequence", () => { + expect(() => DashboardStatsDelta.parse({ ...validDelta, sequence: 101.5 })).toThrow() + }) + + it("should reject missing totalDelta", () => { + const { totalDelta: _td, ...withoutTotal } = validDelta + expect(() => DashboardStatsDelta.parse(withoutTotal)).toThrow() + }) + + it("should reject missing breakdownDelta", () => { + const { breakdownDelta: _bd, ...withoutBreakdown } = validDelta + expect(() => DashboardStatsDelta.parse(withoutBreakdown)).toThrow() + }) + + it("should reject missing sessionUpsert", () => { + const { sessionUpsert: _su, ...withoutUpsert } = validDelta + expect(() => DashboardStatsDelta.parse(withoutUpsert)).toThrow() + }) + + it("should reject negative dayIndex in heatmapDayDelta", () => { + expect(() => + DashboardStatsDelta.parse({ + ...validDelta, + heatmapDayDelta: { dayIndex: -1, delta: 0.01 }, + }), + ).toThrow() + }) + + it("should reject non-integer dayIndex in heatmapDayDelta", () => { + expect(() => + DashboardStatsDelta.parse({ + ...validDelta, + heatmapDayDelta: { dayIndex: 1.5, delta: 0.01 }, + }), + ).toThrow() + }) +}) + +// ── DashboardStatsError ───────────────────────────────────────────────────── + +describe("DashboardStatsError", () => { + const validError = { + requestId: "sub-001", + code: "STATS_STREAM/subscribe/001", + message: "Invalid subscription payload", + } + + it("should parse a valid error", () => { + const result = DashboardStatsError.parse(validError) + expect(result.code).toBe("STATS_STREAM/subscribe/001") + expect(result.message).toBe("Invalid subscription payload") + }) + + it("should reject missing requestId", () => { + const { requestId: _req, ...withoutReq } = validError + expect(() => DashboardStatsError.parse(withoutReq)).toThrow() + }) + + it("should reject missing code", () => { + const { code: _code, ...withoutCode } = validError + expect(() => DashboardStatsError.parse(withoutCode)).toThrow() + }) + + it("should reject missing message", () => { + const { message: _msg, ...withoutMsg } = validError + expect(() => DashboardStatsError.parse(withoutMsg)).toThrow() + }) +}) + +// ── Serialization round-trip ──────────────────────────────────────────────── + +describe("serialization round trips", () => { + it("should round-trip DashboardStatsSnapshot through JSON", () => { + const validSessionPage = { + requestId: "sub-001", + sessions: [validSessionSummary], + cursor: "next-cursor", + totalEstimate: 50, + } + const snapshot = { + requestId: "sub-001", + generation: 1, + sequence: 100, + stats: validStatsSnapshot, + sessions: validSessionPage, + cursor: "next-cursor", + heatmap: { rangeDays: 30, values: [0.1, 0.2] }, + } + const json = JSON.stringify(snapshot) + const parsed = JSON.parse(json) + const result = DashboardStatsSnapshot.parse(parsed) + expect(result.sequence).toBe(100) + expect(result.heatmap.values).toHaveLength(2) + }) + + it("should round-trip DashboardStatsDelta through JSON", () => { + const bucketDelta = { + key: { day: "2026-07-29" }, + events: 1, + completedCalls: 1, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 100, + cacheWriteTokens: 50, + reasoningTokens: 0, + totalTokens: 700, + costUsd: 0.01, + unknownEventCount: 0, + } + const delta = { + requestId: "sub-001", + generation: 1, + sequence: 101, + totalDelta: bucketDelta, + breakdownDelta: [bucketDelta], + heatmapDayDelta: { dayIndex: 28, delta: 0.01 }, + sessionUpsert: [validSessionSummary], + } + const json = JSON.stringify(delta) + const parsed = JSON.parse(json) + const result = DashboardStatsDelta.parse(parsed) + expect(result.sequence).toBe(101) + expect(result.heatmapDayDelta?.delta).toBe(0.01) + }) + + it("should round-trip DashboardStatsError through JSON", () => { + const error = { + requestId: "sub-001", + code: "STATS_STREAM/query/001", + message: "Snapshot query failed", + } + const json = JSON.stringify(error) + const parsed = JSON.parse(json) + const result = DashboardStatsError.parse(parsed) + expect(result.code).toBe("STATS_STREAM/query/001") + }) + + it("should round-trip DashboardSessionPage through JSON", () => { + const page = { + requestId: "sub-001", + sessions: [validSessionSummary], + cursor: "next-cursor", + totalEstimate: 50, + } + const json = JSON.stringify(page) + const parsed = JSON.parse(json) + const result = DashboardSessionPage.parse(parsed) + expect(result.sessions).toHaveLength(1) + expect(result.totalEstimate).toBe(50) + }) + + it("should round-trip task snapshot, delta, page, and detail through JSON", () => { + const taskPage = { + requestId: "sub-001", + catalogRevision: 7, + tasks: [validTaskSummary], + cursor: "next-task-cursor", + totalEstimate: 50, + } + const bucketDelta = { + key: { day: "2026-07-29" }, + events: 1, + completedCalls: 1, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 100, + cacheWriteTokens: 50, + reasoningTokens: 0, + totalTokens: 700, + costUsd: 0.01, + unknownEventCount: 0, + } + const snapshot = { + requestId: "sub-001", + generation: 1, + sequence: 100, + stats: validStatsSnapshot, + tasks: taskPage, + cursor: taskPage.cursor, + heatmap: { rangeDays: 30, values: [0.1, 0.2] }, + } + const delta = { + requestId: "sub-001", + generation: 1, + sequence: 101, + totalDelta: bucketDelta, + breakdownDelta: [bucketDelta], + taskUpsert: [validTaskSummary], + } + const detail = { + taskId: "task-001", + title: "Fix task projection", + taskTimestamp: 1722259100000, + models: ["claude-sonnet-4-20250514"], + modes: ["code"], + totalTokens: 12000, + totalCost: 0.15, + callCount: 1, + apiCalls: [ + { + index: 1, + mode: "code", + timestamp: 1722259200000, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1000, + cacheWriteTokens: 500, + reasoningTokens: 200, + costUsd: 0.15, + status: "completed", + model: "claude-sonnet-4-20250514", + }, + ], + } + + expect(DashboardTaskPage.parse(JSON.parse(JSON.stringify(taskPage))).catalogRevision).toBe(7) + expect(DashboardTaskStatsSnapshot.parse(JSON.parse(JSON.stringify(snapshot))).tasks.tasks).toHaveLength(1) + expect(DashboardTaskStatsDelta.parse(JSON.parse(JSON.stringify(delta))).taskUpsert).toHaveLength(1) + expect(DashboardTaskDetail.parse(JSON.parse(JSON.stringify(detail))).apiCalls).toHaveLength(1) + }) +}) diff --git a/packages/types/src/__tests__/usage-stats.spec.ts b/packages/types/src/__tests__/usage-stats.spec.ts new file mode 100644 index 0000000000..7392852f24 --- /dev/null +++ b/packages/types/src/__tests__/usage-stats.spec.ts @@ -0,0 +1,401 @@ +import { + UsageEventStatus, + UsageValueSource, + InclusionRule, + SourcedNumber, + UsageEventV1, + StatsQuery, + StatsBucket, + StatsSnapshot, + DashboardTaskSummary, +} from "../usage-stats.js" +import { providerIdentifiers } from "../provider-identifiers.js" + +describe("usage-stats schemas", () => { + // ── Enums ──────────────────────────────────────────────────────────── + + describe("UsageEventStatus", () => { + it("should accept all valid statuses", () => { + expect(UsageEventStatus.parse("completed")).toBe("completed") + expect(UsageEventStatus.parse("failed")).toBe("failed") + expect(UsageEventStatus.parse("cancelled")).toBe("cancelled") + }) + + it("should reject invalid status", () => { + expect(() => UsageEventStatus.parse("success")).toThrow() + }) + }) + + describe("UsageValueSource", () => { + it("should accept all valid sources", () => { + expect(UsageValueSource.parse("provider")).toBe("provider") + expect(UsageValueSource.parse("estimated")).toBe("estimated") + expect(UsageValueSource.parse("backfilled")).toBe("backfilled") + }) + + it("should reject invalid source", () => { + expect(() => UsageValueSource.parse("guessed")).toThrow() + }) + }) + + describe("InclusionRule", () => { + it("should accept all valid rules", () => { + expect(InclusionRule.parse("included")).toBe("included") + expect(InclusionRule.parse("excluded")).toBe("excluded") + expect(InclusionRule.parse("unknown")).toBe("unknown") + }) + }) + + // ── SourcedNumber ───────────────────────────────────────────────────── + + describe("SourcedNumber", () => { + it("should parse a valid SourcedNumber", () => { + const result = SourcedNumber.parse({ value: 42, source: "provider" }) + expect(result).toEqual({ value: 42, source: "provider" }) + }) + + it("should reject missing source", () => { + expect(() => SourcedNumber.parse({ value: 42 })).toThrow() + }) + + it("should reject missing value", () => { + expect(() => SourcedNumber.parse({ source: "estimated" })).toThrow() + }) + }) + + // ── UsageEventV1 ──────────────────────────────────────────────────────── + + describe("UsageEventV1", () => { + const validEvent = { + schemaVersion: 1, + eventId: "evt-001", + idempotencyKey: "idem-001", + occurredAt: "2026-07-18T12:00:00.000Z", + timezoneOffsetMinutes: -540, + status: "completed", + attempt: 1, + taskId: "task-001", + provider: providerIdentifiers.anthropic, + model: "claude-sonnet-4-20250514", + mode: "code", + usage: { + inputTokens: { value: 1000, source: "provider" }, + outputTokens: { value: 500, source: "provider" }, + costUsd: { value: 0.015, source: "provider" }, + }, + semantics: { + cacheReadInInput: "included", + cacheWriteInInput: "included", + reasoningInOutput: "excluded", + }, + provenance: "live", + } + + it("should parse a valid complete event", () => { + const result = UsageEventV1.parse(validEvent) + expect(result.eventId).toBe("evt-001") + expect(result.schemaVersion).toBe(1) + expect(result.usage.inputTokens?.value).toBe(1000) + }) + + it("should accept optional parentTaskId", () => { + const result = UsageEventV1.parse({ ...validEvent, parentTaskId: "task-000" }) + expect(result.parentTaskId).toBe("task-000") + }) + + it("should work without optional usage fields", () => { + const minimal = { ...validEvent, usage: {} } + const result = UsageEventV1.parse(minimal) + expect(result.usage.inputTokens).toBeUndefined() + }) + + it("should accept backfilled provenance", () => { + const result = UsageEventV1.parse({ ...validEvent, provenance: "history-backfill" }) + expect(result.provenance).toBe("history-backfill") + }) + + it("should reject schemaVersion !== 1", () => { + expect(() => UsageEventV1.parse({ ...validEvent, schemaVersion: 2 })).toThrow() + }) + + it("should reject missing semantics", () => { + const { semantics: _semantics, ...withoutSemantics } = validEvent + expect(() => UsageEventV1.parse(withoutSemantics)).toThrow() + }) + + it("should reject invalid provenance", () => { + expect(() => UsageEventV1.parse({ ...validEvent, provenance: "imported" })).toThrow() + }) + + it("should reject missing required fields (eventId)", () => { + const { eventId: _eventId, ...withoutEventId } = validEvent + expect(() => UsageEventV1.parse(withoutEventId)).toThrow() + }) + + it("should accept attempt of 0 (no min constraint in V1)", () => { + // z.number() accepts negatives, but attempt should be >= 0 logically + // This test confirms the schema accepts any number (no min constraint in V1) + const result = UsageEventV1.parse({ ...validEvent, attempt: 0 }) + expect(result.attempt).toBe(0) + }) + + it("should accept optional rootTaskId (dashboard streaming)", () => { + const result = UsageEventV1.parse({ ...validEvent, rootTaskId: "root-task-001" }) + expect(result.rootTaskId).toBe("root-task-001") + }) + + it("should work without rootTaskId (backward compatible)", () => { + const result = UsageEventV1.parse(validEvent) + expect(result.rootTaskId).toBeUndefined() + }) + }) + + // ── StatsQuery ─────────────────────────────────────────────────────── + + describe("StatsQuery", () => { + it("should parse a valid query with preset", () => { + const result = StatsQuery.parse({ + preset: "7d", + timezone: "Asia/Seoul", + groupBy: ["day"], + }) + expect(result.preset).toBe("7d") + expect(result.includeCancelled).toBe(false) // default + }) + + it("should parse a query with from/to range", () => { + const result = StatsQuery.parse({ + from: "2026-07-01T00:00:00Z", + to: "2026-07-18T00:00:00Z", + timezone: "UTC", + groupBy: ["provider", "model"], + }) + expect(result.from).toBe("2026-07-01T00:00:00Z") + expect(result.groupBy).toHaveLength(2) + }) + + it("should default includeCancelled to false", () => { + const result = StatsQuery.parse({ + timezone: "UTC", + groupBy: [], + }) + expect(result.includeCancelled).toBe(false) + }) + + it("should accept includeCancelled: true", () => { + const result = StatsQuery.parse({ + timezone: "UTC", + groupBy: [], + includeCancelled: true, + }) + expect(result.includeCancelled).toBe(true) + }) + + it("should reject more than 3 groupBy dimensions", () => { + expect(() => + StatsQuery.parse({ + timezone: "UTC", + groupBy: ["day", "week", "month", "provider"], + }), + ).toThrow() + }) + + it("should reject invalid preset", () => { + expect(() => + StatsQuery.parse({ + preset: "90d", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + }) + + it("should reject missing timezone", () => { + expect(() => + StatsQuery.parse({ + groupBy: [], + }), + ).toThrow() + }) + + it("should reject unparseable from/to datetimes", () => { + expect(() => + StatsQuery.parse({ + from: "not-a-date", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + expect(() => + StatsQuery.parse({ + to: "2026-13-99T99:99:99Z", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + }) + + it("should reject invalid groupBy dimension", () => { + expect(() => + StatsQuery.parse({ + timezone: "UTC", + groupBy: ["hour"], + }), + ).toThrow() + }) + }) + + // ── StatsBucket ────────────────────────────────────────────────────── + + describe("StatsBucket", () => { + const validBucket = { + key: { day: "2026-07-18" }, + events: 10, + completedCalls: 8, + failedCalls: 1, + cancelledCalls: 1, + inputTokens: 5000, + outputTokens: 2500, + cacheReadTokens: 1000, + cacheWriteTokens: 500, + reasoningTokens: 200, + totalTokens: 7500, + costUsd: 0.075, + unknownEventCount: 0, + } + + it("should parse a valid bucket", () => { + const result = StatsBucket.parse(validBucket) + expect(result.events).toBe(10) + expect(result.key.day).toBe("2026-07-18") + }) + + it("should reject missing required numeric field", () => { + const { costUsd: _costUsd, ...withoutCost } = validBucket + expect(() => StatsBucket.parse(withoutCost)).toThrow() + }) + + it("should accept empty key record", () => { + const result = StatsBucket.parse({ ...validBucket, key: {} }) + expect(Object.keys(result.key)).toHaveLength(0) + }) + }) + + // ── StatsSnapshot ───────────────────────────────────────────────────── + + describe("StatsSnapshot", () => { + const validQuery = { + timezone: "UTC", + groupBy: ["day"], + } + const validBucket = { + key: { day: "2026-07-18" }, + events: 5, + completedCalls: 4, + failedCalls: 1, + cancelledCalls: 0, + inputTokens: 2000, + outputTokens: 1000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 3000, + costUsd: 0.03, + unknownEventCount: 0, + } + const validSnapshot = { + query: validQuery, + generatedAt: "2026-07-18T12:00:00.000Z", + buckets: [validBucket], + totals: validBucket, + coverage: { + firstEventAt: "2026-07-01T00:00:00.000Z", + lastEventAt: "2026-07-18T12:00:00.000Z", + recordingPaused: false, + backfilledEventCount: 0, + }, + } + + it("should parse a valid snapshot", () => { + const result = StatsSnapshot.parse(validSnapshot) + expect(result.buckets).toHaveLength(1) + expect(result.coverage.recordingPaused).toBe(false) + }) + + it("should accept empty buckets array", () => { + const result = StatsSnapshot.parse({ ...validSnapshot, buckets: [] }) + expect(result.buckets).toHaveLength(0) + }) + + it("should accept optional firstEventAt/lastEventAt omitted", () => { + const result = StatsSnapshot.parse({ + ...validSnapshot, + coverage: { + recordingPaused: true, + backfilledEventCount: 0, + }, + }) + expect(result.coverage.firstEventAt).toBeUndefined() + expect(result.coverage.lastEventAt).toBeUndefined() + }) + + it("should reject missing coverage", () => { + const { coverage: _coverage, ...withoutCoverage } = validSnapshot + expect(() => StatsSnapshot.parse(withoutCoverage)).toThrow() + }) + + it("should reject missing totals", () => { + const { totals: _totals, ...withoutTotals } = validSnapshot + expect(() => StatsSnapshot.parse(withoutTotals)).toThrow() + }) + }) + + // ── DashboardTaskSummary ────────────────────────────────────────────── + + describe("DashboardTaskSummary", () => { + const validSummary = { + taskId: "task-001", + rootTaskId: "task-001", + title: "Root task", + taskTimestamp: 1722259100000, + totalCost: 0.15, + totalTokens: 12000, + inputTokens: 8000, + outputTokens: 4000, + model: "claude-sonnet-4-20250514", + provider: providerIdentifiers.anthropic, + models: ["claude-sonnet-4-20250514", "gpt-4"], + modes: ["code", "architect"], + eventCount: 5, + childTaskIds: [], + } + + it("should accept the subtree identity aggregate fields", () => { + const result = DashboardTaskSummary.parse(validSummary) + expect(result.inputTokens).toBe(8000) + expect(result.outputTokens).toBe(4000) + expect(result.models).toEqual(["claude-sonnet-4-20250514", "gpt-4"]) + expect(result.modes).toEqual(["code", "architect"]) + }) + + it("should accept zero tokens and empty model/mode lists for unused tasks", () => { + const result = DashboardTaskSummary.parse({ + ...validSummary, + inputTokens: 0, + outputTokens: 0, + models: [], + modes: [], + }) + expect(result.inputTokens).toBe(0) + expect(result.outputTokens).toBe(0) + expect(result.models).toEqual([]) + expect(result.modes).toEqual([]) + }) + + it.each(["inputTokens", "outputTokens", "models", "modes"] as const)("should reject a missing %s", (field) => { + const without = { ...validSummary } as Record + delete without[field] + expect(() => DashboardTaskSummary.parse(without)).toThrow() + }) + }) +}) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82588ae537..2ad040df8d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,6 +23,7 @@ export * from "./provider-settings.js" export * from "./task.js" export * from "./todo.js" export * from "./skills.js" +export * from "./usage-stats.js" export * from "./rules.js" export * from "./marketplace.js" export * from "./telemetry.js" diff --git a/packages/types/src/usage-stats.ts b/packages/types/src/usage-stats.ts new file mode 100644 index 0000000000..362cbf3ab0 --- /dev/null +++ b/packages/types/src/usage-stats.ts @@ -0,0 +1,555 @@ +import { z } from "zod" + +// ── Enums ────────────────────────────────────────────────────────────────── + +/** Final status of an LLM API call */ +export const UsageEventStatus = z.enum(["completed", "failed", "cancelled"]) +export type UsageEventStatus = z.infer + +/** Source of a token usage value */ +export const UsageValueSource = z.enum(["provider", "estimated", "backfilled"]) +export type UsageValueSource = z.infer + +/** Whether a token field is double-counted (e.g. cacheRead included in inputTokens) */ +export const InclusionRule = z.enum(["included", "excluded", "unknown"]) +export type InclusionRule = z.infer + +// ── SourcedNumber ────────────────────────────────────────────────────────── + +/** A numeric value paired with its source */ +export const SourcedNumber = z.object({ + value: z.number(), + source: UsageValueSource, +}) +export type SourcedNumber = z.infer + +// ── UsageEventV1 ──────────────────────────────────────────────────────────── + +/** + * A usage event for a single LLM API call. + * schemaVersion 1 — bump when the schema changes. + * + * Security: prompt bodies, response bodies, API keys, and workspace paths + * must never be included in this schema. + */ +export const UsageEventV1 = z.object({ + schemaVersion: z.literal(1), + eventId: z.string(), + idempotencyKey: z.string(), + occurredAt: z.string(), // ISO 8601 UTC + timezoneOffsetMinutes: z.number(), + status: UsageEventStatus, + attempt: z.number(), + taskId: z.string(), + parentTaskId: z.string().optional(), + /** + * Stable root-session identity for dashboard streaming. + * Resolved from the task hierarchy by the recorder; migration resolves + * legacy parent chains with the existing cycle guard. Absent on events + * recorded before this field was introduced (backward compatible). + */ + rootTaskId: z.string().optional(), + provider: z.string(), + model: z.string(), + mode: z.string(), + /** + * Domain extracted from the provider's custom base URL (e.g. "kimi.ai", + * "localhost:1234"). Only set when the user configured a custom base URL + * that differs from the provider's default. Absent for default endpoints + * and for providers without a base URL field. Backward compatible: + * events recorded before this field was introduced remain valid. + */ + endpoint: z.string().optional(), + /** + * Snapshot of the model's pricing info at capture time. Only populated + * for models NOT in the static provider registry (i.e. custom/user- + * configured models). The dashboard query path uses this to compute + * cost and cacheRatio discounts when the static registry lookup fails. + * Backward compatible: events recorded before this field was introduced + * remain valid (query path falls back to static registry, then to 0). + */ + modelPricing: z + .object({ + inputPrice: z.number().optional(), + outputPrice: z.number().optional(), + cacheWritesPrice: z.number().optional(), + cacheReadsPrice: z.number().optional(), + }) + .optional(), + usage: z.object({ + inputTokens: SourcedNumber.optional(), + outputTokens: SourcedNumber.optional(), + cacheWriteTokens: SourcedNumber.optional(), + cacheReadTokens: SourcedNumber.optional(), + reasoningTokens: SourcedNumber.optional(), + totalTokens: SourcedNumber.optional(), + costUsd: SourcedNumber.optional(), + }), + semantics: z.object({ + cacheReadInInput: InclusionRule, + cacheWriteInInput: InclusionRule, + reasoningInOutput: InclusionRule, + }), + provenance: z.enum(["live", "history-backfill"]), +}) +export type UsageEventV1 = z.infer + +// ── StatsQuery ────────────────────────────────────────────────────────────── + +/** Statistics query */ +export const StatsQuery = z.object({ + // ISO 8601 datetimes; unparseable values are rejected so downstream + // range math never sees NaN epoch bounds. + from: z + .string() + .refine((v) => !Number.isNaN(new Date(v).getTime()), { message: "from must be a parseable datetime" }) + .optional(), + to: z + .string() + .refine((v) => !Number.isNaN(new Date(v).getTime()), { message: "to must be a parseable datetime" }) + .optional(), + preset: z.enum(["today", "7d", "30d", "all"]).optional(), + timezone: z.string(), // IANA + groupBy: z.array(z.enum(["day", "week", "month", "provider", "model", "mode", "status", "source"])).max(3), + includeCancelled: z.boolean().default(false), + /** + * Cache ratio for estimation when provider doesn't report cacheReadTokens. + * Default: 0.94 (94% of input tokens are estimated as cached) + * Range: 0.0 to 1.0 + */ + cacheRatio: z.number().min(0).max(1).optional(), +}) +export type StatsQuery = z.infer + +// ── StatsBucket ────────────────────────────────────────────────────────────── + +/** Grouped statistics bucket */ +export const StatsBucket = z.object({ + key: z.record(z.string()), + events: z.number(), + completedCalls: z.number(), + failedCalls: z.number(), + cancelledCalls: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + reasoningTokens: z.number(), + totalTokens: z.number(), + costUsd: z.number(), + unknownEventCount: z.number(), +}) +export type StatsBucket = z.infer + +// ── StatsSnapshot ──────────────────────────────────────────────────────────── + +/** Statistics query result snapshot */ +export const StatsSnapshot = z.object({ + query: StatsQuery, + generatedAt: z.string(), + buckets: z.array(StatsBucket), + totals: StatsBucket, + coverage: z.object({ + firstEventAt: z.string().optional(), + lastEventAt: z.string().optional(), + recordingPaused: z.boolean(), + backfilledEventCount: z.number(), + }), +}) +export type StatsSnapshot = z.infer + +// ── SessionSummary / SessionDetail / APICallRecord ────────────────────────── + +/** + * A summary of a single task session, aggregated from all usage events that + * share the same `taskId`. Used by the Dashboard "Sessions" list. + * + * Security: does not include prompt bodies, response bodies, API keys, or + * workspace paths. The `title` is derived from the first user message text + * (truncated); if unavailable, falls back to the taskId. + */ +export interface SessionSummary { + taskId: string + title: string // First line of user input (truncated); falls back to taskId + timestamp: number // Last activity (epoch ms) + model: string // First-seen model (kept for backward compatibility) + provider: string + mode: string // First-seen mode (kept for backward compatibility) + /** + * All unique models used in the session, in first-seen order. + * A session may switch models (e.g. orchestrator delegating to a + * different provider), so this array captures the full set while + * `model` retains the earliest value for backward compatibility. + */ + models: string[] + /** + * All unique modes used in the session, in first-seen order. + * A session may span multiple modes (e.g. orchestrator-crow + * delegating to code, debug, ask), so this array captures the full + * set while `mode` retains the earliest value for backward compat. + */ + modes: string[] + totalTokens: number + totalCost: number + callCount: number +} + +/** + * Detailed view of a single session, including the per-API-call records. + * Used by the Dashboard session detail expansion (Commit 4). + */ +export interface SessionDetail extends SessionSummary { + apiCalls: APICallRecord[] +} + +/** + * A single API call record within a session, used in `SessionDetail.apiCalls`. + */ +export interface APICallRecord { + index: number + mode: string + timestamp: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + reasoningTokens: number + costUsd: number + status: "completed" | "failed" | "cancelled" + model: string +} + +// ── Dashboard Streaming Protocol ──────────────────────────────────────────── +// +// The types below define the versioned dashboard subscription protocol. +// Runtime validation (Zod) is required for every webview-originated query. +// See docs/260729_0001_session_branch-recovery/dashboard-streaming-architecture.md +// for the full specification. + +/** + * Cursor-paged session page request. + * The cursor is host-issued, query-bound, and invalid after a generation or + * query change. + */ +export const DashboardSessionPageRequest = z.object({ + /** Page size, 1–100. Default 50. */ + limit: z.number().int().min(1).max(100).default(50), + /** Opaque cursor returned by the host. Absent for the first page. */ + cursor: z.string().optional(), +}) +export type DashboardSessionPageRequest = z.infer + +/** + * Subscribe request for the dashboard stats stream. + * Contains the main stats query, heatmap range, and session page request. + */ +export const DashboardStatsSubscription = z.object({ + /** Correlation ID for the subscription request. */ + requestId: z.string(), + /** Main dashboard time range query. */ + range: StatsQuery, + /** Maximum sessions per page (1–100). */ + sessionPageSize: z.number().int().min(1).max(100).default(50), + /** Number of days for the heatmap (30, 60, 120, 360). */ + heatmapRangeDays: z.number().int().min(1).max(365), +}) +export type DashboardStatsSubscription = z.infer + +/** + * A single session row in the dashboard sessions list. + * Derived from aggregated usage events sharing the same root task. + * + * Security: does not include prompt bodies, response bodies, API keys, or + * workspace paths. The `title` is derived from the first user message text + * (truncated); if unavailable, falls back to the rootTaskId. + */ +export const DashboardSessionSummary = z.object({ + rootTaskId: z.string(), + title: z.string(), + totalCost: z.number(), + totalTokens: z.number(), + model: z.string(), + provider: z.string(), + /** Last activity timestamp (epoch ms). */ + lastActivity: z.number(), + /** Number of API calls in this session. */ + eventCount: z.number(), +}) +export type DashboardSessionSummary = z.infer + +/** + * Cursor-paged session list response. + */ +export const DashboardSessionPage = z.object({ + /** Correlation ID matching the subscription request. */ + requestId: z.string(), + /** Session summaries for this page (at most `sessionPageSize` items). */ + sessions: z.array(DashboardSessionSummary), + /** Opaque cursor for the next page. Absent if this is the last page. */ + cursor: z.string().optional(), + /** Estimated total session count (may be approximate). */ + totalEstimate: z.number().int(), +}) +export type DashboardSessionPage = z.infer + +// ── Dashboard Task Protocol ──────────────────────────────────────────────── +// +// Task rows are History-first: identity, title, timestamp, and hierarchy come +// from History, while the numeric metrics are composed from direct task usage. +// Each row intentionally represents the task's whole subtree, so parent and +// child rows must not be summed together for dashboard-wide totals. + +/** + * One History task and its aggregate usage for the task plus all descendants. + * A missing usage row is represented by zero metrics, empty `models`/`modes` + * lists, and no `lastUsageAt`. + */ +export const DashboardTaskSummary = z.object({ + taskId: z.string(), + rootTaskId: z.string(), + parentTaskId: z.string().optional(), + title: z.string(), + /** History task timestamp (epoch ms), not a usage timestamp. */ + taskTimestamp: z.number(), + /** Latest usage timestamp in this task's subtree (epoch ms). */ + lastUsageAt: z.number().optional(), + totalCost: z.number(), + totalTokens: z.number(), + /** Subtree-aggregated input tokens. */ + inputTokens: z.number(), + /** Subtree-aggregated output tokens. */ + outputTokens: z.number(), + /** Provider/model from the latest usage row in the subtree, or empty when unused. */ + model: z.string(), + provider: z.string(), + /** Distinct models used in the subtree, in first-seen order. */ + models: z.array(z.string()), + /** Distinct modes used in the subtree, in first-seen order. */ + modes: z.array(z.string()), + eventCount: z.number().int().nonnegative(), + /** Direct children in catalog order; empty for childless tasks. */ + childTaskIds: z.array(z.string()), +}) +export type DashboardTaskSummary = z.infer + +/** Cursor-paged History task list for one immutable catalog revision. */ +export const DashboardTaskPage = z.object({ + /** Correlation ID matching the page or subscription request. */ + requestId: z.string(), + /** Immutable History catalog revision used to produce this page. */ + catalogRevision: z.number().int().nonnegative(), + /** Root tasks only, in catalog order. Subtasks appear in `childTasks`. */ + tasks: z.array(DashboardTaskSummary), + /** Direct children of this page's root tasks, keyed via their `parentTaskId`. */ + childTasks: z.array(DashboardTaskSummary).optional(), + /** Opaque host-issued cursor for the next page. */ + cursor: z.string().optional(), + /** Exact catalog size for the current revision. */ + totalEstimate: z.number().int().nonnegative(), +}) +export type DashboardTaskPage = z.infer + +/** + * Current task summary emitted after usage changes. It has the same complete + * identity and metrics as a page row so reducers never need a second join. + */ +export const DashboardTaskUpsert = DashboardTaskSummary +export type DashboardTaskUpsert = z.infer + +/** One API call shown in a task detail's chronologically ordered call list. */ +export const DashboardTaskApiCall = z.object({ + index: z.number().int().positive(), + mode: z.string(), + timestamp: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + reasoningTokens: z.number(), + costUsd: z.number(), + status: UsageEventStatus, + model: z.string(), +}) +export type DashboardTaskApiCall = z.infer + +/** + * Usage detail for a selected task and all descendants. Known tasks without + * usage succeed with zero totals, empty models/modes, and an empty call list. + */ +export const DashboardTaskDetail = z.object({ + taskId: z.string(), + title: z.string(), + taskTimestamp: z.number(), + models: z.array(z.string()), + modes: z.array(z.string()), + totalTokens: z.number(), + totalCost: z.number(), + callCount: z.number().int().nonnegative(), + apiCalls: z.array(DashboardTaskApiCall), +}) +export type DashboardTaskDetail = z.infer + +/** + * Daily heatmap values snapshot. + */ +export const HeatmapSnapshot = z.object({ + /** Number of days the values array covers. */ + rangeDays: z.number().int().min(1), + /** Daily cost values, one per day, oldest first. */ + values: z.array(z.number()), +}) +export type HeatmapSnapshot = z.infer + +/** + * Full state snapshot sent on initial subscription or recovery. + * Authoritative for its query and epoch. Applied atomically by the reducer. + */ +export const DashboardStatsSnapshot = z.object({ + /** Correlation ID matching the subscription request. */ + requestId: z.string(), + /** Store generation at the time of snapshot. */ + generation: z.number().int(), + /** Monotonic sequence of the last committed event included. */ + sequence: z.number().int(), + /** Full stats snapshot for the main dashboard. */ + stats: StatsSnapshot, + /** First page of sessions. */ + sessions: DashboardSessionPage, + /** Opaque cursor for fetching the next session page. */ + cursor: z.string().optional(), + /** Heatmap daily values. */ + heatmap: HeatmapSnapshot, +}) +export type DashboardStatsSnapshot = z.infer + +/** + * Task-based full state snapshot. This is intentionally separate from + * DashboardStatsSnapshot so legacy session stream consumers remain valid while + * the extension host and webview migrate together. + */ +export const DashboardTaskStatsSnapshot = z.object({ + requestId: z.string(), + generation: z.number().int(), + sequence: z.number().int(), + stats: StatsSnapshot, + /** First History-first task page for this catalog revision. */ + tasks: DashboardTaskPage, + /** Opaque cursor for fetching the next task page. */ + cursor: z.string().optional(), + heatmap: HeatmapSnapshot, +}) +export type DashboardTaskStatsSnapshot = z.infer + +/** + * Signed delta for a single stats bucket. + * Key fields are identities; numeric fields are signed deltas. + * Signed values support correction/reset migrations. + */ +export const StatsBucketDelta = z.object({ + /** Stable serialized bucket key (e.g. JSON of group dimensions). */ + key: z.record(z.string()), + /** Signed delta for events count. */ + events: z.number(), + /** Signed delta for completed calls. */ + completedCalls: z.number(), + /** Signed delta for failed calls. */ + failedCalls: z.number(), + /** Signed delta for cancelled calls. */ + cancelledCalls: z.number(), + /** Signed delta for input tokens. */ + inputTokens: z.number(), + /** Signed delta for output tokens. */ + outputTokens: z.number(), + /** Signed delta for cache read tokens. */ + cacheReadTokens: z.number(), + /** Signed delta for cache write tokens. */ + cacheWriteTokens: z.number(), + /** Signed delta for reasoning tokens. */ + reasoningTokens: z.number(), + /** Signed delta for total tokens. */ + totalTokens: z.number(), + /** Signed delta for cost in USD. */ + costUsd: z.number(), + /** Signed delta for unknown event count. */ + unknownEventCount: z.number(), +}) +export type StatsBucketDelta = z.infer + +/** + * Session upsert: a complete current summary for a root session. + * Existing rows update in place. A newly created session may be inserted at + * the top; ordinary numeric updates do not reorder the visible page. + */ +export const DashboardSessionUpsert = z.object({ + rootTaskId: z.string(), + title: z.string(), + totalCost: z.number(), + totalTokens: z.number(), + model: z.string(), + provider: z.string(), + lastActivity: z.number(), + eventCount: z.number(), +}) +export type DashboardSessionUpsert = z.infer + +/** + * Incremental delta message sent after the initial snapshot. + * The reducer accepts it only when generation matches and afterSequence + * equals the local through-sequence. + */ +export const DashboardStatsDelta = z.object({ + /** Correlation ID matching the subscription request. */ + requestId: z.string(), + /** Store generation at the time of delta. */ + generation: z.number().int(), + /** Monotonic sequence of the last committed event included. */ + sequence: z.number().int(), + /** Signed delta for totals. */ + totalDelta: StatsBucketDelta, + /** Signed deltas for breakdown buckets. */ + breakdownDelta: z.array(StatsBucketDelta), + /** Signed delta for a single heatmap day. */ + heatmapDayDelta: z + .object({ + /** Day index within the heatmap range (0-based). */ + dayIndex: z.number().int().min(0), + /** Signed delta for that day's cost. */ + delta: z.number(), + }) + .optional(), + /** Session upserts for changed sessions. */ + sessionUpsert: z.array(DashboardSessionUpsert), +}) +export type DashboardStatsDelta = z.infer + +/** Task-based stream delta with complete subtree summaries for changed rows. */ +export const DashboardTaskStatsDelta = z.object({ + requestId: z.string(), + generation: z.number().int(), + sequence: z.number().int(), + totalDelta: StatsBucketDelta, + breakdownDelta: z.array(StatsBucketDelta), + heatmapDayDelta: z + .object({ + dayIndex: z.number().int().min(0), + delta: z.number(), + }) + .optional(), + taskUpsert: z.array(DashboardTaskUpsert), +}) +export type DashboardTaskStatsDelta = z.infer + +/** + * Typed error message for the dashboard stats stream. + * Existing data stays visible for recoverable errors. + * No stack trace crosses the boundary. + */ +export const DashboardStatsError = z.object({ + /** Correlation ID matching the subscription request. */ + requestId: z.string(), + /** Stable error code (e.g. "STATS_STREAM/subscribe/001"). */ + code: z.string(), + /** Safe, user-facing error message (no stack traces). */ + message: z.string(), +}) +export type DashboardStatsError = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..bd137e0490 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -22,6 +22,21 @@ import type { SkillMetadata } from "./skills.js" import type { RuleMetadata } from "./rules.js" import type { TelemetrySetting } from "./telemetry.js" import type { WorktreeIncludeStatus } from "./worktree.js" +import type { + StatsQuery, + StatsSnapshot, + SessionSummary, + SessionDetail, + DashboardStatsSubscription, + DashboardStatsSnapshot, + DashboardStatsDelta, + DashboardSessionPage, + DashboardTaskPage, + DashboardTaskDetail, + DashboardTaskStatsSnapshot, + DashboardTaskStatsDelta, + DashboardStatsError, +} from "./usage-stats.js" /** * ExtensionMessage @@ -108,6 +123,23 @@ export interface ExtensionMessage { | "fileContent" | "rooHistoryImportProgress" | "themeFixtureProbeRequest" + // Usage stats response types + | "getUsageStatsResponse" + | "clearUsageStatsResponse" + | "exportUsageStatsResponse" + | "requestClearNonceResponse" + | "usageStatsChanged" + // Dashboard response types + | "dashboardStatsResponse" + | "dashboardSessionsResponse" + | "dashboardSessionDetailResponse" + // Dashboard streaming response types + | "dashboardStatsStreamSnapshot" + | "dashboardStatsStreamDelta" + | "dashboardStatsStreamError" + | "dashboardSessionPageResponse" + | "dashboardTaskPageResponse" + | "dashboardTaskDetailResponse" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -121,6 +153,7 @@ export interface ExtensionMessage { | "settingsButtonClicked" | "historyButtonClicked" | "marketplaceButtonClicked" + | "dashboardButtonClicked" | "didBecomeVisible" | "focusInput" | "switchTab" @@ -254,6 +287,40 @@ export interface ExtensionMessage { copyProgressItemName?: string // folderSelected path?: string + // Usage stats response payloads + usageStatsSnapshot?: StatsSnapshot + clearUsageStatsResult?: { success: boolean; error?: string } + exportUsageStatsResult?: { format: "json" | "csv"; data: string; error?: string } + // B2 fix: host-issued clear nonce returned in `requestClearNonceResponse`. + // null when the service is unavailable or an error occurred (see `error`). + clearNonce?: string | null + // Dashboard sessions response payload (Commit 3). + // `dashboardSessions` is null when the service is unavailable or an error + // occurred (see `error`). On success it is an array (possibly empty). + dashboardSessions?: SessionSummary[] | null + // Dashboard session detail response payload (Commit 4). + // `dashboardSessionDetail` is null when the service is unavailable, the + // taskId is not found, or an error occurred (see `error`). On success it + // contains the full session summary plus the per-API-call records. + dashboardSessionDetail?: SessionDetail | null + + // Dashboard streaming response payloads + /** + * Full state snapshot for `dashboardStatsStreamSnapshot`. Legacy session + * payloads remain valid until all producers and consumers migrate to the + * History-first task page shape. + */ + dashboardStatsStreamSnapshot?: DashboardTaskStatsSnapshot | DashboardStatsSnapshot + /** Incremental delta for `dashboardStatsStreamDelta` during the same transition. */ + dashboardStatsStreamDelta?: DashboardTaskStatsDelta | DashboardStatsDelta + /** Typed error for `dashboardStatsStreamError`. */ + dashboardStatsStreamError?: DashboardStatsError + /** Cursor-paged session page for `dashboardSessionPageResponse`. */ + dashboardSessionPage?: DashboardSessionPage + /** Cursor-paged History task page for `dashboardTaskPageResponse`. */ + dashboardTaskPage?: DashboardTaskPage + /** History task detail for `dashboardTaskDetailResponse`. */ + dashboardTaskDetail?: DashboardTaskDetail | null } export interface OpenAiCodexRateLimitsMessage { @@ -646,10 +713,28 @@ export interface WebviewMessage { | "openRuleFile" | "openRulesDirectory" | "themeFixtureProbeResponse" + // Usage stats request types + | "getUsageStats" + | "clearUsageStats" + | "exportUsageStats" + | "requestClearNonce" + // Dashboard request types + | "getDashboardSessionDetail" + | "getDashboardSessions" + | "getDashboardTaskDetail" + // Dashboard streaming request types + | "subscribeDashboardStats" + | "unsubscribeDashboardStats" + | "replaceDashboardStatsSubscription" + | "pauseDashboardStats" + | "resumeDashboardStats" + | "resyncDashboardStats" + | "getDashboardSessionPage" + | "getDashboardTaskPage" text?: string taskId?: string editedMessageContent?: string - tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" + tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" | "stats" | "dashboard" disabled?: boolean context?: string dataUri?: string @@ -757,6 +842,35 @@ export interface WebviewMessage { worktreeForce?: boolean worktreeNewWindow?: boolean worktreeIncludeContent?: string + // Usage stats request payloads + usageStatsQuery?: StatsQuery + clearUsageStatsNonce?: string + exportUsageStatsFormat?: "json" | "csv" + // B2 fix: host-issued clear nonce returned to webview in response to + // `requestClearNonce`. The webview must use this nonce (not a self-generated + // one) when sending the subsequent `clearUsageStats` message, so the host's + // nonce validation actually passes. + clearNonce?: string + // Dashboard sessions request payload (Commit 3). + // `usageStatsQuery` carries the time range; `dashboardSessionFilters` + // carries optional model/provider filters applied after grouping. + dashboardSessionFilters?: { + model?: string + provider?: string + } + + // Dashboard streaming request payloads. + // `dashboardStatsSubscription` carries the validated subscription + // descriptor for subscribe/replace operations. + dashboardStatsSubscription?: DashboardStatsSubscription + // Opaque cursor for `getDashboardSessionPage` requests. + dashboardSessionCursor?: string + // Page size for `getDashboardSessionPage` requests (1–100). + dashboardSessionLimit?: number + // Opaque cursor for `getDashboardTaskPage` requests. + dashboardTaskCursor?: string + // Page size for `getDashboardTaskPage` requests (1–100). + dashboardTaskLimit?: number } export interface WebviewThemeFixture { From f2c50d0303462c223c9f08f640ee99b437d35ce9 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 03:54:47 +0900 Subject: [PATCH 2/3] fix(dashboard): tighten usage-stats schema validation Address CodeRabbit review on PR #1745: - Replace broad timestamp/Date.parse validation with shared strict validators (IsoUtcDateTime, IsoOffsetDateTime) that enforce valid calendar dates; apply to occurredAt, generatedAt, firstEventAt, lastEventAt (UTC) and StatsQuery from/to (offset-aware) - HeatmapSnapshot: cross-field refine requiring values length to equal rangeDays exactly - DashboardStatsDelta / DashboardTaskStatsDelta: add required integer afterSequence (reducer's local through-sequence before the delta) with a refine requiring afterSequence < sequence --- .../__tests__/dashboard-stats-stream.spec.ts | 107 ++++++++++++- .../types/src/__tests__/usage-stats.spec.ts | 100 ++++++++++++ packages/types/src/usage-stats.ts | 151 +++++++++++------- 3 files changed, 293 insertions(+), 65 deletions(-) diff --git a/packages/types/src/__tests__/dashboard-stats-stream.spec.ts b/packages/types/src/__tests__/dashboard-stats-stream.spec.ts index 0d1ecc543d..2906304af4 100644 --- a/packages/types/src/__tests__/dashboard-stats-stream.spec.ts +++ b/packages/types/src/__tests__/dashboard-stats-stream.spec.ts @@ -357,20 +357,27 @@ describe("DashboardTaskDetail", () => { describe("HeatmapSnapshot", () => { it("should parse a valid heatmap", () => { - const result = HeatmapSnapshot.parse({ rangeDays: 30, values: [0.1, 0.2, 0.3] }) - expect(result.rangeDays).toBe(30) + const result = HeatmapSnapshot.parse({ rangeDays: 3, values: [0.1, 0.2, 0.3] }) + expect(result.rangeDays).toBe(3) expect(result.values).toHaveLength(3) }) - it("should accept empty values array", () => { - const result = HeatmapSnapshot.parse({ rangeDays: 30, values: [] }) - expect(result.values).toHaveLength(0) + it("should reject values shorter than rangeDays", () => { + expect(() => HeatmapSnapshot.parse({ rangeDays: 30, values: [0.1, 0.2, 0.3] })).toThrow() + }) + + it("should reject values longer than rangeDays", () => { + expect(() => HeatmapSnapshot.parse({ rangeDays: 2, values: [0.1, 0.2, 0.3] })).toThrow() }) it("should reject rangeDays of 0", () => { expect(() => HeatmapSnapshot.parse({ rangeDays: 0, values: [] })).toThrow() }) + it("should reject non-integer rangeDays", () => { + expect(() => HeatmapSnapshot.parse({ rangeDays: 3.5, values: [0.1, 0.2, 0.3] })).toThrow() + }) + it("should reject missing values", () => { const { values: _v, ...withoutValues } = { rangeDays: 30, values: [1] } expect(() => HeatmapSnapshot.parse(withoutValues)).toThrow() @@ -465,7 +472,7 @@ describe("DashboardStatsSnapshot", () => { } const validHeatmap = { - rangeDays: 30, + rangeDays: 3, values: [0.1, 0.2, 0.3], } @@ -552,6 +559,7 @@ describe("DashboardStatsDelta", () => { requestId: "sub-001", generation: 1, sequence: 101, + afterSequence: 100, totalDelta: validBucketDelta, breakdownDelta: [validBucketDelta], heatmapDayDelta: { @@ -566,6 +574,7 @@ describe("DashboardStatsDelta", () => { expect(result.requestId).toBe("sub-001") expect(result.generation).toBe(1) expect(result.sequence).toBe(101) + expect(result.afterSequence).toBe(100) expect(result.totalDelta.events).toBe(1) expect(result.breakdownDelta).toHaveLength(1) expect(result.heatmapDayDelta?.dayIndex).toBe(28) @@ -607,6 +616,29 @@ describe("DashboardStatsDelta", () => { expect(() => DashboardStatsDelta.parse(withoutSeq)).toThrow() }) + it("should reject missing afterSequence", () => { + const { afterSequence: _after, ...withoutAfter } = validDelta + expect(() => DashboardStatsDelta.parse(withoutAfter)).toThrow() + }) + + it("should reject non-integer afterSequence", () => { + expect(() => DashboardStatsDelta.parse({ ...validDelta, afterSequence: 100.5 })).toThrow() + }) + + it("should reject afterSequence equal to sequence (empty delta is a snapshot's job)", () => { + expect(() => DashboardStatsDelta.parse({ ...validDelta, afterSequence: 101 })).toThrow() + }) + + it("should reject afterSequence greater than sequence", () => { + expect(() => DashboardStatsDelta.parse({ ...validDelta, afterSequence: 102 })).toThrow() + }) + + it("should accept a multi-event delta spanning several sequences", () => { + const result = DashboardStatsDelta.parse({ ...validDelta, afterSequence: 98, sequence: 101 }) + expect(result.afterSequence).toBe(98) + expect(result.sequence).toBe(101) + }) + it("should reject non-integer generation", () => { expect(() => DashboardStatsDelta.parse({ ...validDelta, generation: 1.5 })).toThrow() }) @@ -649,6 +681,63 @@ describe("DashboardStatsDelta", () => { }) }) +// ── DashboardTaskStatsDelta ───────────────────────────────────────────────── + +describe("DashboardTaskStatsDelta", () => { + const validBucketDelta = { + key: { day: "2026-07-29" }, + events: 1, + completedCalls: 1, + failedCalls: 0, + cancelledCalls: 0, + inputTokens: 500, + outputTokens: 200, + cacheReadTokens: 100, + cacheWriteTokens: 50, + reasoningTokens: 0, + totalTokens: 700, + costUsd: 0.01, + unknownEventCount: 0, + } + + const validTaskDelta = { + requestId: "sub-001", + generation: 1, + sequence: 101, + afterSequence: 100, + totalDelta: validBucketDelta, + breakdownDelta: [validBucketDelta], + taskUpsert: [validTaskSummary], + } + + it("should parse a valid task delta", () => { + const result = DashboardTaskStatsDelta.parse(validTaskDelta) + expect(result.sequence).toBe(101) + expect(result.afterSequence).toBe(100) + expect(result.taskUpsert).toHaveLength(1) + }) + + it("should reject missing afterSequence", () => { + const { afterSequence: _after, ...withoutAfter } = validTaskDelta + expect(() => DashboardTaskStatsDelta.parse(withoutAfter)).toThrow() + }) + + it("should reject non-integer afterSequence", () => { + expect(() => DashboardTaskStatsDelta.parse({ ...validTaskDelta, afterSequence: 100.5 })).toThrow() + }) + + it("should reject afterSequence equal to or greater than sequence", () => { + expect(() => DashboardTaskStatsDelta.parse({ ...validTaskDelta, afterSequence: 101 })).toThrow() + expect(() => DashboardTaskStatsDelta.parse({ ...validTaskDelta, afterSequence: 102 })).toThrow() + }) + + it("should accept a multi-event delta spanning several sequences", () => { + const result = DashboardTaskStatsDelta.parse({ ...validTaskDelta, afterSequence: 97, sequence: 101 }) + expect(result.afterSequence).toBe(97) + expect(result.sequence).toBe(101) + }) +}) + // ── DashboardStatsError ───────────────────────────────────────────────────── describe("DashboardStatsError", () => { @@ -697,7 +786,7 @@ describe("serialization round trips", () => { stats: validStatsSnapshot, sessions: validSessionPage, cursor: "next-cursor", - heatmap: { rangeDays: 30, values: [0.1, 0.2] }, + heatmap: { rangeDays: 2, values: [0.1, 0.2] }, } const json = JSON.stringify(snapshot) const parsed = JSON.parse(json) @@ -726,6 +815,7 @@ describe("serialization round trips", () => { requestId: "sub-001", generation: 1, sequence: 101, + afterSequence: 100, totalDelta: bucketDelta, breakdownDelta: [bucketDelta], heatmapDayDelta: { dayIndex: 28, delta: 0.01 }, @@ -794,12 +884,13 @@ describe("serialization round trips", () => { stats: validStatsSnapshot, tasks: taskPage, cursor: taskPage.cursor, - heatmap: { rangeDays: 30, values: [0.1, 0.2] }, + heatmap: { rangeDays: 2, values: [0.1, 0.2] }, } const delta = { requestId: "sub-001", generation: 1, sequence: 101, + afterSequence: 100, totalDelta: bucketDelta, breakdownDelta: [bucketDelta], taskUpsert: [validTaskSummary], diff --git a/packages/types/src/__tests__/usage-stats.spec.ts b/packages/types/src/__tests__/usage-stats.spec.ts index 7392852f24..6119a2e65b 100644 --- a/packages/types/src/__tests__/usage-stats.spec.ts +++ b/packages/types/src/__tests__/usage-stats.spec.ts @@ -3,6 +3,8 @@ import { UsageValueSource, InclusionRule, SourcedNumber, + IsoUtcDateTime, + IsoOffsetDateTime, UsageEventV1, StatsQuery, StatsBucket, @@ -63,6 +65,45 @@ describe("usage-stats schemas", () => { }) }) + // ── Shared datetime validators ─────────────────────────────────────── + + describe("IsoUtcDateTime", () => { + it("should accept valid Z datetimes", () => { + expect(IsoUtcDateTime.parse("2026-07-18T12:00:00Z")).toBe("2026-07-18T12:00:00Z") + expect(IsoUtcDateTime.parse("2026-07-18T12:00:00.000Z")).toBe("2026-07-18T12:00:00.000Z") + }) + + it("should reject arbitrary strings", () => { + expect(() => IsoUtcDateTime.parse("not-a-date")).toThrow() + }) + + it("should reject impossible calendar dates instead of normalizing them", () => { + expect(() => IsoUtcDateTime.parse("2026-02-30T00:00:00Z")).toThrow() + expect(() => IsoUtcDateTime.parse("2026-13-01T00:00:00Z")).toThrow() + // Date.parse would normalize this to 2026-03-02; the strict validator must not. + expect(Number.isNaN(new Date("2026-02-30T00:00:00Z").getTime())).toBe(false) + }) + + it("should reject offset forms", () => { + expect(() => IsoUtcDateTime.parse("2026-07-18T12:00:00+05:30")).toThrow() + }) + }) + + describe("IsoOffsetDateTime", () => { + it("should accept valid Z and offset datetimes", () => { + expect(IsoOffsetDateTime.parse("2026-07-18T12:00:00Z")).toBe("2026-07-18T12:00:00Z") + expect(IsoOffsetDateTime.parse("2026-07-18T12:00:00+05:30")).toBe("2026-07-18T12:00:00+05:30") + expect(IsoOffsetDateTime.parse("2026-07-18T12:00:00-07:00")).toBe("2026-07-18T12:00:00-07:00") + }) + + it("should reject arbitrary strings and impossible calendar dates", () => { + expect(() => IsoOffsetDateTime.parse("not-a-date")).toThrow() + expect(() => IsoOffsetDateTime.parse("2026-02-30T00:00:00Z")).toThrow() + expect(() => IsoOffsetDateTime.parse("2026-13-01T00:00:00Z")).toThrow() + expect(() => IsoOffsetDateTime.parse("2026-02-30T00:00:00+05:30")).toThrow() + }) + }) + // ── UsageEventV1 ──────────────────────────────────────────────────────── describe("UsageEventV1", () => { @@ -127,6 +168,14 @@ describe("usage-stats schemas", () => { expect(() => UsageEventV1.parse({ ...validEvent, provenance: "imported" })).toThrow() }) + it("should reject a non-UTC or impossible occurredAt", () => { + expect(() => UsageEventV1.parse({ ...validEvent, occurredAt: "not-a-date" })).toThrow() + expect(() => UsageEventV1.parse({ ...validEvent, occurredAt: "2026-02-30T00:00:00Z" })).toThrow() + expect(() => UsageEventV1.parse({ ...validEvent, occurredAt: "2026-13-01T00:00:00Z" })).toThrow() + // Offset form is reserved for query range bounds, not event timestamps. + expect(() => UsageEventV1.parse({ ...validEvent, occurredAt: "2026-07-18T12:00:00+05:30" })).toThrow() + }) + it("should reject missing required fields (eventId)", () => { const { eventId: _eventId, ...withoutEventId } = validEvent expect(() => UsageEventV1.parse(withoutEventId)).toThrow() @@ -235,6 +284,41 @@ describe("usage-stats schemas", () => { ).toThrow() }) + it("should reject impossible calendar dates in from/to", () => { + expect(() => + StatsQuery.parse({ + from: "2026-02-30T00:00:00Z", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + expect(() => + StatsQuery.parse({ + to: "2026-13-01T00:00:00Z", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + expect(() => + StatsQuery.parse({ + from: "2026-02-30T00:00:00+05:30", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + }) + + it("should accept offset-aware from/to datetimes", () => { + const result = StatsQuery.parse({ + from: "2026-07-01T00:00:00+05:30", + to: "2026-07-18T00:00:00-07:00", + timezone: "UTC", + groupBy: [], + }) + expect(result.from).toBe("2026-07-01T00:00:00+05:30") + expect(result.to).toBe("2026-07-18T00:00:00-07:00") + }) + it("should reject invalid groupBy dimension", () => { expect(() => StatsQuery.parse({ @@ -348,6 +432,22 @@ describe("usage-stats schemas", () => { const { totals: _totals, ...withoutTotals } = validSnapshot expect(() => StatsSnapshot.parse(withoutTotals)).toThrow() }) + + it("should reject impossible generatedAt / coverage datetimes", () => { + expect(() => StatsSnapshot.parse({ ...validSnapshot, generatedAt: "not-a-date" })).toThrow() + expect(() => + StatsSnapshot.parse({ + ...validSnapshot, + coverage: { ...validSnapshot.coverage, firstEventAt: "2026-02-30T00:00:00Z" }, + }), + ).toThrow() + expect(() => + StatsSnapshot.parse({ + ...validSnapshot, + coverage: { ...validSnapshot.coverage, lastEventAt: "2026-13-01T00:00:00Z" }, + }), + ).toThrow() + }) }) // ── DashboardTaskSummary ────────────────────────────────────────────── diff --git a/packages/types/src/usage-stats.ts b/packages/types/src/usage-stats.ts index 362cbf3ab0..4e5c4eb739 100644 --- a/packages/types/src/usage-stats.ts +++ b/packages/types/src/usage-stats.ts @@ -1,5 +1,24 @@ import { z } from "zod" +// ── Shared datetime validators ───────────────────────────────────────────── + +/** + * Strict UTC ISO 8601 datetime string (e.g. "2026-07-18T12:00:00.000Z"). + * Unlike Date.parse-based checks, impossible calendar dates (e.g. + * "2026-02-30T00:00:00Z") are rejected instead of silently normalized into + * neighboring dates. Shared by all host-emitted UTC timestamp fields. + */ +export const IsoUtcDateTime = z.string().datetime() +export type IsoUtcDateTime = z.infer + +/** + * Offset-aware variant of IsoUtcDateTime: also accepts a numeric UTC offset + * suffix (e.g. "2026-07-18T12:00:00+05:30"). Used for webview-originated + * range bounds where a local-offset form is allowed. + */ +export const IsoOffsetDateTime = z.string().datetime({ offset: true }) +export type IsoOffsetDateTime = z.infer + // ── Enums ────────────────────────────────────────────────────────────────── /** Final status of an LLM API call */ @@ -36,7 +55,7 @@ export const UsageEventV1 = z.object({ schemaVersion: z.literal(1), eventId: z.string(), idempotencyKey: z.string(), - occurredAt: z.string(), // ISO 8601 UTC + occurredAt: IsoUtcDateTime, // ISO 8601 UTC timezoneOffsetMinutes: z.number(), status: UsageEventStatus, attempt: z.number(), @@ -98,16 +117,10 @@ export type UsageEventV1 = z.infer /** Statistics query */ export const StatsQuery = z.object({ - // ISO 8601 datetimes; unparseable values are rejected so downstream - // range math never sees NaN epoch bounds. - from: z - .string() - .refine((v) => !Number.isNaN(new Date(v).getTime()), { message: "from must be a parseable datetime" }) - .optional(), - to: z - .string() - .refine((v) => !Number.isNaN(new Date(v).getTime()), { message: "to must be a parseable datetime" }) - .optional(), + // ISO 8601 datetimes; unparseable values and impossible calendar dates are + // rejected so downstream range math never sees NaN epoch bounds. + from: IsoOffsetDateTime.optional(), + to: IsoOffsetDateTime.optional(), preset: z.enum(["today", "7d", "30d", "all"]).optional(), timezone: z.string(), // IANA groupBy: z.array(z.enum(["day", "week", "month", "provider", "model", "mode", "status", "source"])).max(3), @@ -146,12 +159,12 @@ export type StatsBucket = z.infer /** Statistics query result snapshot */ export const StatsSnapshot = z.object({ query: StatsQuery, - generatedAt: z.string(), + generatedAt: IsoUtcDateTime, buckets: z.array(StatsBucket), totals: StatsBucket, coverage: z.object({ - firstEventAt: z.string().optional(), - lastEventAt: z.string().optional(), + firstEventAt: IsoUtcDateTime.optional(), + lastEventAt: IsoUtcDateTime.optional(), recordingPaused: z.boolean(), backfilledEventCount: z.number(), }), @@ -392,12 +405,16 @@ export type DashboardTaskDetail = z.infer /** * Daily heatmap values snapshot. */ -export const HeatmapSnapshot = z.object({ - /** Number of days the values array covers. */ - rangeDays: z.number().int().min(1), - /** Daily cost values, one per day, oldest first. */ - values: z.array(z.number()), -}) +export const HeatmapSnapshot = z + .object({ + /** Number of days the values array covers. */ + rangeDays: z.number().int().min(1), + /** Daily cost values, one per day, oldest first. */ + values: z.array(z.number()), + }) + .refine((heatmap) => heatmap.values.length === heatmap.rangeDays, { + message: "values must contain exactly rangeDays entries", + }) export type HeatmapSnapshot = z.infer /** @@ -497,46 +514,66 @@ export type DashboardSessionUpsert = z.infer * The reducer accepts it only when generation matches and afterSequence * equals the local through-sequence. */ -export const DashboardStatsDelta = z.object({ - /** Correlation ID matching the subscription request. */ - requestId: z.string(), - /** Store generation at the time of delta. */ - generation: z.number().int(), - /** Monotonic sequence of the last committed event included. */ - sequence: z.number().int(), - /** Signed delta for totals. */ - totalDelta: StatsBucketDelta, - /** Signed deltas for breakdown buckets. */ - breakdownDelta: z.array(StatsBucketDelta), - /** Signed delta for a single heatmap day. */ - heatmapDayDelta: z - .object({ - /** Day index within the heatmap range (0-based). */ - dayIndex: z.number().int().min(0), - /** Signed delta for that day's cost. */ - delta: z.number(), - }) - .optional(), - /** Session upserts for changed sessions. */ - sessionUpsert: z.array(DashboardSessionUpsert), -}) +export const DashboardStatsDelta = z + .object({ + /** Correlation ID matching the subscription request. */ + requestId: z.string(), + /** Store generation at the time of delta. */ + generation: z.number().int(), + /** Monotonic sequence of the last committed event included. */ + sequence: z.number().int(), + /** + * Reducer's local through-sequence before this delta. Must be less + * than `sequence`: a delta carries at least one event (equal sequences + * are snapshots' job). + */ + afterSequence: z.number().int(), + /** Signed delta for totals. */ + totalDelta: StatsBucketDelta, + /** Signed deltas for breakdown buckets. */ + breakdownDelta: z.array(StatsBucketDelta), + /** Signed delta for a single heatmap day. */ + heatmapDayDelta: z + .object({ + /** Day index within the heatmap range (0-based). */ + dayIndex: z.number().int().min(0), + /** Signed delta for that day's cost. */ + delta: z.number(), + }) + .optional(), + /** Session upserts for changed sessions. */ + sessionUpsert: z.array(DashboardSessionUpsert), + }) + .refine((delta) => delta.afterSequence < delta.sequence, { + message: "afterSequence must be less than sequence (a delta carries at least one event)", + }) export type DashboardStatsDelta = z.infer /** Task-based stream delta with complete subtree summaries for changed rows. */ -export const DashboardTaskStatsDelta = z.object({ - requestId: z.string(), - generation: z.number().int(), - sequence: z.number().int(), - totalDelta: StatsBucketDelta, - breakdownDelta: z.array(StatsBucketDelta), - heatmapDayDelta: z - .object({ - dayIndex: z.number().int().min(0), - delta: z.number(), - }) - .optional(), - taskUpsert: z.array(DashboardTaskUpsert), -}) +export const DashboardTaskStatsDelta = z + .object({ + requestId: z.string(), + generation: z.number().int(), + sequence: z.number().int(), + /** + * Reducer's local through-sequence before this delta. Must be less + * than `sequence`: a delta carries at least one event (equal sequences + * are snapshots' job). + */ + afterSequence: z.number().int(), + totalDelta: StatsBucketDelta, + breakdownDelta: z.array(StatsBucketDelta), + heatmapDayDelta: z + .object({ + dayIndex: z.number().int().min(0), + delta: z.number(), + }) + .optional(), + taskUpsert: z.array(DashboardTaskUpsert), + }) + .refine((delta) => delta.afterSequence < delta.sequence, { + message: "afterSequence must be less than sequence (a delta carries at least one event)", + }) export type DashboardTaskStatsDelta = z.infer /** From b16b21e23f6dc5178af78bb9d2e0158a19fa16a3 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Tue, 22 Sep 2026 04:47:08 +0900 Subject: [PATCH 3/3] fix(dashboard): reject out-of-range UTC offsets in stats query bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zod's datetime({ offset: true }) validates only the offset shape ([+-]HH:MM), so values like +24:00 or +99:99 passed. Append a Date.parse-based refine to IsoOffsetDateTime: V8 returns NaN for out-of-range offsets, which the refine rejects. The Z-only IsoUtcDateTime is unchanged — it has no offset to range-check. --- .../types/src/__tests__/usage-stats.spec.ts | 50 +++++++++++++++++++ packages/types/src/usage-stats.ts | 9 +++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/usage-stats.spec.ts b/packages/types/src/__tests__/usage-stats.spec.ts index 6119a2e65b..3aa110b961 100644 --- a/packages/types/src/__tests__/usage-stats.spec.ts +++ b/packages/types/src/__tests__/usage-stats.spec.ts @@ -102,6 +102,21 @@ describe("usage-stats schemas", () => { expect(() => IsoOffsetDateTime.parse("2026-13-01T00:00:00Z")).toThrow() expect(() => IsoOffsetDateTime.parse("2026-02-30T00:00:00+05:30")).toThrow() }) + + it("should reject out-of-range UTC offsets", () => { + // zod's offset check validates only the [+-]HH:MM shape; these are + // numerically invalid and rejected by the Date.parse refine. + expect(() => IsoOffsetDateTime.parse("2026-07-18T12:00:00+24:00")).toThrow() + expect(() => IsoOffsetDateTime.parse("2026-07-18T12:00:00+99:99")).toThrow() + expect(() => IsoOffsetDateTime.parse("2026-07-18T12:00:00+05:99")).toThrow() + }) + + it("should accept valid UTC offset extremes", () => { + expect(IsoOffsetDateTime.parse("2026-07-18T12:00:00+23:59")).toBe("2026-07-18T12:00:00+23:59") + expect(IsoOffsetDateTime.parse("2026-07-18T12:00:00-12:00")).toBe("2026-07-18T12:00:00-12:00") + // ISO 8601 allows -00:00 ("unknown offset"); Date.parse treats it as UTC. + expect(IsoOffsetDateTime.parse("2026-07-18T12:00:00-00:00")).toBe("2026-07-18T12:00:00-00:00") + }) }) // ── UsageEventV1 ──────────────────────────────────────────────────────── @@ -319,6 +334,41 @@ describe("usage-stats schemas", () => { expect(result.to).toBe("2026-07-18T00:00:00-07:00") }) + it("should reject out-of-range UTC offsets in from/to", () => { + expect(() => + StatsQuery.parse({ + from: "2026-07-01T00:00:00+24:00", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + expect(() => + StatsQuery.parse({ + to: "2026-07-18T00:00:00+99:99", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + expect(() => + StatsQuery.parse({ + from: "2026-07-01T00:00:00+05:99", + timezone: "UTC", + groupBy: [], + }), + ).toThrow() + }) + + it("should accept valid UTC offset extremes in from/to", () => { + const result = StatsQuery.parse({ + from: "2026-07-01T00:00:00+23:59", + to: "2026-07-18T00:00:00-12:00", + timezone: "UTC", + groupBy: [], + }) + expect(result.from).toBe("2026-07-01T00:00:00+23:59") + expect(result.to).toBe("2026-07-18T00:00:00-12:00") + }) + it("should reject invalid groupBy dimension", () => { expect(() => StatsQuery.parse({ diff --git a/packages/types/src/usage-stats.ts b/packages/types/src/usage-stats.ts index 4e5c4eb739..55de4a46ca 100644 --- a/packages/types/src/usage-stats.ts +++ b/packages/types/src/usage-stats.ts @@ -15,8 +15,15 @@ export type IsoUtcDateTime = z.infer * Offset-aware variant of IsoUtcDateTime: also accepts a numeric UTC offset * suffix (e.g. "2026-07-18T12:00:00+05:30"). Used for webview-originated * range bounds where a local-offset form is allowed. + * + * zod's offset check validates only the shape ([+-]HH:MM), not the numeric + * range; Date.parse rejects out-of-range offsets (e.g. "+24:00", "+99:99") + * with NaN, which the refine turns into a validation error. */ -export const IsoOffsetDateTime = z.string().datetime({ offset: true }) +export const IsoOffsetDateTime = z + .string() + .datetime({ offset: true }) + .refine((value) => Number.isFinite(Date.parse(value)), { message: "Invalid UTC offset" }) export type IsoOffsetDateTime = z.infer // ── Enums ──────────────────────────────────────────────────────────────────