mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
* feat(menubar): expose combined multi-device usage in menubar-json (#566) The macOS menubar reads `codeburn status --format menubar-json`, which only ever reflected the local machine. Multi-device "Combined" usage existed only in the terminal `devices` command. This exposes combined + per-device usage through the menubar-json contract so the menubar can mirror the combined dashboard. - Extract the per-device summary + combined reduce that was inline in renderDevices into a reusable pure `summarizeDeviceUsage(results, window?)`. Error/unreachable devices are excluded from the combined sums (kept in perDevice); deviceCount counts all, reachableCount counts the reachable ones. renderDevices now formats from it with byte-identical output. - Add an optional `combined?: CombinedUsage` block to MenubarPayload (perDevice list + combined totals incl. calls/sessions). Absent by default. - Add `--scope local|combined` to `status` (default local). `combined` builds the local payload, pulls paired devices (pullDevices isolates per-peer failures), and attaches the summary. - Correctness guards: reject `--scope combined` with `--days` (non-contiguous, not representable over the sharing query) and with `--provider`/`--project`/ `--exclude` (the sharing query carries no filters, so peers would report unfiltered usage). Window-scope the cache-token sum to the selected period (cache lives in 365-day daily history; current carries no cache counts). TS/CLI only. The menubar Local/Combined toggle + render is a follow-up. * fix(menubar): never let combined enrichment break the base local payload The status --format menubar-json --scope combined path pulls paired devices. Wrap that best-effort enrichment in a guard so an unexpected failure (corrupt remotes store, peer I/O) can never take down the base local menubar payload — on error the local payload is still emitted with combined omitted. Add a test that a corrupt remote-devices.json still yields a valid combined (local-only) payload. --------- Co-authored-by: AgentSeal <hello@agentseal.org>
This commit is contained in:
parent
b24dd31ede
commit
88c1cee467
6 changed files with 597 additions and 54 deletions
43
src/main.ts
43
src/main.ts
|
|
@ -17,10 +17,11 @@ import { renderOverview } from './overview.js'
|
|||
import { runWebDashboard } from './web-dashboard.js'
|
||||
import { hostname } from 'os'
|
||||
import { runShareServer } from './sharing/share-run.js'
|
||||
import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js'
|
||||
import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js'
|
||||
import { browse } from './sharing/discovery.js'
|
||||
import { promptChoice } from './sharing/prompt.js'
|
||||
import { loadRemotes, saveRemotes } from './sharing/store.js'
|
||||
import type { UsageQuery } from './sharing/share-server.js'
|
||||
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize } from './optimize.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
|
|
@ -172,6 +173,15 @@ function assertProvider(value: string, command: string): void {
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
function assertScope(value: string, allowed: readonly string[], command: string): void {
|
||||
if (!allowed.includes(value)) {
|
||||
process.stderr.write(
|
||||
`codeburn ${command}: unknown scope "${value}". Valid values: ${allowed.join(', ')}.\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
|
||||
await loadPricing()
|
||||
const { range, label } = getDateRange(period)
|
||||
|
|
@ -630,6 +640,7 @@ program
|
|||
.command('status')
|
||||
.description('Compact status output (today + month)')
|
||||
.option('--format <format>', 'Output format: terminal, menubar-json, json', 'terminal')
|
||||
.option('--scope <scope>', 'Usage scope for menubar-json: local, combined', 'local')
|
||||
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
|
||||
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
|
||||
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
|
||||
|
|
@ -641,6 +652,7 @@ program
|
|||
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
|
||||
.action(async (opts) => {
|
||||
assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
|
||||
assertScope(opts.scope, ['local', 'combined'], 'status')
|
||||
assertProvider(opts.provider, 'status')
|
||||
if (opts.day && (opts.from || opts.to)) {
|
||||
process.stderr.write('error: --day cannot be combined with --from or --to\n')
|
||||
|
|
@ -650,6 +662,14 @@ program
|
|||
process.stderr.write('error: --days cannot be combined with --day, --from, or --to\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.days) {
|
||||
process.stderr.write('error: --scope combined cannot be combined with --days\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (opts.scope === 'combined' && (opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0)) {
|
||||
process.stderr.write('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)\n')
|
||||
process.exit(1)
|
||||
}
|
||||
await loadPricing()
|
||||
const pf = opts.provider
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
|
||||
|
|
@ -669,6 +689,27 @@ program
|
|||
daysSelection,
|
||||
optimize: opts.optimize !== false,
|
||||
})
|
||||
if (opts.scope === 'combined') {
|
||||
// Combined multi-device usage is best-effort enrichment on the menubar's
|
||||
// hot path. Never let pulling peers (or a corrupt remotes store) take
|
||||
// down the base local payload: on any failure, emit local data with
|
||||
// `combined` omitted so the menubar always gets a valid response.
|
||||
try {
|
||||
const query: UsageQuery = customRange
|
||||
? { from: opts.from, to: opts.to }
|
||||
: daySelection
|
||||
? { from: daySelection.day, to: daySelection.day }
|
||||
: { period: opts.period }
|
||||
const localGetUsage = async (): Promise<typeof payload> => payload
|
||||
const results = await pullDevices(localGetUsage, query, hostname(), {})
|
||||
payload.combined = summarizeDeviceUsage(results, {
|
||||
start: toDateString(periodInfo.range.start),
|
||||
end: toDateString(periodInfo.range.end),
|
||||
})
|
||||
} catch {
|
||||
// best-effort only: the local payload is still emitted below
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,37 @@ export type LocalModelSavings = {
|
|||
byProvider: Array<{ name: string; calls: number; savingsUSD: number }>
|
||||
}
|
||||
|
||||
export type DeviceSummary = {
|
||||
id: string
|
||||
name: string
|
||||
local: boolean
|
||||
error?: string
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreateTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export type CombinedUsage = {
|
||||
perDevice: DeviceSummary[]
|
||||
combined: {
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreateTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
deviceCount: number
|
||||
reachableCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export type MenubarPayload = {
|
||||
generated: string
|
||||
current: {
|
||||
|
|
@ -180,6 +211,7 @@ export type MenubarPayload = {
|
|||
history: {
|
||||
daily: DailyHistoryEntry[]
|
||||
}
|
||||
combined?: CombinedUsage
|
||||
}
|
||||
|
||||
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
|
||||
|
|
|
|||
|
|
@ -5,16 +5,23 @@ import { sanitizeForSharing } from './sanitize.js'
|
|||
import type { DiscoveredDevice } from './discovery.js'
|
||||
import type { UsageQuery } from './share-server.js'
|
||||
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
import type { CombinedUsage, DeviceSummary, MenubarPayload } from '../menubar-json.js'
|
||||
import { formatCost } from '../currency.js'
|
||||
import { renderTable } from '../text-table.js'
|
||||
import { Chalk } from 'chalk'
|
||||
|
||||
export type { CombinedUsage, DeviceSummary } from '../menubar-json.js'
|
||||
|
||||
// Minimal shape we read from a device's usage payload (the menubar payload).
|
||||
// Cache create/read are only in the daily history, so we sum those.
|
||||
type DevicePayload = {
|
||||
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
|
||||
history?: { daily?: Array<{ cacheReadTokens?: number; cacheWriteTokens?: number }> }
|
||||
history?: { daily?: Array<{ date?: string; cacheReadTokens?: number; cacheWriteTokens?: number }> }
|
||||
}
|
||||
|
||||
type SummaryWindow = {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export type DeviceUsage = {
|
||||
|
|
@ -25,6 +32,80 @@ export type DeviceUsage = {
|
|||
error?: string
|
||||
}
|
||||
|
||||
const zeroUsage = {
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
}
|
||||
|
||||
function num(n: number | undefined): number {
|
||||
return n ?? 0
|
||||
}
|
||||
|
||||
function summarizeOneDevice(d: DeviceUsage, window?: SummaryWindow): DeviceSummary {
|
||||
const error = d.error !== undefined ? d.error : d.payload === undefined ? 'no usage payload' : undefined
|
||||
if (error !== undefined || d.payload === undefined) {
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
local: d.local,
|
||||
error,
|
||||
...zeroUsage,
|
||||
}
|
||||
}
|
||||
|
||||
const cur = d.payload.current
|
||||
const daily = (d.payload.history?.daily ?? []).filter((e) => {
|
||||
if (window === undefined) return true
|
||||
return e.date !== undefined && window.start <= e.date && e.date <= window.end
|
||||
})
|
||||
const inputTokens = num(cur?.inputTokens)
|
||||
const outputTokens = num(cur?.outputTokens)
|
||||
const cacheCreateTokens = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
|
||||
const cacheReadTokens = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
local: d.local,
|
||||
cost: num(cur?.cost),
|
||||
calls: num(cur?.calls),
|
||||
sessions: num(cur?.sessions),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens,
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeDeviceUsage(results: DeviceUsage[], window?: SummaryWindow): CombinedUsage {
|
||||
const perDevice = results.map((d) => summarizeOneDevice(d, window))
|
||||
const combined = perDevice.reduce(
|
||||
(a, d) => {
|
||||
if (d.error !== undefined) return a
|
||||
return {
|
||||
cost: a.cost + d.cost,
|
||||
calls: a.calls + d.calls,
|
||||
sessions: a.sessions + d.sessions,
|
||||
inputTokens: a.inputTokens + d.inputTokens,
|
||||
outputTokens: a.outputTokens + d.outputTokens,
|
||||
cacheCreateTokens: a.cacheCreateTokens + d.cacheCreateTokens,
|
||||
cacheReadTokens: a.cacheReadTokens + d.cacheReadTokens,
|
||||
totalTokens: a.totalTokens + d.totalTokens,
|
||||
deviceCount: a.deviceCount,
|
||||
reachableCount: a.reachableCount + 1,
|
||||
}
|
||||
},
|
||||
{ ...zeroUsage, deviceCount: perDevice.length, reachableCount: 0 },
|
||||
)
|
||||
return { perDevice, combined }
|
||||
}
|
||||
|
||||
function parseHostPort(input: string, defaultPort: number): { host: string; port: number } {
|
||||
const idx = input.lastIndexOf(':')
|
||||
if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) {
|
||||
|
|
@ -118,38 +199,20 @@ export async function pullDevices(
|
|||
// Joined "Totals by machine" report: one row per device plus a bold Combined
|
||||
// row. Tokens are shown as full, comma-grouped numbers.
|
||||
export function renderDevices(results: DeviceUsage[]): string {
|
||||
const num = (n: number | undefined): number => n ?? 0
|
||||
const n = (x: number): string => Math.round(x).toLocaleString()
|
||||
const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,')
|
||||
const rows = results.map((d) => {
|
||||
const cur = d.payload?.current
|
||||
const daily = d.payload?.history?.daily ?? []
|
||||
const input = num(cur?.inputTokens)
|
||||
const output = num(cur?.outputTokens)
|
||||
const cacheCreate = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0)
|
||||
const cacheRead = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0)
|
||||
return {
|
||||
name: d.name + (d.local ? ' (this Mac)' : ''),
|
||||
error: d.error,
|
||||
cost: num(cur?.cost),
|
||||
input,
|
||||
output,
|
||||
cacheCreate,
|
||||
cacheRead,
|
||||
total: input + output + cacheCreate + cacheRead,
|
||||
}
|
||||
})
|
||||
const combined = rows.reduce(
|
||||
(a, r) => ({
|
||||
cost: a.cost + r.cost,
|
||||
input: a.input + r.input,
|
||||
output: a.output + r.output,
|
||||
cacheCreate: a.cacheCreate + r.cacheCreate,
|
||||
cacheRead: a.cacheRead + r.cacheRead,
|
||||
total: a.total + r.total,
|
||||
}),
|
||||
{ cost: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 },
|
||||
)
|
||||
const summary = summarizeDeviceUsage(results)
|
||||
const rows = summary.perDevice.map((d) => ({
|
||||
name: d.name + (d.local ? ' (this Mac)' : ''),
|
||||
error: d.error,
|
||||
cost: d.cost,
|
||||
input: d.inputTokens,
|
||||
output: d.outputTokens,
|
||||
cacheCreate: d.cacheCreateTokens,
|
||||
cacheRead: d.cacheReadTokens,
|
||||
total: d.totalTokens,
|
||||
}))
|
||||
const combined = summary.combined
|
||||
|
||||
const tableRows = [
|
||||
...rows.map((r) =>
|
||||
|
|
@ -157,7 +220,7 @@ export function renderDevices(results: DeviceUsage[]): string {
|
|||
? [r.name, r.error, '-', '-', '-', '-', '-']
|
||||
: [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)],
|
||||
),
|
||||
['Combined', money(combined.cost), n(combined.total), n(combined.input), n(combined.output), n(combined.cacheCreate), n(combined.cacheRead)],
|
||||
['Combined', money(combined.cost), n(combined.totalTokens), n(combined.inputTokens), n(combined.outputTokens), n(combined.cacheCreateTokens), n(combined.cacheReadTokens)],
|
||||
]
|
||||
const table = renderTable(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -154,4 +154,201 @@ describe('codeburn status --format menubar-json', () => {
|
|||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-'))
|
||||
|
||||
try {
|
||||
const projectDir = join(home, '.claude', 'projects', 'myapp')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
|
||||
await writeFile(
|
||||
join(projectDir, 'session.jsonl'),
|
||||
[
|
||||
userLine('s1', ts1),
|
||||
assistantLine('s1', ts2, 'msg-1'),
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const combinedResult = runCli([
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--scope', 'combined',
|
||||
'--period', 'today',
|
||||
'--provider', 'all',
|
||||
'--no-optimize',
|
||||
], home)
|
||||
|
||||
expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0)
|
||||
|
||||
const payload = JSON.parse(combinedResult.stdout) as {
|
||||
current: {
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
history: {
|
||||
daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }>
|
||||
}
|
||||
combined?: {
|
||||
perDevice: Array<{
|
||||
id: string
|
||||
local: boolean
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreateTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
}>
|
||||
combined: {
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreateTokens: number
|
||||
cacheReadTokens: number
|
||||
totalTokens: number
|
||||
deviceCount: number
|
||||
reachableCount: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(payload.combined).toBeDefined()
|
||||
expect(payload.combined!.perDevice).toHaveLength(1)
|
||||
const local = payload.combined!.perDevice[0]!
|
||||
const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0)
|
||||
const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0)
|
||||
const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
expect(local).toMatchObject({
|
||||
id: 'local',
|
||||
local: true,
|
||||
cost: payload.current.cost,
|
||||
calls: payload.current.calls,
|
||||
sessions: payload.current.sessions,
|
||||
inputTokens: payload.current.inputTokens,
|
||||
outputTokens: payload.current.outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
totalTokens,
|
||||
})
|
||||
expect(payload.combined!.combined).toEqual({
|
||||
cost: payload.current.cost,
|
||||
calls: payload.current.calls,
|
||||
sessions: payload.current.sessions,
|
||||
inputTokens: payload.current.inputTokens,
|
||||
outputTokens: payload.current.outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
totalTokens,
|
||||
deviceCount: 1,
|
||||
reachableCount: 1,
|
||||
})
|
||||
|
||||
const localResult = runCli([
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--scope', 'local',
|
||||
'--period', 'today',
|
||||
'--provider', 'all',
|
||||
'--no-optimize',
|
||||
], home)
|
||||
expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0)
|
||||
expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['--provider', ['--provider', 'claude']],
|
||||
['--project', ['--project', 'x']],
|
||||
['--exclude', ['--exclude', 'y']],
|
||||
])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-'))
|
||||
|
||||
try {
|
||||
const result = runCli([
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--scope', 'combined',
|
||||
...filterArgs,
|
||||
'--no-optimize',
|
||||
], home)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid menubar-json scope values', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-'))
|
||||
|
||||
try {
|
||||
const result = runCli([
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--scope', 'remote',
|
||||
'--no-optimize',
|
||||
], home)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('unknown scope "remote"')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('still emits a valid combined menubar payload when the remotes store is corrupt', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-corrupt-remotes-'))
|
||||
|
||||
try {
|
||||
const projectDir = join(home, '.claude', 'projects', 'myapp')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
await writeFile(join(projectDir, 'session.jsonl'), [userLine('s1', ts1), assistantLine('s1', ts2, 'msg-1')].join('\n'))
|
||||
|
||||
// Corrupt the remotes store the combined path reads. The menubar must
|
||||
// still get a valid payload (combined degrades to local-only).
|
||||
const sharingDir = join(home, '.config', 'codeburn', 'sharing')
|
||||
await mkdir(sharingDir, { recursive: true })
|
||||
await writeFile(join(sharingDir, 'remote-devices.json'), '{ this is : not valid json ]')
|
||||
|
||||
const result = runCli([
|
||||
'status', '--format', 'menubar-json', '--scope', 'combined',
|
||||
'--period', 'today', '--no-optimize',
|
||||
], home)
|
||||
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
current: { cost: number }
|
||||
combined?: { perDevice: unknown[]; combined: { deviceCount: number; reachableCount: number } }
|
||||
}
|
||||
expect(payload.combined).toBeDefined()
|
||||
expect(payload.combined!.perDevice).toHaveLength(1)
|
||||
expect(payload.combined!.combined.deviceCount).toBe(1)
|
||||
expect(payload.combined!.combined.reachableCount).toBe(1)
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
|
||||
import { buildMenubarPayload, type CombinedUsage, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
|
||||
import type { OptimizeResult } from '../src/optimize.js'
|
||||
|
||||
function emptyPeriod(label: string): PeriodData {
|
||||
|
|
@ -231,4 +231,42 @@ describe('buildMenubarPayload', () => {
|
|||
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
|
||||
expect(payload.current.providers).toEqual({ claude: 76.45 })
|
||||
})
|
||||
|
||||
it('omits combined usage by default and accepts the documented combined shape when attached', () => {
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
|
||||
expect(payload).not.toHaveProperty('combined')
|
||||
|
||||
const combined: CombinedUsage = {
|
||||
perDevice: [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Mac Studio',
|
||||
local: true,
|
||||
cost: 1,
|
||||
calls: 2,
|
||||
sessions: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheCreateTokens: 10,
|
||||
cacheReadTokens: 20,
|
||||
totalTokens: 180,
|
||||
},
|
||||
],
|
||||
combined: {
|
||||
cost: 1,
|
||||
calls: 2,
|
||||
sessions: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheCreateTokens: 10,
|
||||
cacheReadTokens: 20,
|
||||
totalTokens: 180,
|
||||
deviceCount: 1,
|
||||
reachableCount: 1,
|
||||
},
|
||||
}
|
||||
payload.combined = combined
|
||||
|
||||
expect(payload.combined).toEqual(combined)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,34 +1,38 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { generateIdentity } from '../../src/sharing/identity.js'
|
||||
import { PeerStore } from '../../src/sharing/pairing.js'
|
||||
import { ShareServer } from '../../src/sharing/share-server.js'
|
||||
import { addRemote, pullDevices, renderDevices, type DeviceUsage } from '../../src/sharing/host.js'
|
||||
import { addRemote, pullDevices, renderDevices, summarizeDeviceUsage, type DeviceUsage } from '../../src/sharing/host.js'
|
||||
|
||||
describe('host device flow (loopback)', () => {
|
||||
let server: ShareServer
|
||||
let port: number
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
hello: vi.fn(),
|
||||
pair: vi.fn(),
|
||||
pairRequest: vi.fn(),
|
||||
fetchUsage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/sharing/client.js', () => clientMock)
|
||||
|
||||
describe('host device flow', () => {
|
||||
let dir: string
|
||||
const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } }
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
dir = await mkdtemp(join(tmpdir(), 'cb-host-'))
|
||||
const serverId = await generateIdentity('MacBook')
|
||||
server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => remoteUsage })
|
||||
port = await server.listen(0, '127.0.0.1')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close()
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('pairs, persists, pulls both devices, and combines', async () => {
|
||||
const pin = server.openPairing()
|
||||
const device = await addRemote(`127.0.0.1:${port}`, pin, { defaultPort: port, dir })
|
||||
const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } }
|
||||
clientMock.hello.mockResolvedValue({ status: 200, json: { fingerprint: 'remote-fp', name: 'MacBook' } })
|
||||
clientMock.pair.mockResolvedValue({ status: 200, json: { token: 'remote-token' } })
|
||||
clientMock.fetchUsage.mockResolvedValue({ status: 200, json: remoteUsage })
|
||||
|
||||
const device = await addRemote('127.0.0.1:7777', '123456', { defaultPort: 7777, dir })
|
||||
expect(device.name).toBe('MacBook')
|
||||
expect(device.token).toBeTruthy()
|
||||
|
||||
|
|
@ -41,6 +45,11 @@ describe('host device flow (loopback)', () => {
|
|||
const remote = results.find((r) => !r.local)!
|
||||
expect(remote.name).toBe('MacBook')
|
||||
expect(remote.payload!.current!.cost).toBe(100)
|
||||
expect(clientMock.fetchUsage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ host: '127.0.0.1', port: 7777, expectedFingerprint: 'remote-fp' }),
|
||||
'remote-token',
|
||||
{ period: 'month' },
|
||||
)
|
||||
|
||||
const text = renderDevices(results)
|
||||
expect(text).toContain('Mac Studio (this Mac)')
|
||||
|
|
@ -51,11 +60,174 @@ describe('host device flow (loopback)', () => {
|
|||
|
||||
it('renders an unreachable device as an error without dropping the combined row', () => {
|
||||
const results: DeviceUsage[] = [
|
||||
{ name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } },
|
||||
{ name: 'MacBook', local: false, error: 'connection refused' },
|
||||
{ id: 'local', name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } },
|
||||
{ id: 'remote-1', name: 'MacBook', local: false, error: 'connection refused' },
|
||||
]
|
||||
const text = renderDevices(results)
|
||||
expect(text).toContain('connection refused')
|
||||
expect(text).toContain('Combined')
|
||||
})
|
||||
|
||||
it('summarizes reachable devices and excludes error rows from combined totals', () => {
|
||||
const results: DeviceUsage[] = [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Mac Studio',
|
||||
local: true,
|
||||
payload: {
|
||||
current: { cost: 10, calls: 2, sessions: 1, inputTokens: 100, outputTokens: 40 },
|
||||
history: {
|
||||
daily: [
|
||||
{ cacheWriteTokens: 5, cacheReadTokens: 10 },
|
||||
{ cacheWriteTokens: 7, cacheReadTokens: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'remote-1',
|
||||
name: 'MacBook',
|
||||
local: false,
|
||||
payload: {
|
||||
current: { cost: 3, calls: 4, sessions: 2, inputTokens: 20, outputTokens: 30 },
|
||||
history: { daily: [{ cacheWriteTokens: 2, cacheReadTokens: 8 }] },
|
||||
},
|
||||
},
|
||||
{ id: 'remote-err', name: 'Offline', local: false, error: 'timeout' },
|
||||
]
|
||||
|
||||
const summary = summarizeDeviceUsage(results)
|
||||
|
||||
expect(summary.perDevice).toEqual([
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Mac Studio',
|
||||
local: true,
|
||||
cost: 10,
|
||||
calls: 2,
|
||||
sessions: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cacheCreateTokens: 12,
|
||||
cacheReadTokens: 13,
|
||||
totalTokens: 165,
|
||||
},
|
||||
{
|
||||
id: 'remote-1',
|
||||
name: 'MacBook',
|
||||
local: false,
|
||||
cost: 3,
|
||||
calls: 4,
|
||||
sessions: 2,
|
||||
inputTokens: 20,
|
||||
outputTokens: 30,
|
||||
cacheCreateTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
totalTokens: 60,
|
||||
},
|
||||
{
|
||||
id: 'remote-err',
|
||||
name: 'Offline',
|
||||
local: false,
|
||||
error: 'timeout',
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheCreateTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
])
|
||||
expect(summary.combined).toEqual({
|
||||
cost: 13,
|
||||
calls: 6,
|
||||
sessions: 3,
|
||||
inputTokens: 120,
|
||||
outputTokens: 70,
|
||||
cacheCreateTokens: 14,
|
||||
cacheReadTokens: 21,
|
||||
totalTokens: 225,
|
||||
deviceCount: 3,
|
||||
reachableCount: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes cache-token summaries to the optional window without changing no-window totals', () => {
|
||||
const results: DeviceUsage[] = [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Mac Studio',
|
||||
local: true,
|
||||
payload: {
|
||||
current: { cost: 1, calls: 1, sessions: 1, inputTokens: 100, outputTokens: 50 },
|
||||
history: {
|
||||
daily: [
|
||||
{ date: '2026-04-09', cacheWriteTokens: 100, cacheReadTokens: 1000 },
|
||||
{ date: '2026-04-10', cacheWriteTokens: 5, cacheReadTokens: 10 },
|
||||
{ date: '2026-04-11', cacheWriteTokens: 7, cacheReadTokens: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'remote-1',
|
||||
name: 'MacBook',
|
||||
local: false,
|
||||
payload: {
|
||||
current: { cost: 2, calls: 2, sessions: 1, inputTokens: 20, outputTokens: 30 },
|
||||
history: {
|
||||
daily: [
|
||||
{ date: '2026-04-08', cacheWriteTokens: 11, cacheReadTokens: 13 },
|
||||
{ date: '2026-04-10', cacheWriteTokens: 2, cacheReadTokens: 8 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const all = summarizeDeviceUsage(results)
|
||||
expect(all.perDevice[0]).toMatchObject({
|
||||
cacheCreateTokens: 112,
|
||||
cacheReadTokens: 1013,
|
||||
totalTokens: 1275,
|
||||
})
|
||||
expect(all.perDevice[1]).toMatchObject({
|
||||
cacheCreateTokens: 13,
|
||||
cacheReadTokens: 21,
|
||||
totalTokens: 84,
|
||||
})
|
||||
expect(all.combined).toMatchObject({
|
||||
inputTokens: 120,
|
||||
outputTokens: 80,
|
||||
cacheCreateTokens: 125,
|
||||
cacheReadTokens: 1034,
|
||||
totalTokens: 1359,
|
||||
})
|
||||
|
||||
const scoped = summarizeDeviceUsage(results, { start: '2026-04-10', end: '2026-04-10' })
|
||||
expect(scoped.perDevice[0]).toMatchObject({
|
||||
cacheCreateTokens: 5,
|
||||
cacheReadTokens: 10,
|
||||
totalTokens: 165,
|
||||
})
|
||||
expect(scoped.perDevice[1]).toMatchObject({
|
||||
cacheCreateTokens: 2,
|
||||
cacheReadTokens: 8,
|
||||
totalTokens: 60,
|
||||
})
|
||||
expect(scoped.combined).toMatchObject({
|
||||
cost: 3,
|
||||
calls: 3,
|
||||
sessions: 2,
|
||||
inputTokens: 120,
|
||||
outputTokens: 80,
|
||||
cacheCreateTokens: 7,
|
||||
cacheReadTokens: 18,
|
||||
totalTokens: 225,
|
||||
deviceCount: 2,
|
||||
reachableCount: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue