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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ openswarm start --foreground # run attached (logs stream to the terminal)
openswarm status # pid, uptime, log path
openswarm stop # stop the daemon
openswarm dash # open the web dashboard (:3847)
openswarm cost --since 24h # LLM spend by model (--by stage|task|project|adapter|day, --json)
openswarm cost --since 24h # LLM spend by model (--by stage|task|project|adapter|day|hour, --json)
```

Every model API call is appended to `~/.openswarm/usage/<UTC date>.jsonl` with the
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ program
.command('cost')
.description('Show LLM spend from the usage ledger (per API call, metered by the provider)')
.option('--since <window>', 'Duration back from now (90m, 24h, 7d) or an ISO date', '24h')
.option('--by <key>', 'Group by model | stage | task | project | adapter | day', 'model')
.option('--by <key>', 'Group by model | stage | task | project | adapter | day | hour', 'model')
.option('--json', 'Print the aggregate as JSON')
.action(async (opts: { since: string; by: string; json?: boolean }) => {
const { runCostCommand } = await import('./cli/costCommand.js');
Expand Down
17 changes: 17 additions & 0 deletions src/support/usageLedger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ describe('usage ledger', () => {
expect(aggregateUsage(records, 'day').rows.map((r) => r.key).sort()).toEqual(['2026-09-02', '2026-09-03']);
});

it('groups by UTC hour, so a window shorter than a day is still a series', () => {
// Rendered on the `day` axis a 1h window is one bar, which is not a series
// — the reason the shortest window on the dashboard used to be 24h.
const records = [
record({ ts: '2026-09-02T03:59:59.999Z' }),
record({ ts: '2026-09-02T04:00:00.000Z' }),
record({ ts: '2026-09-02T04:30:00.000Z' }),
];
const rows = aggregateUsage(records, 'hour').rows;

expect(rows.map((r) => r.key).sort()).toEqual(['2026-09-02T03', '2026-09-02T04']);
expect(rows.find((r) => r.key === '2026-09-02T04')?.calls).toBe(2);
// The same records still bucket into one day, so `hour` is an addition and
// not a redefinition of the coarser axis.
expect(aggregateUsage(records, 'day').rows).toHaveLength(1);
});

it('parses durations back from now and ISO dates', () => {
const now = Date.parse('2026-09-02T12:00:00.000Z');
expect(parseUsageSince('90m', now)).toBe(now - 90 * 60_000);
Expand Down
7 changes: 6 additions & 1 deletion src/support/usageLedger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ function isUsageRecord(value: unknown): value is UsageRecord {
&& (typeof v.costUsd === 'number' || v.costUsd === null);
}

export const USAGE_GROUP_KEYS = ['model', 'stage', 'task', 'project', 'adapter', 'day'] as const;
export const USAGE_GROUP_KEYS = ['model', 'stage', 'task', 'project', 'adapter', 'day', 'hour'] as const;
export type UsageGroupKey = (typeof USAGE_GROUP_KEYS)[number];

export interface UsageAggregateRow {
Expand Down Expand Up @@ -173,6 +173,11 @@ function groupKeyOf(record: UsageRecord, by: UsageGroupKey): string {
case 'task': return record.taskId ?? '(unattributed)';
case 'project': return record.cwd ? basename(record.cwd) : '(unknown)';
case 'day': return record.ts.slice(0, 10);
// '2026-09-10T14'. UTC on the wire, like `day`; the client renders it in
// local time. A window shorter than a day drawn on the `day` axis is a
// single bar, which is not a series — which is why the dashboard's
// shortest window used to be 24h. (AGT-4296)
case 'hour': return record.ts.slice(0, 13);
}
}

Expand Down
94 changes: 91 additions & 3 deletions tests/web/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
// @ts-expect-error — browser ESM asset without type declarations
import {
attributedTasks, cacheRate, costPerCall, formatCost, formatPercent, formatTokens,
loadUsage, rateClass, renderDays, renderSummary, renderTable, share,
formatBucket, loadUsage, rateClass, renderDays, renderSummary, renderTable, share,
timeAxisFor, WINDOWS,
rowShare, startUsageView, truncationNote, UNATTRIBUTED, windowFromSearch,
} from '../../web/static/js/usage.mjs';

Expand All @@ -30,6 +31,17 @@ function row(key: string, over: Record<string, number> = {}) {
}

/** Mount the real shell so the tests bind to the same ids the page ships. */
// `UTC on the wire, local on screen` is invisible where the two coincide, and
// CI runners are UTC — a mutant that skips the conversion entirely passes
// there. Pinned to a non-zero offset so the assertion can fail everywhere.
// Only the offset is load-bearing; any non-UTC zone would do. (AGT-4296)
const ORIGINAL_TZ = process.env.TZ;
beforeAll(() => { process.env.TZ = 'Asia/Seoul'; });
afterAll(() => {
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
else process.env.TZ = ORIGINAL_TZ;
});

function mountShell(): void {
document.body.innerHTML = SHELL.replace(/^[\s\S]*?<body[^>]*>/, '').replace(/<\/body>[\s\S]*$/, '');
}
Expand Down Expand Up @@ -390,6 +402,7 @@ describe('loading', () => {
const data = await loadUsage('7d', fetchImpl as never);

const asked = fetchImpl.mock.calls.map(([url]) => new URL(url as string, 'http://x').searchParams.get('by'));
// 7d is past the hourly ceiling, so the time axis is still `day`.
expect(asked.sort()).toEqual(['adapter', 'day', 'model', 'project', 'stage', 'task']);
expect(fetchImpl.mock.calls.every(([url]) => (url as string).includes('since=7d'))).toBe(true);
expect(data.model.rows[0].key).toBe('model-a');
Expand All @@ -401,7 +414,9 @@ describe('loading', () => {

expect(document.querySelector('#table-model tbody td')?.textContent).toBe('model-a');
expect(document.querySelector('#table-stage tbody td')?.textContent).toBe('stage-a');
expect(document.querySelector('#days .bar-day')?.textContent).toBe('day-a');
// The default window is 24h, so the series comes off the hour axis.
expect(document.querySelector('#days .bar-day')?.textContent).toBe('hour-a');
expect(document.querySelector('#days-title')?.textContent).toBe('시간별');
expect(document.querySelector('#status')?.textContent).toContain('기준');
});

Expand Down Expand Up @@ -622,3 +637,76 @@ describe('loading', () => {
expect((fetchImpl.mock.calls[0][0] as string)).toContain('since=30d');
});
});

describe('windows and the time axis (AGT-4296)', () => {
beforeEach(mountShell);

const ok = (by: string) => ({
ok: true,
json: async () => ({ since: '2026-09-09T00:00:00.000Z', until: '2026-09-10T00:00:00.000Z', by, rows: [row(`${by}-a`)], total: row('total') }),
});

it('picks the finest axis that still draws a series, and stops before it is a texture', () => {
expect(timeAxisFor('1h')).toBe('hour');
expect(timeAxisFor('2h')).toBe('hour');
expect(timeAxisFor('48h')).toBe('hour');
expect(timeAxisFor('49h')).toBe('day');
expect(timeAxisFor('7d')).toBe('day');
expect(timeAxisFor('90m')).toBe('hour');
// A hand-typed ISO date can name a window of any length, so it takes the
// coarse axis rather than a guess. Same for a missing value.
expect(timeAxisFor('2026-09-01')).toBe('day');
expect(timeAxisFor(undefined)).toBe('day');
});

it('offers the short windows the selector was missing', () => {
expect(WINDOWS.slice(0, 3)).toEqual(['1h', '2h', '6h']);
const offered = [...document.querySelectorAll('#window option')].map(o => (o as HTMLOptionElement).value);
expect(offered).toEqual(WINDOWS);
// Every option is a window the URL parser will accept back.
expect(offered.every(v => windowFromSearch(`?since=${v}`) === v)).toBe(true);
});

it('asks for the hour axis on a short window and renders it as the series', async () => {
const fetchImpl = vi.fn(async (url: string) => ok(new URL(url, 'http://x').searchParams.get('by')!));
const data = await loadUsage('2h', fetchImpl as never);

const asked = fetchImpl.mock.calls.map(([url]) => new URL(url as string, 'http://x').searchParams.get('by'));
expect(asked).toContain('hour');
expect(asked).not.toContain('day');
expect(data.timeAxis).toBe('hour');
expect(data.time).toBe(data.hour);
});

it("renders a UTC hour bucket on the reader's clock, and leaves a day alone", () => {
// A literal, not the implementation's own expression: recomputing the
// expected value with the code under test says "the code equals the code"
// and would not notice a wrong `timeZone`. 14:00Z is 23시 in the pinned zone.
expect(formatBucket('2026-09-10T14')).toBe('9. 10. 23시');
// A day key must NOT be converted — shifting it by the offset relabels the
// day, which is AGT-4293's scope, not this change's.
expect(formatBucket('2026-09-10')).toBe('2026-09-10');
expect(formatBucket('not-a-bucket')).toBe('not-a-bucket');
expect(formatBucket(undefined)).toBe('');
});

it('keeps the raw bucket key reachable after formatting it', () => {
document.body.innerHTML = '<div id="d"></div>';
renderDays(document.querySelector('#d')!, { rows: [row('2026-09-10T14')], total: row('total') });
const label = document.querySelector('#d .bar-day') as HTMLElement;
expect(label.title).toBe('2026-09-10T14');
expect(label.textContent).toBe('9. 10. 23시');
});

it('heads the card with the axis it actually drew', async () => {
const fetchImpl = vi.fn(async (url: string) => ok(new URL(url, 'http://x').searchParams.get('by')!));
const view = startUsageView({ fetchImpl: fetchImpl as never, location: { search: '' } as never });
(document.querySelector('#window') as HTMLSelectElement).value = '7d';
await view.refresh();
expect(document.querySelector('#days-title')?.textContent).toBe('일별');

(document.querySelector('#window') as HTMLSelectElement).value = '1h';
await view.refresh();
expect(document.querySelector('#days-title')?.textContent).toBe('시간별');
});
});
21 changes: 21 additions & 0 deletions tests/web/usageLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,28 @@ const RAW = readFileSync(resolve(__dirname, '../../web/static/css/usage.css'), '
*/
const CSS = RAW.replace(/\/\*[\s\S]*?\*\//g, '');

const TOKENS_CSS = readFileSync(resolve(__dirname, '../../web/static/css/tokens.css'), 'utf8');

describe('usage.css invariants', () => {
it('references only custom properties tokens.css actually defines', () => {
// Written after three `var(--…)` names that do not exist were shipped into
// this file (`--surface-2`, `--text-1`, `--text-2`; the real names are
// `--surface2`, `--fg-primary`, `--fg-secondary`). An undefined custom
// property is not an error — the declaration is dropped and the rule
// renders with an inherited colour, so it looks plausible. Neither the
// build, nor oxlint, nor jsdom sees it.
const defined = new Set(
[...TOKENS_CSS.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map(m => m[1]),
);
const used = new Set([...CSS.matchAll(/var\((--[a-z0-9-]+)/g)].map(m => m[1]));

expect([...used].filter(name => !defined.has(name))).toEqual([]);
// Guard the guard: if either file stopped being read, both sets would be
// empty and the assertion above would pass while checking nothing.
expect(used.size).toBeGreaterThan(10);
expect(defined.size).toBeGreaterThan(50);
});

it('does not scope the rate colours to table cells', () => {
// `td.rate-low` would leave #sum-cache uncoloured.
expect(CSS).toMatch(/^\.rate-low\s/m);
Expand Down
67 changes: 60 additions & 7 deletions web/static/js/usage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,45 @@
// cache rate, cost per call, calls per task — are the ones that carry the
// cost signal; the raw rows do not have them.

const AXES = ['day', 'model', 'stage', 'adapter', 'project', 'task'];
// AGT-4296: a window shorter than a day, and an axis fine enough to draw it
// on. 24h was the shortest window the selector offered, and a 24h total is
// dominated by whatever ran before the last deploy — the draft cache rate read
// 34.1% over 24h and 66.8% over the last two hours, and only the second one
// described the code actually running.

/** Axes fetched for every window. The time axis is chosen per window. */
const AXES = ['model', 'stage', 'adapter', 'project', 'task'];

/**
* The finest time axis that still draws a series for this window.
*
* Beyond two days an hourly series is 720 bars, which is a texture rather than
* a reading. Anything the selector does not offer — a hand-typed ISO date,
* which can name a window of any length — falls back to the coarse axis rather
* than guessing.
*/
export function timeAxisFor(since) {
const m = /^(\d+)([mhd])$/.exec(String(since ?? '').trim());
if (!m) return 'day';
const hours = Number(m[1]) * { m: 1 / 60, h: 1, d: 24 }[m[2]];
return hours <= 48 ? 'hour' : 'day';
}

/**
* A bucket key as a reader's clock shows it.
*
* `2026-09-10T14` is a UTC hour and carries no minutes, which `Date.parse`
* rejects on its own — so it is completed before parsing. A day key is left
* alone: converting it would shift it by the UTC offset and relabel the day,
* which is AGT-4293's business, not this change's.
*/
export function formatBucket(key) {
const text = String(key ?? '');
if (!text.includes('T')) return text;
const at = new Date(`${text}:00:00Z`);
if (Number.isNaN(at.getTime())) return text;
return at.toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', hour12: false });
}

/**
* cachedTokens / promptTokens, or null when there is nothing to divide.
Expand Down Expand Up @@ -197,7 +235,7 @@ export function renderTable(table, aggregate, { limit = 25, sortBy = 'cost', lab
};
}

/** Render the day axis as bars, oldest first — a series read left to right in time. */
/** Render the time axis as bars, oldest first — a series read left to right in time. */
export function renderDays(container, aggregate) {
container.replaceChildren();
const rows = [...(aggregate?.rows ?? [])].sort((a, b) => String(a.key).localeCompare(String(b.key)));
Expand Down Expand Up @@ -225,7 +263,9 @@ export function renderDays(container, aggregate) {

const day = document.createElement('span');
day.className = 'bar-day';
day.textContent = row.key;
day.textContent = formatBucket(row.key);
// The raw key stays reachable: the label is now a rendering of it.
day.title = row.key;

const track = document.createElement('div');
track.className = 'bar-track';
Expand Down Expand Up @@ -298,16 +338,24 @@ export function renderSummary(root, { model, task }) {
* partial render for the rarer per-request failures is a separate change.
*/
export async function loadUsage(since, fetchImpl = globalThis.fetch) {
const results = await Promise.all(AXES.map(async (by) => {
const timeAxis = timeAxisFor(since);
const results = await Promise.all([timeAxis, ...AXES].map(async (by) => {
const res = await fetchImpl(`/api/usage?since=${encodeURIComponent(since)}&by=${by}`);
if (!res.ok) throw new Error(`/api/usage?by=${by} → ${res.status}`);
return [by, await res.json()];
}));
return Object.fromEntries(results);
const data = Object.fromEntries(results);
// `time` is whichever axis was chosen, so the renderer need not know which.
data.time = data[timeAxis];
data.timeAxis = timeAxis;
return data;
}

/** Windows the selector offers. A hand-typed `?since=` outside this set is ignored. */
export const WINDOWS = ['24h', '7d', '30d'];
// A landed fix is invisible in a 24h total: the draft cache rate read 34.1%
// over 24h and 66.8% over the last two hours, and only the second described
// the deployed code. (AGT-4296)
export const WINDOWS = ['1h', '2h', '6h', '24h', '7d', '30d'];

/** The window named by the URL, or null when it names nothing valid. */
export function windowFromSearch(search) {
Expand Down Expand Up @@ -359,7 +407,12 @@ export function startUsageView({ root = document, fetchImpl = globalThis.fetch,
const data = await loadUsage(since, fetchImpl);
if (seq !== latest) return; // a newer window is already in flight
renderSummary(root, data);
renderDays(root.querySelector('#days'), data.day);
renderDays(root.querySelector('#days'), data.time);
// The card is headed by whoever knows which axis was chosen. Left fixed
// it read 일별 over hourly labels like `9. 10. 23시`, on first load for
// every reader, because the default window is 24h.
const daysTitle = root.querySelector('#days-title');
if (daysTitle) daysTitle.textContent = data.timeAxis === 'hour' ? '시간별' : '일별';
const LABELS = { model: '모델', stage: '스테이지', adapter: '어댑터', project: '프로젝트' };
for (const axis of ['model', 'stage', 'adapter', 'project']) {
const table = root.querySelector(`#table-${axis}`);
Expand Down
3 changes: 3 additions & 0 deletions web/static/usage.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<div class="topbar-actions">
<label class="sr-only" for="window">기간</label>
<select id="window" class="select">
<option value="1h">최근 1시간</option>
<option value="2h">최근 2시간</option>
<option value="6h">최근 6시간</option>
<option value="24h" selected>최근 24시간</option>
<option value="7d">최근 7일</option>
<option value="30d">최근 30일</option>
Expand Down
Loading