fix(stats): restore cached totals and refresh only while visible (#1041)

## Summary
Replace the renderer-only workspace-ID cache with bounded validated
display snapshots in backend-owned state.ui.serviceUsage. Cache survives
recreated renderer/backend/workspace identity, keyed by authenticated
service/discovery/execution-host/folder fingerprint and period/timezone.
Eight entries, 1 MiB maximum; no plaintext credentials or paths.
Preferences does not write the opener's client-state journal.

Show previous totals/charts immediately with a cached-state label while
revalidating; preserve them on error. Hidden/unmounted Stats starts no
reads, cancels trailing work and fences late results. Visibility resumes
one read; execution hints use the existing activity cooldown instead of
one native scan per descendant. Manual refresh, period, reconnect and
deletion/pruning paths retained.

## Validation
- Rebased on current dev 7a2ee897e, retaining #1037 shared fixture
cache/shutdown and token UI plus #1039 launcher changes.
- Server/UI typechecks pass; cache/scheduler units 8/8; browser
service-usage 5/5, usage-metrics 4/4, status-tokens 4/4, sequentially.
- Real renderer AND backend recreation with a two-second stats delay:
2098 ms cold / 68 ms restored, then fresh totals replace cached values.
- Hidden-start regression: base makes one unwanted read; fixed makes
zero. Tests cover hidden events, visibility, burst scheduling, identity
changes, retention/errors and malformed cache bounds.
- Isolated native fixture with four synthetic sessions passes; repeated
native reads ~15–19 ms. OpenCode still repeats aggregation; no claim to
optimize large-history scans.
- No app/shared daemon/user data touched. Live installed app and
non-Windows native acceptance not tested.

## Oversized touched files
http-server.ts ~2446 lines; api-client.ts ~817; 10 locale settings.ts
files ~735–749. No unrelated refactor.
This commit is contained in:
Pascal André 2026-10-20 19:49:45 +02:00 • committed by GitHub
parent b818a28c3d
commit 6f8881d5ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 964 additions and 83 deletions

View file

@ -470,6 +470,9 @@ jobs:
- name: Test server
run: node --import tsx --test "packages/server/src/**/*.test.ts"
- name: Test Stats restoration and connection fences explicitly
run: node --import tsx --test packages/server/src/opencode/service-usage.test.ts
- name: Install Chromium for UI rendering regressions
run: npx playwright install --with-deps chromium

View file

@ -54,7 +54,7 @@
- Code Mode reads native `metadata.toolCalls` in `tool-call/renderers/execute-data.ts`; keep call-list styling in `styles/messaging/tool-call/execute.css`, with native image/output ownership in the shared tool shell.
- Native websearch text is parsed conservatively by `tool-call/renderers/websearch-data.ts`; unknown/oversized responses use the shared raw renderer. Recognized provider-consent Forms retain the generic schema/answer path, with layout in `styles/messaging/tool-call/websearch.css`.
- Settings → Providers separates Models and Web search into two shared `.settings-disclosure` groups (no icons): Models starts open, Web search folded, and the folded Web search mounts nothing, so its connection metadata is read only once opened. Each starts with a provider selector, Connect action and configured-provider list; Web search then shows its global default and project override. Reuse provider rows, selectors, inputs and action styles, without an API-key disclosure or unrelated card design. Search-only integrations stay out of the Models group. Web search configuration lives entirely here; Status has no websearch configuration section. Read connection metadata while this Settings surface is visible, never credential secret values; keep API-key entry explicit and clear drafts on context changes. Use the authorized Global/Project document operations shared with plugin controls and `opencode/native-setting-document.ts` for targeted JSONC edits; the 2.0.18 client has no websearch config-write API. Preserve unrelated fields, native discovery roots, WSL atomic writes and conflict/connection/deletion fences. Group/search styling lives in `styles/components/websearch-settings.css`; credentials use native integration methods and remain global.
- Stats preferences explicitly aggregate the entire connected OpenCode service via `opencode/service-usage.ts`, as authorized for this feature. Reserve the Usage label for the provider subscription panel. Native project IDs span independent clones, so never label native `session.stats` as physical-folder/Git-family scoped. Keep connection/workspace fences, `tools: "none"` and periods bounded to 366 days; never load transcripts for statistics or imply billing/quota precision. Styles live in `styles/components/usage-dashboard.css`.
- Stats preferences explicitly aggregate the entire connected OpenCode service via `opencode/service-usage.ts`, as authorized for this feature. Reserve the Usage label for the provider subscription panel. Native project IDs span independent clones, so never label native `session.stats` as physical-folder/Git-family scoped. Keep connection/workspace fences, `tools: "none"` and periods bounded to 366 days; never load transcripts for statistics or imply billing/quota precision. Last snapshots and period choices restore through backend-owned `state.ui.serviceUsage` (eight each/1 MiB, validated service/channel/folder identity), never a Preferences writer to main-window client state or a workspace-UUID cache. Native aggregation must not await optional discovery/cache reads; cached responses cannot replace fresh data. Backend authorized admission order, not completion order or client time, chooses the period across windows; optional publication is bounded and shutdown-fenced. Keep last-known-owner totals explicitly stale through disconnect/errors and revoke them on verified owner/discovery changes. Read while mounted and document-visible; hide cancels queued/trailing demand, activation revalidates once and terminal hints use the shared activity cooldown. Document visibility is not qualified native minimize/occlusion detection; never equate blur with hidden. See `dev-docs/CACHE_REFRESH_CONVENTIONS.md` for the remaining native scope. Styles live in `styles/components/usage-dashboard.css`.
- Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in `styles/messaging/session-timeline-rail.css`. Hide the rail and force conversation-header actions into their overflow menu below 420 CSS px of the `session-center` container on all platforms. Keep that width-only force separate from measured header density so trial layouts can expand again above the breakpoint; filters anchor and return focus to the visible menu. Docked drawers reserve a minimum conversation width of 390 CSS px before overlaying. The composer worktree selector becomes a compact arrow-only cell below 460 CSS px of that conversation container (not the remaining composer width); keep its accessible label and styles in `styles/components/prompt-context-controls.css`. Timeline visibility must not depend on measured header density, changing status controls or viewport height; preserve the saved visibility preference.
- Timeline geometry uses the cached structural index independently of transcript/excerpt loading. Aggregate tool markers retain the outline's representative tool name so their icon does not fall back to `other`; preserve that metadata through projection and persisted-index revalidation. Hover/focus previews render bounded Markdown, prefetch visible-nearby excerpts and prioritize the hovered marker through `stores/timeline-previews.ts`; never mount whole message/tool cards or fetch transcript windows for hover. Keep the viewport-bounded surface in `styles/messaging/timeline-preview.css`.
- Document any new styling conventions or directory additions in this file so future changes remain consistent.

View file

@ -28,6 +28,7 @@ where their authority, lifetime or runtime differs.
| Timeline previews | At most 512 bounded Markdown excerpts. Visible-nearby batches of 12, hovered ID first; obsolete demand cancels. Thirty-second age prompts on-demand revalidation only. Generation, mutation and undo fence reuse. No transcript-window loads or geometry changes. | `packages/ui/src/stores/timeline-previews.ts` |
| Providers/models | Retained signals; shared in-flight catalogue load; dirty-bit trailing refresh; instance, location and request-generation checks. No completed-result TTL inside `fetchProviders` itself. | `packages/ui/src/stores/session-api.ts` |
| Provider Usage | Panel-local snapshot; same-location `provider.updated` revalidates in place with one in-flight read and coalesced trailing demand. Window-label keys preserve quota DOM across responses. Duplicate transport statuses and instance/status/native-generation snapshots are no-ops, not reconnects. A changed native generation still fences quotas even if the disconnect was missed. Credential/integration/config events, native `server.connected`, real connection transitions and source changes clear the snapshot immediately and fence old responses/trailing demand. Failures revoke quotas rather than retain a possibly different account. Hidden panels do not read; no global browser quota cache. | `components/session/provider-usage-panel.tsx`, `provider-usage-state.ts` |
| Stats | Backend-owned `state.ui.serviceUsage` saves eight validated display snapshots and eight period choices (1 MiB aggregate). Authenticated native discovery root/execution host/channel fingerprint plus exact opened folder, not workspace UUID, isolate snapshot reuse. Native aggregation, optional restoration and owner checks run independently; discovery cannot hold fresh totals. Backend admission order controls the saved period across windows, not response order or client timestamps. Preferences never writes the opener's client state. Previous totals/charts remain explicitly stale through same-owner disconnect/errors; a verified different owner/discovery identity revokes them. A mounted, document-visible Stats section reads; hiding/disposal cancels queued demand and fences in-flight publication. Terminal hints use the shared five-second activity cooldown. Native hidden/minimized/occluded window behavior is not qualified (see below). No polling or transcript reads. | server `opencode/service-usage-cache.ts`, `service-usage.ts`; UI `settings/usage-settings-section.tsx` |
| Session model selection | Native selection events invalidate rather than overwrite the displayed selection, because SSE echoes can lag successful HTTP writes. Coalesced authoritative reads share the session admission queue with prompts and explicit selections; client/generation and captured-selection checks fence late responses. Catalog merges preserve pending optimistic choices; successful writes publish a fresh model object so pre-confirmation snapshots lose authority. Read failures retain display state until the next event or reconnect refresh. | `stores/session-model-reconciliation.ts`, `stores/session-action-queue.ts`, `stores/session-generation-recovery.ts` |
| OpenCode setup | Shared in-flight status read plus coalesced trailing refresh for focus/visibility/reconnect demand. Executable changes and explicit actions fence old responses. Failed reads retain version/path diagnostics but revoke the connected claim and stale reload/restart controls; Continue checks again before retrying the pending workspace. Activation requires a successful pre-action status read. | UI `stores/opencode-setup.ts`, `components/settings/opencode-setup-panel.tsx` |
| Filesystem invalidations | Host directory observations and native filesystem events coalesce before cache publication: 100 ms quiet-case delay, then at most once per five seconds in continuous activity, with no idle timer. Explicit mutations/recovery invalidate immediately. Workspace versions target the browsing root and listed directory; only changed visible directories reread. Hidden/collapsed caches stay stale until demand. Bounded metadata falls back conservatively to full invalidation. | `filesystem-events.ts`, `useWorkspaceTree.ts`, server `workspaces/file-watches.ts` |
@ -51,6 +52,78 @@ by entity and recipient; it never substitutes stale display data for authorizati
An isolated native fixture covers warm-cache create/remove visibility; browser
fixtures cover menu updates, focus, old responses and refresh bursts.
### Stats restoration and remaining native cost
Stats uses the existing backend `SettingsService` UI state owner, not an extra file,
localStorage, or the native per-main-window restoration graph. The same owner is
available to Electron/Tauri Preferences recreation and browser/remote clients.
Each backend/profile owns its own document. Only an opaque SHA-256 fingerprint
is stored: native global discovery root (from authenticated `config.get`), host or
WSL distro, authenticated service channel, and exact backend opened folder. No
credentials, native paths, PID, transient port (when authenticated), or workspace
UUID are serialized. Changing the authenticated service channel deliberately
misses the cache; snapshots never authorize a Location or mutation. Period and
timezone stay separate, and corrupt/oversized entries are optional display misses.
Fresh aggregation does not await discovery or cache restoration. The UI starts a
cheap authenticated owner check, an optional cache read and native aggregation
in parallel. Initial native reads can restore the selected period synchronously
from bounded UI state using the fast execution-host/channel/folder owner key;
that preference is not authorization to reuse metrics. Cache data still requires
the full native discovery identity. A late cache response never replaces fresh
totals or their period. Late discovery may attach identity metadata only when its
opaque live connection token matches the fresh response; tokens are not saved.
The backend assigns monotonically increasing receipt order before asynchronous
connection acquisition. Only an owned, current connection admits that order to
the bounded owner/timezone frontier (64 entries, retired-order floor). The latest
admitted request chooses the saved period, including across windows. Older
responses and out-of-order discovery cannot publish over it. Client `from`/`to`
values never act as freshness authority. Optional snapshot publication is capped
at 32 pending operations, rechecks connection/workspace/admission ownership and
uses the existing 10-second discovery deadline; backend close aborts and drains
these operations before their Settings owner can be disposed.
Disconnect keeps only explicitly stale, last-known-owner display data; it grants
no connected or mutation authority. Reconnect checks host/channel/folder promptly
without discovery. A different owner clears old data, and a different full
discovery identity clears it when verified. If a previous fresh display had no
verified discovery identity, a changed live connection cannot adopt it blindly.
Unknown/failing reconnect observations do not relabel old metrics as new data.
**Native visibility limit:** the visibility regressions change the document's
visibility state synthetically. The reviewed Electron Preferences bridge exposes
minimize as an action but no passive visible/minimized subscription; host-side
`BrowserWindow.isVisible/isMinimized` is not a renderer hook. No corresponding
shared Preferences visibility observation is exposed by the Tauri abstraction.
The gatekeeper's isolated native minimize/background probes did not produce a
hidden document. This change does not claim Electron/Tauri hidden/minimized or
occlusion acceptance. A visible but unfocused window is not necessarily unseen,
so blur is deliberately not treated as hidden. No native window framework or
installed-user-app probe was added; reliable native visibility remains separate
work if the hosts do not publish document visibility transitions.
The isolated browser test recreates both the renderer and SettingsService, changes
the workspace UUID, and delays `session.stats` by 2 s: observed cold display
2,120 ms, restored totals/charts 81 ms, followed by replacement from the current
read. A separate hidden-start regression fails on the original component (one
Stats call while hidden, expected zero). These are fixture observations, not a
claim about the user's service or upstream speed. Additional deferred-promise
regressions hold discovery through native admission and fresh rendering, release
abandoned 30-day reads after a seven-day selection, recreate the backend/renderer,
and verify same-owner disconnect retention and changed-owner/discovery rejection.
Reviewed OpenCode 2.1.0 source (`packages/server/src/handlers/session.ts:71-90`,
`packages/core/src/session/stats.ts:85-190,282-315`) invokes aggregation on every
GET, without a completed-result cache. It queries all matching message rows in
31-day windows, extracts JSON usage fields and folds them in memory. Even with
`tools: "none"`, it enumerates service session IDs and queries compaction usage
events in batches of 500. Identical no-change GETs repeat that work; this is full
range re-aggregation, not evidence about SQLite's physical query plan. The
isolated native fixture's four synthetic sessions took 18.72/16.42/14.62 ms for
three identical reads. Large-history latency remains upstream work; no server
aggregation optimization or native database change is included here.
### Event relay ordering and recovery
- Ordering is per native session (including move/delete), PTY or Shell and recipient,

View file

@ -888,5 +888,8 @@ export type {
export type { PruneRequest, PruneResult } from "./opencode/session-pruning/contract"
export interface ServiceUsageSnapshot {
scope: "service"
owner?: string
identity?: string
connection?: string
stats: import("@opencode/client").SessionStatsInfo
}

View file

@ -0,0 +1,71 @@
import { z } from "zod"
import type { SettingsService } from "../settings/service"
import type { ServiceUsageSnapshot } from "../api-types"
export const usagePeriods = [7, 30, 90, 365] as const
const number = z.number().finite().nonnegative()
const tokens = z.object({ input: number, output: number, reasoning: number, cache: z.object({ read: number, write: number }) })
const snapshot = z.object({ scope: z.literal("service"), stats: z.object({
range: z.object({ from: number, to: number }), sessions: number, subagents: number, prompts: number, steps: number,
tokens, cost: number, tools: z.object({ mode: z.literal("none") }), activeDays: number, streak: number,
models: z.array(z.object({ model: z.object({ providerID: z.string().max(256), id: z.string().max(256), variant: z.string().max(256).optional() }),
steps: number, tokens, cost: number })).max(256),
activity: z.array(z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), steps: number })).max(366),
}) }).refine(value => value.stats.range.to > value.stats.range.from && value.stats.range.to - value.stats.range.from <= 366 * 86_400_000)
const period = z.union([z.literal(7), z.literal(30), z.literal(90), z.literal(365)])
const timezone = z.string().min(1).max(128).refine(value => { try { new Intl.DateTimeFormat("en", { timeZone: value }); return true } catch { return false } })
const entry = z.object({ identity: z.string().regex(/^[a-f0-9]{64}$/), days: period, timezone, snapshot })
.refine(value => value.snapshot.stats.range.to - value.snapshot.stats.range.from === value.days * 86_400_000)
type Entry = z.infer<typeof entry>
type Settings = Pick<SettingsService, "getOwner" | "mergePatchOwner">
const selection = z.object({ owner: z.string().regex(/^[a-f0-9]{64}$/), timezone, days: period })
type Selection = z.infer<typeof selection>
/** Optional display-only UI state, not native client-state or mutation authority.
* The backend is the sole Stats writer, including for secondary Preferences windows. */
export class ServiceUsageCache {
constructor(private readonly settings: Settings) {}
private entries(): Entry[] {
const value = this.settings.getOwner("state", "ui").serviceUsage as { version?: unknown; entries?: unknown } | undefined
if (value?.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 8) return []
try { if (Buffer.byteLength(JSON.stringify(value.entries)) > 1_048_576) return [] }
catch { return [] }
return value.entries.flatMap(value => {
const parsed = entry.safeParse(value)
return parsed.success ? [parsed.data] : []
})
}
private selections() {
const value = this.settings.getOwner("state", "ui").serviceUsage as { selections?: unknown } | undefined
const parsed = z.array(selection).max(8).safeParse(value?.selections)
return parsed.success ? parsed.data : []
}
period(owner: string, timezone: string) {
return this.selections().find(value => value.owner === owner && value.timezone === timezone)?.days ?? 30
}
private save(entries: Entry[], selections: Selection[]) {
// ponytail: eight snapshots/choices, evict oldest instead of partitioning display aggregates.
while (entries.length && Buffer.byteLength(JSON.stringify({ entries, selections })) > 1_048_576) entries.pop()
this.settings.mergePatchOwner("state", "ui", { serviceUsage: { version: 1, entries, selections } })
}
select(owner: string, timezone: string, days: number) {
const parsed = selection.safeParse({ owner, timezone, days })
if (!parsed.success) return
const previous = this.selections()
if (previous[0]?.owner === owner && previous[0].timezone === timezone && previous[0].days === days) return
const selections = [parsed.data, ...previous.filter(value => value.owner !== owner || value.timezone !== timezone)].slice(0, 8)
this.save(this.entries(), selections)
}
get(identity: string, timezone: string, days?: number, owner?: string) {
const count = days ?? (owner ? this.period(owner, timezone) : undefined)
const saved = this.entries().find(value => value.identity === identity && value.timezone === timezone && (count === undefined || value.days === count))
return { days: count ?? saved?.days ?? 30, snapshot: saved?.snapshot as ServiceUsageSnapshot | undefined }
}
set(identity: string, timezone: string, days: number, snapshot: ServiceUsageSnapshot) {
const parsed = entry.safeParse({ identity, timezone, days, snapshot })
if (!parsed.success) return
const previous = this.entries()
const entries = [parsed.data, ...previous.filter(value => !(value.identity === identity && value.timezone === timezone && value.days === days))].slice(0, 8)
this.save(entries, this.selections())
}
}

View file

@ -3,19 +3,35 @@ import { test } from "node:test"
import Fastify from "fastify"
import { ServiceUsage } from "./service-usage"
import { registerServiceUsageRoutes } from "../server/routes/service-usage"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { SettingsService } from "../settings/service"
import { resolveConfigLocation } from "../config/location"
import { ServiceUsageCache } from "./service-usage-cache"
import { applyMergePatch } from "../settings/merge-patch"
function fixture() {
let current = true, present = true, defer: (() => void) | undefined
const record = {}, calls: any[] = []
const connection = { assertCurrent() { assert.ok(current, "stale connection") }, client: {
session: { stats: async (input: any) => { calls.push(input); if (defer) await new Promise<void>(resolve => { defer = resolve }); return { sessions: 3, tools: { mode: "none" } } } },
const record = { path: "/fixture/project" }, calls: any[] = []
let root = "/fixture/discovery", distro: string | undefined
const connection = { endpoint: { url: "http://127.0.0.1:1234", auth: { type: "basic", username: "fixture", password: "secret-must-not-persist" } },
assertCurrent() { assert.ok(current, "stale connection") }, client: {
config: { get: async () => [{ type: "directory", path: root }] },
session: { stats: async (input: any) => { calls.push(input); if (defer) await new Promise<void>(resolve => { defer = resolve }); return stats(input.from, input.to) } },
} }
const manager: any = { get: () => present ? record : undefined, getSharedServiceConnection: async () => connection }
return { service: new ServiceUsage(manager), calls,
const manager: any = { get: () => present ? record : undefined, getSharedServiceConnection: async () => connection,
getServiceWslDistro: () => distro, getServicePathStyle: () => "posix" }
return { service: new ServiceUsage(manager), calls, manager, connection, record, root: (value: string) => { root = value }, distro: (value: string) => { distro = value },
stale: () => { current = false }, close: () => { present = false },
defer: () => { defer = () => {} }, release: () => defer?.() }
}
const query = { from: 1, to: 2, timezone: "UTC" }
function stats(from: number, to: number) {
return { range: { from, to }, sessions: 3, subagents: 1, prompts: 5, steps: 42,
tokens: { input: 123, output: 45, reasoning: 0, cache: { read: 6, write: 7 } }, cost: 0.1,
tools: { mode: "none" as const }, activeDays: 1, streak: 1, models: [], activity: [{ date: "2026-10-20", steps: 42 }] }
}
test("usage explicitly returns service-wide statistics without a misleading project filter", async () => {
const f = fixture()
assert.equal((await f.service.read("w", query)).scope, "service")
@ -43,5 +59,140 @@ test("usage route bounds periods and rejects misleading directory/project and to
for (const extra of ["&project=foreign", "&directory=/repo", "&tools=detail", "&workspaceID=legacy", "&from=0"]) assert.equal((await app.inject(url + extra)).statusCode, 400)
for (const invalid of [url.replace("to=2", "to=1"), url.replace("to=2", "to=999999999999"), url.replace("UTC", "Invalid/Zone")]) assert.equal((await app.inject(invalid)).statusCode, 400)
assert.equal(f.calls.length, 1)
assert.equal((await app.inject("/api/workspaces/w/service-usage/cache?timezone=UTC")).statusCode, 200)
for (const extra of ["&days=366", "&directory=/foreign", "&identity=chosen"]) assert.equal((await app.inject("/api/workspaces/w/service-usage/cache?timezone=UTC" + extra)).statusCode, 400)
assert.equal(f.calls.length, 1, "restoration never calls session.stats")
} finally { await app.close() }
})
test("Stats UI-owner snapshots survive a new backend/settings instance, not workspace UUIDs; connection/folder scopes stay isolated", async () => {
const parent = path.join(os.tmpdir(), "opencode"); await mkdir(parent, { recursive: true })
const root = await mkdtemp(path.join(parent, "stats-state-"))
const logger: any = { child() { return this }, warn() {}, info() {}, debug() {} }
const settings = () => new SettingsService(resolveConfigLocation(path.join(root, "config.yaml")), undefined, logger)
const period = { from: 1, to: 1 + 7 * 86_400_000, timezone: "UTC" }
try {
const first = fixture(), owner = settings()
owner.mergePatchOwner("state", "ui", { recentFolders: ["keep-me"] })
const firstService = new ServiceUsage(first.manager, owner)
await firstService.read("old-uuid", period)
await firstService.cached("old-uuid", "UTC") // Observe completion of optional identity publication, not a timer.
const serialized = JSON.stringify(owner.getOwner("state", "ui"))
assert.ok(!serialized.includes("secret-must-not-persist") && !serialized.includes("old-uuid") && !serialized.includes("/fixture"))
const reopened = fixture(), restored = settings(), service = new ServiceUsage(reopened.manager, restored)
reopened.connection.endpoint.url = "http://127.0.0.1:5678"
assert.equal((await service.cached("new-uuid", "UTC")).snapshot?.stats.steps, 42)
assert.equal((await service.cached("new-uuid", "UTC")).days, 7)
assert.equal(reopened.calls.length, 0)
assert.deepEqual(restored.getOwner("state", "ui").recentFolders, ["keep-me"])
assert.equal((await service.cached("new-uuid", "Europe/Paris")).snapshot, undefined)
assert.equal((await service.cached("new-uuid", "UTC", 30)).snapshot, undefined)
reopened.record.path = "/different/project"
assert.equal((await service.cached("new-uuid", "UTC")).snapshot, undefined)
for (const change of [() => reopened.root("/other/service"), () => { reopened.connection.endpoint.auth.password = "other-service" }, () => reopened.distro("other-distro")]) {
reopened.record.path = "/fixture/project"; change()
assert.equal((await new ServiceUsage(reopened.manager, restored).cached("new-uuid", "UTC")).snapshot, undefined)
}
const stale = fixture(); stale.defer()
const pending = new ServiceUsage(stale.manager, restored).read("stale", { ...period, to: period.to + 1, from: 2 })
while (!stale.calls.length) await new Promise(resolve => setImmediate(resolve))
stale.stale(); stale.release(); await assert.rejects(pending)
assert.equal(JSON.stringify(restored.getOwner("state", "ui")), serialized, "late connection reads cannot alter saved state")
} finally { await rm(root, { recursive: true, force: true }) }
})
test("Stats cache validates optional display state and caps retention without treating query timestamps as admission order", () => {
let owner: any = { unrelated: true }
const settings: any = { getOwner: () => owner, mergePatchOwner: (_kind: string, _owner: string, patch: any) => { owner = { ...owner, ...patch } } }
const cache = new ServiceUsageCache(settings), identity = "a".repeat(64), snapshot = { scope: "service" as const, stats: stats(1, 1 + 30 * 86_400_000) }
cache.set(identity, "UTC", 30, snapshot)
cache.set(identity, "UTC", 30, { ...snapshot, stats: { ...snapshot.stats, range: { from: 0, to: 30 * 86_400_000 }, steps: 1 } })
assert.equal(cache.get(identity, "UTC").snapshot?.stats.steps, 1, "freshness belongs to service admission, not a client-supplied range end")
for (const broken of [null, { version: 2, entries: owner.serviceUsage.entries }, { version: 1, entries: [{ ...owner.serviceUsage.entries[0], days: 7 }] },
{ version: 1, entries: [{ ...owner.serviceUsage.entries[0], snapshot: { ...snapshot, stats: { ...snapshot.stats, steps: -1 } } }] }]) {
owner.serviceUsage = broken
assert.equal(cache.get(identity, "UTC").snapshot, undefined)
}
for (let index = 0; index < 20; index++) cache.set(index.toString(16).padStart(64, "0"), "UTC", 30, snapshot)
assert.equal(owner.serviceUsage.entries.length, 8)
assert.equal(owner.unrelated, true)
})
test("optional cache identity/storage failures do not suppress fresh native totals", async () => {
const period = { from: 1, to: 1 + 7 * 86_400_000, timezone: "UTC" }
const settings: any = { getOwner: () => ({}), mergePatchOwner: () => { throw new Error("fixture disk unavailable") } }
const unavailable = fixture()
unavailable.connection.client.config.get = () => { throw new Error("fixture identity unavailable") }
const service = new ServiceUsage(unavailable.manager, settings)
assert.equal((await service.read("w", period)).stats.steps, 42)
await service.close()
assert.equal((await new ServiceUsage(fixture().manager, settings).read("w", period)).stats.steps, 42)
})
test("P1 held optional discovery never delays native Stats admission or its result", async () => {
const f = fixture(), settings: any = { getOwner: () => ({}), mergePatchOwner: () => ({}) }
let release!: () => void
const held = new Promise<void>(resolve => { release = resolve })
f.connection.client.config.get = async () => { await held; return [{ type: "directory", path: "/fixture/discovery" }] }
const service = new ServiceUsage(f.manager, settings)
const pending = service.read("w", query)
try {
await new Promise(resolve => setImmediate(resolve))
assert.equal(f.calls.length, 1, "optional config.get must not gate session.stats")
assert.equal((await pending).stats.steps, 42, "fresh totals finish while discovery is held")
} finally { release(); await pending }
})
test("P2 abandoned periods cannot reset restoration, including reversed connection admission across windows", async () => {
for (const holdContext of [false, true]) {
const f = fixture()
let owner: any = {}, release!: () => void
const held = new Promise<void>(resolve => { release = resolve })
const settings: any = { getOwner: () => owner, mergePatchOwner: (_kind: string, _owner: string, patch: any) => { owner = applyMergePatch(owner, patch); return owner } }
if (holdContext) {
let first = true
f.manager.getSharedServiceConnection = async () => { if (first) { first = false; await held }; return f.connection }
} else {
f.connection.client.session.stats = async input => { if ((input.to - input.from) / 86400000 === 30) await held; return stats(input.from, input.to) }
}
const service = new ServiceUsage(f.manager, settings), to = 365 * 86400000
const abandoned = service.read("window-a", { from: to - 30 * 86400000, to, timezone: "UTC" })
await new Promise(resolve => setImmediate(resolve))
await service.read("window-b", { from: to - 7 * 86400000, to, timezone: "UTC" })
release(); await abandoned
const restored = await new ServiceUsage(f.manager, settings).cached("new-workspace-id", "UTC")
assert.equal(restored.days, 7, `response order must not choose period (held context=${holdContext})`)
}
})
test("optional persistence is cancelled on shutdown, even when discovery ignores abort", async () => {
const f = fixture(); let owner: any = {}, release!: () => void
const held = new Promise<void>(resolve => { release = resolve })
f.connection.client.config.get = async () => { await held; return [{ type: "directory", path: "/fixture/discovery" }] }
const settings: any = { getOwner: () => owner, mergePatchOwner: (_kind: string, _owner: string, patch: any) => { owner = applyMergePatch(owner, patch); return owner } }
const service = new ServiceUsage(f.manager, settings)
await service.read("w", { from: 1, to: 1 + 7 * 86400000, timezone: "UTC" })
await service.close()
const before = JSON.stringify(owner)
release(); await new Promise(resolve => setImmediate(resolve))
assert.equal(JSON.stringify(owner), before, "closed backend cannot write after its owner is disposed")
assert.deepEqual(owner.serviceUsage.entries, [])
})
test("P2 out-of-order discovery across connections cannot reset the latest admitted period", async () => {
const first = fixture(), second = fixture(); let owner: any = {}, release!: () => void
const held = new Promise<void>(resolve => { release = resolve })
first.connection.client.config.get = async () => { await held; return [{ type: "directory", path: "/old/discovery" }] }
const settings: any = { getOwner: () => owner, mergePatchOwner: (_kind: string, _owner: string, patch: any) => { owner = applyMergePatch(owner, patch); return owner } }
let connection = first.connection
first.manager.getSharedServiceConnection = async () => connection
const service = new ServiceUsage(first.manager, settings), to = 365 * 86400000
await service.read("window-a", { from: to - 30 * 86400000, to, timezone: "UTC" })
connection = second.connection
await service.read("window-b", { from: to - 7 * 86400000, to, timezone: "UTC" })
await service.cached("window-b", "UTC")
release(); await new Promise(resolve => setImmediate(resolve))
assert.equal((await new ServiceUsage(first.manager, settings).cached("restarted", "UTC")).days, 7)
assert.equal(owner.serviceUsage.entries.length, 1, "late old identity cannot publish a superseded snapshot")
await service.close()
})

View file

@ -1,23 +1,144 @@
import type { WorkspaceManager } from "../workspaces/manager"
import { PluginControlsError } from "./plugin-controls"
import { createHash, randomUUID } from "node:crypto"
import path from "node:path"
import type { ServiceConnection } from "../workspaces/opencode-service"
import type { SettingsService } from "../settings/service"
import { ServiceUsageCache } from "./service-usage-cache"
type Manager = Pick<WorkspaceManager, "get" | "getSharedServiceConnection">
export type UsageQuery = { from: number; to: number; timezone: string }
type Manager = Pick<WorkspaceManager, "get" | "getSharedServiceConnection" | "getServiceWslDistro" | "getServicePathStyle">
export type UsageQuery = { from: number; to: number; timezone: string; restorePeriod?: boolean }
// Deliberately service-wide, as authorized for the Usage Preferences surface.
// Native project IDs can span independent clones and are not physical Git-family
// authority. Never present this opaque aggregate as workspace/directory-scoped.
export class ServiceUsage {
constructor(private readonly manager: Manager) {}
async read(workspaceId: string, query: UsageQuery) {
private readonly cache?: ServiceUsageCache
private readonly identities = new WeakMap<ServiceConnection, { promise: Promise<string>; value?: string }>()
private readonly connections = new WeakMap<ServiceConnection, string>()
private readonly closing = new AbortController()
private readonly pending = new Set<Promise<unknown>>()
private readonly admissions = new Map<string, number>()
private sequence = 0
private retiredThrough = 0
async close() {
this.closing.abort()
await Promise.allSettled([...this.pending])
}
constructor(private readonly manager: Manager, settings?: Pick<SettingsService, "getOwner" | "mergePatchOwner">) {
if (settings) this.cache = new ServiceUsageCache(settings)
}
private async context(workspaceId: string) {
if (this.closing.signal.aborted) throw new PluginControlsError("Stats service closed", "unavailable")
const record = this.manager.get(workspaceId)
if (!record) throw new PluginControlsError("Workspace not found", "not-found")
const connection = await this.manager.getSharedServiceConnection(workspaceId)
if (!connection) throw new PluginControlsError("OpenCode connection unavailable", "unavailable")
connection.assertCurrent()
const stats = await connection.client.session.stats({ from: query.from, to: query.to, timezone: query.timezone, tools: "none" })
connection.assertCurrent()
if (this.manager.get(workspaceId) !== record) throw new PluginControlsError("Workspace connection changed", "forbidden")
return { scope: "service" as const, stats }
const current = () => {
if (this.closing.signal.aborted) throw new PluginControlsError("Stats service closed", "unavailable")
connection.assertCurrent()
if (this.manager.get(workspaceId) !== record) throw new PluginControlsError("Workspace connection changed", "forbidden")
}
return { record, connection, current }
}
private ownerKey(workspaceId: string, connection: ServiceConnection, folder: string) {
const auth = connection.endpoint.auth
return createHash("sha256").update(JSON.stringify([this.manager.getServicePathStyle(workspaceId),
this.manager.getServiceWslDistro(workspaceId)?.toLowerCase() ?? `host:${process.platform}`, folder,
auth ? [auth.type, auth.username, auth.password] : connection.endpoint.url])).digest("hex")
}
private connectionKey(connection: ServiceConnection) {
let key = this.connections.get(connection)
if (!key) this.connections.set(connection, key = randomUUID())
return key
}
async owner(workspaceId: string) {
const { connection, record, current } = await this.context(workspaceId)
current()
return { owner: this.ownerKey(workspaceId, connection, record.path), identity: this.knownIdentity(connection, record.path), connection: this.connectionKey(connection) }
}
private knownIdentity(connection: ServiceConnection, folder: string) {
const value = this.identities.get(connection)?.value
return value ? createHash("sha256").update(JSON.stringify([value, folder])).digest("hex") : undefined
}
private async identity(workspaceId: string, connection: ServiceConnection, folder: string) {
let identity = this.identities.get(connection)
if (!identity) {
const style = this.manager.getServicePathStyle(workspaceId)
if (!style) throw new PluginControlsError("Workspace execution identity unavailable", "unavailable")
const paths = style === "win32" ? path.win32 : path.posix
const host = this.manager.getServiceWslDistro(workspaceId)?.toLowerCase() ?? `host:${process.platform}`
const signal = AbortSignal.any([this.closing.signal, AbortSignal.timeout(10_000)])
signal.throwIfAborted()
let cancel!: () => void
const aborted = new Promise<never>((_resolve, reject) => {
cancel = () => reject(signal.reason)
signal.addEventListener("abort", cancel, { once: true })
})
const lookup = Promise.race([Promise.resolve().then(() => connection.client.config.get(undefined, { signal })), aborted]).then(entries => {
connection.assertCurrent()
const root = entries.find(value => value.type === "directory")?.path
if (!root || !paths.isAbsolute(root) || root.includes("\0")) throw new Error("Missing native discovery identity")
const normalized = paths.normalize(root)
// Same authenticated-channel scoping as permission receipts. Only a digest
// survives; never store/expose credentials, transient workspace IDs or PIDs.
const auth = connection.endpoint.auth
return createHash("sha256").update(JSON.stringify([host, style === "win32" ? normalized.toLowerCase() : normalized,
auth ? [auth.type, auth.username, auth.password] : connection.endpoint.url])).digest("hex")
}).finally(() => signal.removeEventListener("abort", cancel))
identity = { promise: lookup }
this.identities.set(connection, identity)
const captured = identity
void lookup.then(value => { captured.value = value }, () => this.identities.delete(connection))
}
// Keep different opened Locations separate even though each result explicitly aggregates the service.
return createHash("sha256").update(JSON.stringify([await identity.promise, folder])).digest("hex")
}
async cached(workspaceId: string, timezone: string, days?: number) {
const { connection, record, current } = await this.context(workspaceId)
const identity = this.cache ? await this.identity(workspaceId, connection, record.path) : ""
current()
const owner = this.ownerKey(workspaceId, connection, record.path)
const saved = this.cache?.get(identity, timezone, days, owner) ?? { days: days ?? 30, snapshot: undefined }
const source = this.connectionKey(connection)
return { ...saved, owner, identity, connection: source, snapshot: saved.snapshot ? { ...saved.snapshot, owner, identity, connection: source } : undefined }
}
async read(workspaceId: string, query: UsageQuery) {
// Backend receipt order, assigned before any asynchronous connection lookup.
// The latest authorized admission wins across windows, never client clocks.
const order = ++this.sequence
const { connection, record, current } = await this.context(workspaceId)
current()
const owner = this.cache ? this.ownerKey(workspaceId, connection, record.path) : undefined
let from = query.from
try { if (owner && query.restorePeriod) from = query.to - this.cache!.period(owner, query.timezone) * 86_400_000 }
catch { /* Optional saved period is not a prerequisite for aggregation. */ }
if (from < 0) throw new PluginControlsError("Restored Stats range starts before the supported epoch", "invalid")
const native = connection.client.session.stats({ from, to: query.to, timezone: query.timezone, tools: "none" })
const key = JSON.stringify([owner, query.timezone]), days = (query.to - from) / 86_400_000
const latest = () => order > this.retiredThrough && this.admissions.get(key) === order
if (order > this.retiredThrough && order > (this.admissions.get(key) ?? 0)) {
this.admissions.delete(key); this.admissions.set(key, order)
if (this.admissions.size > 64) {
const oldest = this.admissions.entries().next().value!
this.retiredThrough = Math.max(this.retiredThrough, oldest[1]); this.admissions.delete(oldest[0])
}
try { if (owner) this.cache?.select(owner, query.timezone, days) }
catch { /* SettingsService logs optional display-state persistence failures. */ }
}
const stats = await native
current()
const snapshot = { scope: "service" as const, stats, owner, identity: this.knownIdentity(connection, record.path), connection: this.connectionKey(connection) }
if (this.cache && latest() && this.pending.size < 32) {
// Native totals return independently. Discovery has its existing bounded
// deadline, shutdown cancellation and late connection/admission fences.
const save = this.identity(workspaceId, connection, record.path).then(identity => {
current()
if (latest()) this.cache?.set(identity, query.timezone, days, snapshot)
}).catch(() => {}).finally(() => this.pending.delete(save))
this.pending.add(save)
}
return snapshot
}
}

View file

@ -320,7 +320,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerPluginControlRoutes(app, { controls: configurationControls })
registerWebSearchSettingsRoutes(app, new WebSearchSettings(configurationControls))
registerSubagentDepthSettingsRoutes(app, new SubagentDepthSettings(configurationControls, deps.workspaceManager))
registerServiceUsageRoutes(app, new ServiceUsage(deps.workspaceManager))
registerServiceUsageRoutes(app, new ServiceUsage(deps.workspaceManager, deps.settings))
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
const accounts = new ProviderAccountsService(deps.settings)
const stopQuotaObserver = observeProviderQuotaFailures(deps.eventBus, accounts)

View file

@ -3,13 +3,26 @@ import { z } from "zod"
import type { ServiceUsage } from "../../opencode/service-usage"
import { PluginControlsError } from "../../opencode/plugin-controls"
const timezone = z.string().min(1).max(128).refine(value => { try { new Intl.DateTimeFormat("en", { timeZone: value }); return true } catch { return false } })
const Query = z.object({
from: z.coerce.number().int().min(0).max(8_640_000_000_000_000),
to: z.coerce.number().int().min(1).max(8_640_000_000_000_000),
timezone: z.string().min(1).max(128).refine(value => { try { new Intl.DateTimeFormat("en", { timeZone: value }); return true } catch { return false } }),
timezone,
restorePeriod: z.literal("true").transform(() => true).optional(),
}).strict().refine(value => value.to > value.from && value.to - value.from <= 366 * 86_400_000)
export function registerServiceUsageRoutes(app: FastifyInstance, usage: Pick<ServiceUsage, "read">) {
export function registerServiceUsageRoutes(app: FastifyInstance, usage: Pick<ServiceUsage, "read" | "cached" | "owner" | "close">) {
app.addHook("onClose", () => usage.close())
app.get<{ Params: { id: string } }>("/api/workspaces/:id/service-usage/owner", async (request, reply) => {
try { return await usage.owner(request.params.id) }
catch { return reply.code(503).send({ error: "Stats display owner unavailable" }) }
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/service-usage/cache", async (request, reply) => {
const query = z.object({ timezone, days: z.coerce.number().refine(value => [7, 30, 90, 365].includes(value)).optional() }).strict().safeParse(request.query)
if (!query.success) return reply.code(400).send({ error: "Invalid Stats display cache query" })
try { return await usage.cached(request.params.id, query.data.timezone, query.data.days) }
catch { return reply.code(503).send({ error: "Stats display cache unavailable" }) }
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/service-usage", async (request, reply) => {
const query = Query.safeParse(request.query)
if (!query.success) return reply.code(400).send({ error: "Invalid bounded service usage query" })

View file

@ -1,57 +1,128 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import type { ServiceUsageSnapshot } from "../../../../server/src/api-types"
import { useI18n } from "../../lib/i18n"
import { serverApi } from "../../lib/api-client"
import { serverEvents } from "../../lib/server-events"
import { isExecutionTerminalEvent } from "../../lib/native-execution-events"
import { activeInstanceId } from "../../stores/instances"
import { getCachedServiceUsage, setCachedServiceUsage } from "../../stores/service-usage-cache"
import { createEventRefreshScheduler, type RefreshUrgency } from "../../lib/event-refresh-scheduler"
export function UsageSettingsSection(props: { instanceId?: string }) {
const { t, locale } = useI18n()
const [days, setDays] = createSignal(30)
const [choice, setChoice] = createSignal<{ id: string; days: number }>()
const [snapshot, setSnapshot] = createSignal<ServiceUsageSnapshot>()
const [busy, setBusy] = createSignal(false), [error, setError] = createSignal(false)
const [stale, setStale] = createSignal(true)
const [visible, setVisible] = createSignal(document.visibilityState === "visible")
const visibility = () => setVisible(document.visibilityState === "visible")
document.addEventListener("visibilitychange", visibility)
onCleanup(() => document.removeEventListener("visibilitychange", visibility))
const instanceId = () => props.instanceId ?? activeInstanceId() ?? ""
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
let refresh = () => {}
createEffect(() => {
const id = instanceId(), count = days()
let disposed = false, reading = false, trailing = false
// Cache-first: the last snapshot for this period displays immediately and revalidates.
setSnapshot(id ? getCachedServiceUsage(id, count, timezone) : undefined); setError(false); setBusy(false)
const id = instanceId(), selected = choice()?.id === id ? choice()?.days : undefined
let count = selected ?? 30, disposed = false, revision = 0, invalidation = 0, restore = true, connected = true
let periodKnown = selected !== undefined, freshEpoch = -1
let controller: AbortController | undefined
setDays(count); setSnapshot(undefined); setError(false); setBusy(false); setStale(true)
if (!id) return
const current = (epoch: number) => !disposed && untrack(visible) && epoch === revision
const load = async () => {
if (disposed) return
if (reading) { trailing = true; return }
reading = true; setBusy(true)
do {
trailing = false
const to = Date.now()
try {
const next = await serverApi.getServiceUsage(id, { from: to - count * 86_400_000, to, timezone })
if (!disposed && !trailing) {
setSnapshot(next); setError(false)
setCachedServiceUsage(id, count, timezone, next)
if (disposed || !untrack(visible) || !connected) return
controller?.abort()
const epoch = ++revision, observed = invalidation
controller = new AbortController()
const signal = controller.signal
let verified: Pick<ServiceUsageSnapshot, "owner" | "identity" | "connection"> | undefined
setBusy(true)
// These are independent reads: optional discovery/restoration never gates
// native aggregation. Only the last known owner survives disconnects.
const verify = (owner?: string, identity?: string, connection?: string) => {
if (!current(epoch) || freshEpoch === epoch) return
const previous = untrack(snapshot)
if (previous && (owner && previous.owner && owner !== previous.owner
|| identity && previous.identity && identity !== previous.identity
|| !previous.identity && connection && previous.connection && connection !== previous.connection)) setSnapshot(undefined)
}
void serverApi.getServiceUsageOwner(id, signal).then(value => {
if (!current(epoch) || freshEpoch === epoch) return
verify(value.owner, value.identity, value.connection)
verified = value
}).catch(() => {})
if (restore) {
void serverApi.getCachedServiceUsage(id, timezone, selected, signal).then(cached => {
if (!current(epoch)) return
if (freshEpoch === epoch) {
const fresh = untrack(snapshot)
// Bind identity metadata only, never cached totals/period, and only
// when discovery belongs to the very same native connection.
if (fresh && fresh.owner && fresh.connection && fresh.connection === cached.connection && fresh.owner === cached.owner
&& !fresh.identity && cached.identity) setSnapshot({ ...fresh, identity: cached.identity })
return
}
} catch { if (!disposed && !trailing) setError(true) }
} while (!disposed && trailing)
reading = false
if (!disposed) setBusy(false)
if (verified?.owner && cached.owner && verified.owner !== cached.owner
|| verified?.identity && cached.identity && verified.identity !== cached.identity) return
verify(cached.owner, cached.identity, cached.connection)
// A proof is one tuple; never splice A's discovery into B's connection.
verified = { owner: cached.owner, identity: cached.identity, connection: cached.connection }
count = cached.days; periodKnown = true; setDays(count)
if (cached.snapshot) { setSnapshot(cached.snapshot); setStale(true) }
}).catch(() => {}).finally(() => {
if (current(epoch)) restore = false
})
}
const to = Date.now()
try {
const next = await serverApi.getServiceUsage(id, { from: to - count * 86_400_000, to, timezone, ...(!periodKnown ? { restorePeriod: true } : {}) }, signal)
if (current(epoch)) {
if (verified?.owner && next.owner && verified.owner !== next.owner
|| verified?.identity && next.identity && verified.identity !== next.identity) {
setSnapshot(undefined); freshEpoch = epoch // Fence late optional replies as well as retained metrics.
throw new Error("Stats display owner changed")
}
freshEpoch = epoch
count = (next.stats.range.to - next.stats.range.from) / 86_400_000; periodKnown = true; setDays(count)
const identity = verified?.owner && verified.connection && next.owner === verified.owner && next.connection === verified.connection
? verified.identity : undefined
setSnapshot({ ...next, identity: next.identity ?? identity }); setError(false); setStale(observed !== invalidation)
}
} catch { if (current(epoch)) { setError(true); setStale(true) } }
if (current(epoch)) setBusy(false)
}
refresh = () => { void load() }
// Native stats are durable aggregates: refresh at settled session changes,
// never per token/delta and never by loading transcript messages.
const scheduler = createEventRefreshScheduler(load)
const invalidate = (urgency: RefreshUrgency = "urgent") => {
invalidation++; setStale(true)
if (untrack(visible) && connected) scheduler.schedule("stats", urgency)
}
refresh = () => invalidate()
// Terminal events are invalidation hints, not proof a task finished. Bursts
// share the existing activity cooldown, not one full scan per descendant.
const events = serverEvents.on("instance.event", payload => {
if (payload.type !== "instance.event" || payload.instanceId !== id) return
if (isExecutionTerminalEvent(payload.event) || ["session.deleted", "rpc.codenomad.session-pruning.pruned"].includes(payload.event.type)) refresh()
if (isExecutionTerminalEvent(payload.event)) invalidate("activity")
else if (["session.deleted", "rpc.codenomad.session-pruning.pruned"].includes(payload.event.type)) invalidate()
})
const revoke = () => {
revision++; controller?.abort(); scheduler.cancel("stats"); restore = true
setBusy(false); setStale(true)
}
let connectionStatus: string | undefined
const status = serverEvents.on("instance.eventStatus", payload => {
if (payload.type === "instance.eventStatus" && payload.instanceId === id && payload.status === "connected") refresh()
if (payload.type !== "instance.eventStatus" || payload.instanceId !== id) return
const key = `${payload.status}:${payload.generation}`
if (key === connectionStatus) return
connectionStatus = key; connected = payload.status === "connected"; revoke()
if (payload.status === "connected") invalidate()
})
const reconnect = serverEvents.onOpen(() => refresh())
onCleanup(() => { disposed = true; events(); status(); reconnect() })
void load()
const reconnect = serverEvents.onOpen(() => { connected = true; revoke(); invalidate() })
const transport = serverEvents.onTransportStatus(status => { if (status === "disconnected") { connected = false; revoke() } })
createEffect(() => {
if (visible()) invalidate()
else { revision++; controller?.abort(); scheduler.cancel("stats"); setBusy(false); setStale(true) }
})
onCleanup(() => { disposed = true; controller?.abort(); scheduler.cancel("stats"); events(); status(); reconnect(); transport(); refresh = () => {} })
})
const number = (value: number) => new Intl.NumberFormat(locale()).format(value)
const money = (value: number) => new Intl.NumberFormat(locale(), { style: "currency", currency: "USD", maximumFractionDigits: 4 }).format(value)
@ -66,12 +137,13 @@ export function UsageSettingsSection(props: { instanceId?: string }) {
<p class="settings-card-subtitle">{t("settings.usage.scope")}</p>
<Show when={instanceId()} fallback={<p role="status">{t("settings.usage.noProject")}</p>}>
<div class="usage-toolbar">
<label>{t("settings.usage.period")}<select class="selector-trigger" aria-label={t("settings.usage.period")} value={days()} onChange={event => setDays(Number(event.currentTarget.value))}>
<label>{t("settings.usage.period")}<select class="selector-trigger" aria-label={t("settings.usage.period")} value={days()} onChange={event => setChoice({ id: instanceId(), days: Number(event.currentTarget.value) })}>
<For each={[7, 30, 90, 365]}>{count => <option value={count}>{t("settings.usage.days", { count })}</option>}</For>
</select></label>
<button class="selector-button selector-button-secondary" type="button" disabled={busy()} onClick={() => refresh()}>{t("settings.providers.refresh")}</button>
</div>
<Show when={busy()}><p role="status">{t("settings.usage.loading")}</p></Show>
<Show when={busy() && !snapshot()}><p role="status">{t("settings.usage.loading")}</p></Show>
<Show when={snapshot() && stale()}><p role="status">{t("settings.usage.cached")}</p></Show>
<Show when={error()}><p role="alert">{t("settings.usage.error")}</p></Show>
<Show when={snapshot()}>{data => <>
<p>{new Date(data().stats.range.from).toLocaleString(locale())} — {new Date(data().stats.range.to).toLocaleString(locale())} · {timezone}</p>

View file

@ -402,8 +402,14 @@ export const serverApi = {
getWebSearchSettings(instanceId: string, directory: string): Promise<import("../../../server/src/api-types").WebSearchSettingsSnapshot> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/websearch-settings?${new URLSearchParams({ directory })}`)
},
getServiceUsage(instanceId: string, query: { from: number; to: number; timezone: string }): Promise<import("../../../server/src/api-types").ServiceUsageSnapshot> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/service-usage?${new URLSearchParams({ from: String(query.from), to: String(query.to), timezone: query.timezone })}`)
getServiceUsageOwner(instanceId: string, signal?: AbortSignal): Promise<{ owner: string; identity?: string; connection?: string }> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/service-usage/owner`, { signal })
},
getCachedServiceUsage(instanceId: string, timezone: string, days?: number, signal?: AbortSignal): Promise<{ days: number; owner?: string; identity?: string; connection?: string; snapshot?: import("../../../server/src/api-types").ServiceUsageSnapshot }> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/service-usage/cache?${new URLSearchParams({ timezone, ...(days ? { days: String(days) } : {}) })}`, { signal })
},
getServiceUsage(instanceId: string, query: { from: number; to: number; timezone: string; restorePeriod?: boolean }, signal?: AbortSignal): Promise<import("../../../server/src/api-types").ServiceUsageSnapshot> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/service-usage?${new URLSearchParams({ from: String(query.from), to: String(query.to), timezone: query.timezone, ...(query.restorePeriod ? { restorePeriod: "true" } : {}) })}`, { signal })
},
setWebSearchSettings(instanceId: string, payload: import("../../../server/src/api-types").WebSearchSettingsMutation): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(instanceId)}/websearch-settings`, { method: "PUT", body: JSON.stringify(payload) })

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "Zuletzt gespeicherte Statistik · Aktualisierung ausstehend",
"settings.native.openFailed": "Die Einstellungen konnten nicht in ihrem Fenster geöffnet werden. Starten oder aktualisieren Sie die CodeNomad-App und versuchen Sie es erneut.",
"settings.accounts.current": "Aktives Konto",
"settings.usage.title": "Statistik",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "Previous saved statistics · awaiting refresh",
"settings.native.openFailed": "Preferences could not open in their window. Restart or update the CodeNomad desktop app, then try again.",
"settings.accounts.current": "Active account",
"settings.usage.title": "Stats",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "Últimas estadísticas guardadas · actualización pendiente",
"settings.native.openFailed": "No se pudieron abrir las preferencias en su ventana. Reinicia o actualiza la aplicación CodeNomad e inténtalo de nuevo.",
"settings.accounts.current": "Cuenta activa",
"settings.usage.title": "Estadísticas",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "Dernières statistiques sauvegardées · en attente d’actualisation",
"settings.native.openFailed": "Les préférences n'ont pas pu s'ouvrir dans leur fenêtre. Redémarrez ou mettez à jour l'application CodeNomad, puis réessayez.",
"settings.accounts.current": "Compte actif",
"settings.usage.title": "Stats",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "הנתונים הסטטיסטיים האחרונים שנשמרו · ממתין לרענון",
"settings.native.openFailed": "לא ניתן היה לפתוח את ההעדפות בחלון שלהן. הפעילו מחדש או עדכנו את אפליקציית CodeNomad ונסו שוב.",
"settings.accounts.current": "חשבון פעיל",
"settings.usage.title": "סטטיסטיקות",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "前回保存した統計 · 更新待ち",
"settings.native.openFailed": "設定ウィンドウを開けませんでした。CodeNomad アプリを再起動または更新してから、もう一度お試しください。",
"settings.accounts.current": "有効なアカウント",
"settings.usage.title": "統計",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "पछिल्लो सुरक्षित तथ्याङ्क · अद्यावधिकको प्रतीक्षामा",
"settings.native.openFailed": "प्राथमिकताहरू तिनको विन्डोमा खोल्न सकिएन। CodeNomad एप पुनः सुरु गर्नुहोस् वा अद्यावधिक गर्नुहोस्, त्यसपछि फेरि प्रयास गर्नुहोस्।",
"settings.accounts.current": "सक्रिय खाता",
"settings.usage.title": "तथ्याङ्क",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "Последняя сохранённая статистика · ожидание обновления",
"settings.native.openFailed": "Не удалось открыть настройки в их окне. Перезапустите или обновите приложение CodeNomad и повторите попытку.",
"settings.accounts.current": "Активный аккаунт",
"settings.usage.title": "Статистика",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "Son kaydedilen istatistikler · yenileme bekleniyor",
"settings.native.openFailed": "Tercihler kendi penceresinde açılamadı. CodeNomad uygulamasını yeniden başlatın veya güncelleyin, ardından tekrar deneyin.",
"settings.accounts.current": "Etkin hesap",
"settings.usage.title": "İstatistikler",

View file

@ -1,4 +1,5 @@
export const settingsMessages = {
"settings.usage.cached": "上次保存的统计数据 · 等待刷新",
"settings.native.openFailed": "无法在设置窗口中打开偏好设置。请重启或更新 CodeNomad 应用后重试。",
"settings.accounts.current": "活动账户",
"settings.usage.title": "统计",

View file

@ -1,16 +0,0 @@
import type { ServiceUsageSnapshot } from "../../../server/src/api-types"
// Display snapshots for the Stats section, kept for the renderer's lifetime so
// reopening a period shows the last result while it revalidates. Workspace IDs
// are not stable across restarts, so persisting them would never be reused.
const memory = new Map<string, ServiceUsageSnapshot>()
const key = (instanceId: string, days: number, timezone: string) => `${instanceId}\0${days}\0${timezone}`
export function getCachedServiceUsage(instanceId: string, days: number, timezone: string): ServiceUsageSnapshot | undefined {
return memory.get(key(instanceId, days, timezone))
}
export function setCachedServiceUsage(instanceId: string, days: number, timezone: string, snapshot: ServiceUsageSnapshot) {
memory.set(key(instanceId, days, timezone), snapshot)
}

View file

@ -10,27 +10,59 @@ import { serverApi } from "../../../src/lib/api-client"
import { serverEvents } from "../../../src/lib/server-events"
import { applyUiSettings } from "./ui-settings"
import "../../../src/index.css"
const [instanceId, setInstanceId] = createSignal("usage"), [visible, setVisible] = createSignal(true)
const [instanceId, setInstanceId] = createSignal(new URLSearchParams(location.search).get("instance") ?? "usage"), [visible, setVisible] = createSignal(true)
let fail = false, defer = false, release: (() => void) | undefined
let releaseCache: (() => void) | undefined
const queries: any[] = []
serverApi.getServiceUsage = async (id, query) => {
const backend = new URLSearchParams(location.search).has("backend")
const getUsage = serverApi.getServiceUsage
let ownerReplies = 0, cacheReplies = 0
if (backend) {
const getOwner = serverApi.getServiceUsageOwner, getCache = serverApi.getCachedServiceUsage
serverApi.getServiceUsageOwner = async (...args) => { const value = await getOwner(...args); ownerReplies++; return value }
serverApi.getCachedServiceUsage = async (...args) => { const value = await getCache(...args); cacheReplies++; return value }
}
const memory = new Map<string, any>()
let owner = "fixture-service", identity = "fixture-discovery"
let connection = "fixture-connection"
if (!backend) serverApi.getServiceUsageOwner = async () => ({ owner, identity, connection })
if (!backend) serverApi.getCachedServiceUsage = async (id, timezone, days) => {
const value = memory.get(`${id}:${days ?? 30}:${timezone}`)
if (new URLSearchParams(location.search).has("heldCache")) await new Promise<void>(resolve => { releaseCache = resolve })
return { days: days ?? 30, snapshot: value, owner, identity, connection }
}
serverApi.getServiceUsage = async (id, query, signal) => {
queries.push(query)
if (backend) return getUsage(id, query, signal)
const sourceOwner = owner, sourceIdentity = identity, sourceConnection = connection
if (defer) { defer = false; await new Promise<void>(resolve => { release = resolve }) }
if (fail) { fail = false; throw new Error("fixture failed") }
const steps = id === "usage" ? 42 : 7
const tokens = { input: 12345, output: 987, reasoning: 100, cache: { read: 6789, write: 456 } }
return { scope: "service", stats: { range: { from: query.from, to: query.to }, sessions: 3, subagents: 2, prompts: 10, steps,
tokens, cost: 1.2345, tools: { mode: "none" }, activeDays: 2, streak: 2,
const snapshot = { scope: "service" as const, owner: sourceOwner, identity: sourceIdentity, connection: sourceConnection, stats: { range: { from: query.from, to: query.to }, sessions: 3, subagents: 2, prompts: 10, steps,
tokens, cost: 1.2345, tools: { mode: "none" as const }, activeDays: 2, streak: 2,
models: [{ model: { providerID: "fixture", id: "synthetic", variant: "high" }, tokens, steps, cost: 1.2345 }],
activity: [{ date: "2026-09-25", steps: Math.floor(steps / 2) }, { date: "2026-09-26", steps: Math.ceil(steps / 2) }],
} }
memory.set(`${id}:${(query.to - query.from) / 86400000}:${query.timezone}`, snapshot)
return snapshot
}
await applyUiSettings({})
setActiveSettingsSection("usage")
const mountedAt = performance.now()
if (new URLSearchParams(location.search).has("hidden")) Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" })
let metricsAt: number | undefined
const observer = new MutationObserver(() => { if (metricsAt === undefined && document.querySelector(".usage-metrics")) metricsAt = performance.now() })
observer.observe(document.getElementById("root")!, { childList: true, subtree: true })
render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}>
{new URLSearchParams(location.search).has("parent")
? <SettingsScreen standalone providerContext={{ instanceId: instanceId() }} />
: <main style={{ width: "min(760px, 100%)" }}><UsageSettingsSection instanceId={instanceId()} /></main>}
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = { queries, setInstanceId, setVisible, fail: () => { fail = true }, defer: () => { defer = true }, release: () => release?.(),
;(window as any).fixture = { queries, setInstanceId, setVisible, mountedAt, get metricsAt() { return metricsAt }, fail: () => { fail = true }, defer: () => { defer = true }, release: () => release?.(), releaseCache: () => releaseCache?.(),
get ownerReplies() { return ownerReplies }, get cacheReplies() { return cacheReplies },
changeOwner: () => { owner = "changed-service"; connection += "next"; memory.clear() }, changeDiscovery: () => { identity = "changed-discovery"; connection += "next"; memory.clear() },
documentHidden: (hidden: boolean) => { Object.defineProperty(document, "visibilityState", { configurable: true, value: hidden ? "hidden" : "visible" }); document.dispatchEvent(new Event("visibilitychange")) },
connection: (status = "connected", generation = 1) => (serverEvents as any).dispatch({ type: "instance.eventStatus", instanceId: "usage", status, generation }),
transport: (status: string) => (serverEvents as any).emitTransportStatus(status),
refresh: (type = "session.execution.succeeded") => (serverEvents as any).dispatch({ type: "instance.event", instanceId: "usage", event: { type, data: { sessionID: "s" } } }) }

View file

@ -1,9 +1,114 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { startProductFixture } from "./fixtures/product-fixture-server"
import { mkdtemp, mkdir, rm } from "node:fs/promises"
import path from "node:path"
import os from "node:os"
import { SettingsService } from "../../../server/src/settings/service"
import { resolveConfigLocation } from "../../../server/src/config/location"
import { ServiceUsage } from "../../../server/src/opencode/service-usage"
import type { BrowserContextOptions } from "playwright"
async function statsPage(fixture: Awaited<ReturnType<typeof startProductFixture>>, options: BrowserContextOptions = {}) {
const page = await fixture.browser.newPage(options)
// Events are dispatched explicitly by this fixture. A JSON stub is not a
// real SSE connection and must not generate unrelated reconnect failures.
await page.addInitScript(() => { window.EventSource = class extends EventTarget { close() {} } as any })
return page
}
function ownerProbeSnapshot(steps: number, owner: string, connection: string, identity?: string) {
const to = Date.now(), tokens = { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
return { scope: "service", owner, connection, ...(identity ? { identity } : {}), stats: {
range: { from: to - 30 * 86400000, to }, sessions: 1, subagents: 0, prompts: 1, steps,
tokens, cost: 0, tools: { mode: "none" }, activeDays: 1, streak: 1, models: [], activity: [{ date: "2026-10-20", steps }],
} }
}
test("Pass2 missing fresh identity cannot inherit another connection's proof, with cache before or after fresh totals", async () => {
const fixture = await startProductFixture("service-usage")
try {
for (const cacheFirst of [true, false]) {
const page = await statsPage(fixture, { locale: "en-US" })
let returning = false, releaseFresh!: () => void, releaseCache!: () => void
const freshGate = new Promise<void>(resolve => { releaseFresh = resolve }), cacheGate = new Promise<void>(resolve => { releaseCache = resolve })
const proofA = { owner: "shared-channel", identity: "discovery-a", connection: "connection-a" }
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
await page.route("**/api/workspaces/*/service-usage**", async route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith("/owner")) return route.fulfill({ json: proofA })
if (path.endsWith("/cache")) {
await cacheGate
return route.fulfill({ json: { ...proofA, days: 30, ...(!returning ? { snapshot: ownerProbeSnapshot(11, proofA.owner, proofA.connection, proofA.identity) } : {}) } })
}
if (returning) return route.fulfill({ status: 503, json: {} })
await freshGate
return route.fulfill({ json: ownerProbeSnapshot(84, proofA.owner, "connection-b") })
})
try {
await page.goto(`${fixture.url}?backend`)
await page.waitForFunction(() => (window as any).fixture.ownerReplies === 1)
if (cacheFirst) {
releaseCache()
await page.waitForFunction(() => [...document.querySelectorAll(".usage-metrics dt")].find(el => el.textContent === "Steps")?.nextElementSibling?.textContent === "11")
}
releaseFresh()
await page.waitForFunction(() => [...document.querySelectorAll(".usage-metrics dt")].find(el => el.textContent === "Steps")?.nextElementSibling?.textContent === "84")
releaseCache()
await page.waitForFunction(() => (window as any).fixture.cacheReplies === 1)
returning = true
await page.evaluate(() => (window as any).fixture.connection("connected", 2))
await page.getByRole("alert").waitFor()
assert.equal(await page.locator(".usage-metrics").count(), 0, `B totals must not be relabelled as A (cacheFirst=${cacheFirst})`)
assert.equal(await page.locator("meter").count(), 0)
} finally { releaseFresh(); releaseCache(); await page.close() }
}
} finally { await fixture.close() }
})
test("Pass2 fresh owner or discovery conflicts revoke retained cached metrics instead of leaving them beside an error", async () => {
const fixture = await startProductFixture("service-usage")
try {
for (const { change, cacheFirst } of [
{ change: "owner", cacheFirst: true }, { change: "identity", cacheFirst: true },
{ change: "owner", cacheFirst: false }, { change: "identity", cacheFirst: false },
]) {
const page = await statsPage(fixture, { locale: "en-US" })
let release!: () => void, releaseCache!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const cacheGate = new Promise<void>(resolve => { releaseCache = resolve })
const proofA = { owner: "owner-a", identity: "discovery-a", connection: "connection-a" }
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
await page.route("**/api/workspaces/*/service-usage**", async route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith("/owner")) return route.fulfill({ json: proofA })
if (path.endsWith("/cache")) {
await cacheGate
return route.fulfill({ json: { ...proofA, days: 30, snapshot: ownerProbeSnapshot(11, proofA.owner, proofA.connection, proofA.identity) } })
}
await gate
return route.fulfill({ json: ownerProbeSnapshot(84, change === "owner" ? "owner-b" : proofA.owner, "connection-b", "discovery-b") })
})
try {
await page.goto(`${fixture.url}?backend`)
await page.waitForFunction(() => (window as any).fixture.ownerReplies === 1)
if (cacheFirst) {
releaseCache()
await page.waitForFunction(() => [...document.querySelectorAll(".usage-metrics dt")].find(el => el.textContent === "Steps")?.nextElementSibling?.textContent === "11")
}
release()
await page.getByRole("alert").waitFor()
releaseCache()
await page.waitForFunction(() => (window as any).fixture.cacheReplies === 1)
assert.equal(await page.locator(".usage-metrics").count(), 0, `confirmed ${change} conflict must revoke A totals`)
assert.equal(await page.locator("meter").count(), 0)
} finally { release(); releaseCache(); await page.close() }
}
} finally { await fixture.close() }
})
test("service-wide usage dashboard declares scope, bounds requests, fences stale connection reads and stops when disposed", async () => {
const fixture = await startProductFixture("service-usage"), page = await fixture.browser.newPage({ viewport: { width: 380, height: 1000 }, locale: "en-US" })
const fixture = await startProductFixture("service-usage"), page = await statsPage(fixture, { viewport: { width: 380, height: 1000 }, locale: "en-US" })
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
@ -21,7 +126,8 @@ test("service-wide usage dashboard declares scope, bounds requests, fences stale
await page.getByRole("button", { name: "Refresh", exact: true }).click()
await page.getByRole("alert").waitFor()
assert.equal(await page.locator(".usage-metrics").count(), 1)
await page.evaluate(() => { (window as any).fixture.defer(); (window as any).fixture.refresh() })
await page.evaluate(() => { (window as any).fixture.defer(); (window as any).fixture.refresh("session.deleted") })
await page.waitForFunction(() => (window as any).fixture.queries.length === 4)
await page.evaluate(() => (window as any).fixture.setInstanceId("other"))
const steps = page.locator(".usage-metrics > div").filter({ has: page.getByText("Steps", { exact: true }) }).locator("dd")
await page.waitForFunction(() => [...document.querySelectorAll(".usage-metrics dt")].find(el => el.textContent === "Steps")?.nextElementSibling?.textContent === "7")
@ -35,8 +141,26 @@ test("service-wide usage dashboard declares scope, bounds requests, fences stale
} finally { await page.close(); await fixture.close() }
})
test("every settled native execution refreshes the dashboard", async () => {
const fixture = await startProductFixture("service-usage"), page = await fixture.browser.newPage({ viewport: { width: 760, height: 1000 }, locale: "en-US" })
test("a mounted but hidden Preferences document does not read Stats until activation", async () => {
const fixture = await startProductFixture("service-usage"), page = await statsPage(fixture)
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(`${fixture.url}?hidden`)
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.clock.install(); await page.clock.runFor(100)
assert.equal(await page.evaluate(() => (window as any).fixture.queries.length), 0)
await page.evaluate(() => { for (let i = 0; i < 30; i++) (window as any).fixture.refresh("session.execution.interrupted") })
await page.clock.runFor(6000)
assert.equal(await page.evaluate(() => (window as any).fixture.queries.length), 0)
await page.evaluate(() => (window as any).fixture.documentHidden(false))
await page.clock.runFor(100)
await page.locator(".usage-metrics").waitFor()
assert.equal(await page.evaluate(() => (window as any).fixture.queries.length), 1)
} finally { await page.close(); await fixture.close() }
})
test("hidden Stats never read; native hints coalesce and hidden in-flight work cannot launch trailing scans", async () => {
const fixture = await startProductFixture("service-usage"), page = await statsPage(fixture, { viewport: { width: 760, height: 1000 }, locale: "en-US" })
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
@ -44,20 +168,49 @@ test("every settled native execution refreshes the dashboard", async () => {
await page.locator(".usage-metrics").waitFor()
const queries = () => page.evaluate(() => (window as any).fixture.queries.length as number)
const initial = await queries()
// OpenCode V2 settles runs with session.execution.*; it never publishes session.idle.
for (const [index, type] of ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"].entries()) {
await page.evaluate(type => (window as any).fixture.refresh(type), type)
await page.waitForFunction(count => (window as any).fixture.queries.length === count, initial + index + 1)
}
await page.clock.install()
await page.evaluate(() => { for (let i = 0; i < 100; i++) (window as any).fixture.refresh() })
await page.clock.runFor(100)
assert.equal(await queries(), initial, "activity hints wait for the shared cooldown")
await page.evaluate(() => { (window as any).fixture.documentHidden(true); for (let i = 0; i < 100; i++) (window as any).fixture.refresh() })
await page.clock.runFor(6000)
assert.equal(await queries(), initial, "hidden cancels queued reads")
await page.evaluate(() => (window as any).fixture.documentHidden(false))
await page.clock.runFor(100)
assert.equal(await queries(), initial + 1, "activation revalidates exactly once")
await page.evaluate(() => { (window as any).fixture.defer(); (window as any).fixture.refresh("session.deleted") })
await page.clock.runFor(100)
assert.equal(await queries(), initial + 2)
await page.evaluate(() => { for (let i = 0; i < 100; i++) (window as any).fixture.refresh(); (window as any).fixture.documentHidden(true); (window as any).fixture.release() })
await page.clock.runFor(6000)
assert.equal(await queries(), initial + 2, "settlement while hidden does not reschedule")
await page.evaluate(() => (window as any).fixture.documentHidden(false))
await page.clock.runFor(100)
assert.equal(await queries(), initial + 3)
await page.evaluate(() => { for (let i = 0; i < 100; i++) (window as any).fixture.refresh() })
await page.clock.runFor(5100)
assert.equal(await queries(), initial + 4, "one read for a visible burst, not one per terminal event")
await page.evaluate(() => (window as any).fixture.refresh("session.text.delta"))
await page.waitForTimeout(100)
assert.equal(await queries(), initial + 3, "streaming deltas never refresh durable statistics")
await page.clock.runFor(6000)
assert.equal(await queries(), initial + 4, "streaming deltas never refresh durable statistics")
await page.evaluate(() => (window as any).fixture.connection("connected", 1))
await page.clock.runFor(100)
assert.equal(await queries(), initial + 5)
await page.evaluate(() => { for (let i = 0; i < 30; i++) (window as any).fixture.connection("connected", 1) })
await page.clock.runFor(6000)
assert.equal(await queries(), initial + 5, "duplicate connection snapshots are not reconnects")
await page.evaluate(() => { (window as any).fixture.documentHidden(true); (window as any).fixture.connection("connected", 2) })
await page.clock.runFor(6000)
assert.equal(await queries(), initial + 5, "hidden reconnect waits for visible demand")
await page.evaluate(() => (window as any).fixture.documentHidden(false))
await page.clock.runFor(100)
assert.equal(await queries(), initial + 6)
assert.deepEqual(errors, [])
} finally { await page.close(); await fixture.close() }
})
test("Stats is reachable in the real preferences screen", async () => {
const fixture = await startProductFixture("service-usage"), page = await fixture.browser.newPage({ viewport: { width: 1100, height: 900 }, locale: "en-US" })
const fixture = await startProductFixture("service-usage"), page = await statsPage(fixture, { viewport: { width: 1100, height: 900 }, locale: "en-US" })
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
@ -68,3 +221,184 @@ test("Stats is reachable in the real preferences screen", async () => {
assert.deepEqual(errors, [])
} finally { await page.close(); await fixture.close() }
})
test("P1 an unresolved optional cache cannot hold fresh Stats display", async () => {
const fixture = await startProductFixture("service-usage"), page = await statsPage(fixture, { locale: "en-US" })
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.clock.install()
await page.goto(`${fixture.url}?heldCache`)
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.clock.runFor(100)
assert.equal(await page.evaluate(() => (window as any).fixture.queries.length), 1, "fresh HTTP starts before cache release")
assert.equal(await page.locator(".usage-metrics").count(), 1)
await page.evaluate(() => (window as any).fixture.releaseCache())
await page.clock.runFor(100)
assert.equal(await page.locator(".usage-metrics").count(), 1, "late empty cache cannot erase fresh totals")
} finally { await page.close(); await fixture.close() }
})
test("P3 transient disconnect and failed reconnect keep explicitly stale totals and charts", async () => {
const fixture = await startProductFixture("service-usage"), page = await statsPage(fixture, { locale: "en-US" })
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
try {
await page.goto(fixture.url); await page.locator(".usage-metrics").waitFor()
await page.clock.install()
await page.evaluate(() => (window as any).fixture.connection("disconnected", 1))
await page.evaluate(() => (window as any).fixture.transport("disconnected"))
assert.equal(await page.locator(".usage-metrics").count(), 1, "disconnect keeps last known owner's totals")
assert.equal(await page.locator("meter").count(), 2)
await page.getByText("Previous saved statistics · awaiting refresh", { exact: true }).waitFor()
await page.evaluate(() => { (window as any).fixture.fail(); (window as any).fixture.connection("connected", 2) })
await page.clock.runFor(100)
await page.getByRole("alert").waitFor()
assert.equal(await page.locator(".usage-metrics").count(), 1)
// A different authenticated channel or native discovery root is NOT a
// transient disconnect. Fence an older result and revoke the old display.
for (const change of ["changeOwner", "changeDiscovery"]) {
await page.evaluate(() => { (window as any).fixture.defer(); (window as any).fixture.refresh("session.deleted") })
await page.clock.runFor(100)
await page.evaluate(change => { (window as any).fixture[change](); (window as any).fixture.fail(); (window as any).fixture.connection("connected", change === "changeOwner" ? 3 : 4) }, change)
await page.clock.runFor(100)
assert.equal(await page.locator(".usage-metrics").count(), 0, `${change} clears old service totals`)
await page.evaluate(() => (window as any).fixture.release())
await page.clock.runFor(100)
assert.equal(await page.locator(".usage-metrics").count(), 0, "superseded native results stay fenced")
await page.getByRole("button", { name: "Refresh", exact: true }).click()
await page.clock.runFor(100)
assert.equal(await page.locator(".usage-metrics").count(), 1)
}
} finally { await page.close(); await fixture.close() }
})
test("a new Preferences renderer/backend restores totals and charts before a two-second native read, retains errors and its period", async () => {
const parent = path.join(os.tmpdir(), "opencode"); await mkdir(parent, { recursive: true })
const root = await mkdtemp(path.join(parent, "stats-browser-state-"))
const fixture = await startProductFixture("service-usage")
const logger: any = { child() { return this }, warn() {}, info() {}, debug() {} }
let steps = 42, fail = false, calls = 0
const record = { path: "/fixture/project" }
const connection = { endpoint: { url: "http://127.0.0.1:1234", auth: { type: "basic", username: "fixture", password: "fixture-only" } }, assertCurrent() {}, client: {
config: { get: async () => [{ type: "directory", path: "/fixture/discovery" }] },
session: { stats: async (query: any) => {
calls++; const value = steps
await new Promise(resolve => setTimeout(resolve, 2000))
if (fail) { fail = false; throw new Error("fixture native failure") }
const tokens = { input: 12345, output: 987, reasoning: 100, cache: { read: 6789, write: 456 } }
return { range: { from: query.from, to: query.to }, sessions: 3, subagents: 2, prompts: 10, steps: value,
tokens, cost: 1.2345, tools: { mode: "none" }, activeDays: 2, streak: 2,
models: [{ model: { providerID: "fixture", id: "synthetic" }, tokens, steps: value, cost: 1.2345 }],
activity: [{ date: "2026-10-19", steps: value }] }
} },
} }
const manager: any = { get: () => record, getSharedServiceConnection: async () => connection,
getServicePathStyle: () => "posix", getServiceWslDistro: () => undefined }
const reopen = () => new ServiceUsage(manager, new SettingsService(resolveConfigLocation(path.join(root, "config.yaml")), undefined, logger))
let service = reopen()
const pageFor = async (instance: string) => {
const page = await statsPage(fixture, { locale: "en-US" })
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
await page.route("**/api/workspaces/*/service-usage**", async route => {
const url = new URL(route.request().url()), id = url.pathname.split("/")[3]
try {
const result = url.pathname.endsWith("/owner") ? await service.owner(id) : url.pathname.endsWith("/cache")
? await service.cached(id, url.searchParams.get("timezone")!, url.searchParams.has("days") ? Number(url.searchParams.get("days")) : undefined)
: await service.read(id, { from: Number(url.searchParams.get("from")), to: Number(url.searchParams.get("to")), timezone: url.searchParams.get("timezone")!, restorePeriod: url.searchParams.get("restorePeriod") === "true" })
await route.fulfill({ contentType: "application/json", body: JSON.stringify(result) })
} catch { await route.fulfill({ status: 503, contentType: "application/json", body: "{}" }) }
})
await page.goto(`${fixture.url}?backend&instance=${instance}`)
return page
}
try {
const cold = await pageFor("old-workspace-uuid")
await cold.locator(".usage-metrics").waitFor()
const coldMs = await cold.evaluate(() => (window as any).fixture.metricsAt - (window as any).fixture.mountedAt)
assert.ok(coldMs >= 2000)
await cold.getByLabel("Period", { exact: true }).selectOption("7")
await cold.getByRole("button", { name: "Refresh", exact: true }).waitFor({ state: "visible" })
await cold.waitForFunction(() => (document.querySelector(".usage-toolbar button") as HTMLButtonElement)?.disabled === false && (window as any).fixture.queries.length === 2)
await cold.close()
await service.close(); service = reopen(); steps = 99 // New backend/settings and JS window; no renderer Map survives.
const warm = await pageFor("new-workspace-uuid")
await warm.locator(".usage-metrics").waitFor()
const warmMs = await warm.evaluate(() => (window as any).fixture.metricsAt - (window as any).fixture.mountedAt)
assert.ok(warmMs < 1000, `restored display took ${warmMs}ms`)
assert.equal(await warm.getByLabel("Period", { exact: true }).inputValue(), "7")
const total = warm.locator(".usage-metrics > div").filter({ has: warm.getByText("Steps", { exact: true }) }).locator("dd")
assert.equal(await total.innerText(), "42")
assert.equal(await warm.locator("meter").count(), 1)
await warm.getByText("Previous saved statistics · awaiting refresh", { exact: true }).waitFor()
await warm.waitForFunction(() => [...document.querySelectorAll(".usage-metrics dt")].find(el => el.textContent === "Steps")?.nextElementSibling?.textContent === "99")
fail = true
await warm.getByRole("button", { name: "Refresh", exact: true }).click()
await warm.getByRole("alert").waitFor()
assert.equal(await total.innerText(), "99")
console.log(`Stats isolated delayed-read latency: cold=${Math.round(coldMs)}ms, restored=${Math.round(warmMs)}ms; native delay=2000ms; calls=${calls}`)
await warm.close()
} finally { await fixture.close(); await service.close(); await rm(root, { recursive: true, force: true }) }
})
test("P1/P2 held discovery cannot block real fresh display; abandoned periods cannot change restart restoration", async () => {
const parent = path.join(os.tmpdir(), "opencode"); await mkdir(parent, { recursive: true })
const root = await mkdtemp(path.join(parent, "stats-period-race-")), fixture = await startProductFixture("service-usage")
const logger: any = { child() { return this }, warn() {}, info() {}, debug() {} }
let release!: () => void, releaseDiscovery!: () => void, released!: () => void, oldReturned!: () => void, entered!: () => void
const held = new Promise<void>(resolve => { release = resolve }), done = new Promise<void>(resolve => { oldReturned = resolve })
const finished = new Promise<void>(resolve => { released = resolve })
const admitted = new Promise<void>(resolve => { entered = resolve })
const discovery = new Promise<void>(resolve => { releaseDiscovery = resolve })
const calls: number[] = [], record = { path: "/fixture/project" }
const connection = { endpoint: { url: "http://127.0.0.1:1234", auth: { type: "basic", username: "fixture", password: "fixture-only" } }, assertCurrent() {}, client: {
config: { get: async () => { await discovery; return [{ type: "directory", path: "/fixture/discovery" }] } },
session: { stats: async (query: any) => {
const days = (query.to - query.from) / 86400000; calls.push(days)
if (days === 30) { entered(); await held; released() }
const tokens = { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
return { range: { from: query.from, to: query.to }, sessions: 1, subagents: 0, prompts: 1, steps: days,
tokens, cost: 0, tools: { mode: "none" }, activeDays: 1, streak: 1, models: [], activity: [{ date: "2026-10-20", steps: days }] }
} },
} }
const manager: any = { get: () => record, getSharedServiceConnection: async () => connection,
getServicePathStyle: () => "posix", getServiceWslDistro: () => undefined }
const reopen = () => new ServiceUsage(manager, new SettingsService(resolveConfigLocation(path.join(root, "config.yaml")), undefined, logger))
let service = reopen()
const open = async (instance: string) => {
const page = await statsPage(fixture, { locale: "en-US", timezoneId: "UTC" })
await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" }))
await page.route("**/api/workspaces/*/service-usage**", async route => {
const url = new URL(route.request().url())
try {
const result = url.pathname.endsWith("/owner") ? await service.owner(instance)
: url.pathname.endsWith("/cache") ? await service.cached(instance, "UTC", url.searchParams.has("days") ? Number(url.searchParams.get("days")) : undefined)
: await service.read(instance, { from: Number(url.searchParams.get("from")), to: Number(url.searchParams.get("to")), timezone: "UTC", restorePeriod: url.searchParams.get("restorePeriod") === "true" })
await route.fulfill({ contentType: "application/json", body: JSON.stringify(result) })
} catch { /* Browser cancellation is deliberately NOT backend cancellation. */ }
finally { if (calls.includes(30) && url.pathname.endsWith("service-usage") && url.searchParams.get("from") === String(Number(url.searchParams.get("to")) - 30 * 86400000)) oldReturned() }
})
await page.goto(`${fixture.url}?backend&instance=${instance}`)
return page
}
try {
const page = await open("before-restart")
await page.waitForFunction(() => (window as any).fixture.queries.length === 1)
// The route has admitted the real initial native request before selection.
await admitted
assert.deepEqual(calls, [30])
await page.getByLabel("Period", { exact: true }).selectOption("7")
await page.waitForFunction(() => [...document.querySelectorAll(".usage-metrics dt")].find(el => el.textContent === "Steps")?.nextElementSibling?.textContent === "7")
// Both the UI cache request and the backend's optional identity work are
// still held. These fresh totals came through the real production service.
releaseDiscovery(); await service.cached("before-restart", "UTC", 7)
release(); await finished; await done
assert.equal(await page.getByLabel("Period", { exact: true }).inputValue(), "7")
assert.equal(await page.locator(".usage-metrics dd").nth(3).innerText(), "7")
await page.close(); await service.close(); service = reopen()
assert.equal((await service.cached("after-restart", "UTC")).days, 7)
const restored = await open("after-restart")
await restored.locator(".usage-metrics").waitFor()
assert.equal(await restored.getByLabel("Period", { exact: true }).inputValue(), "7")
assert.deepEqual(calls, [30, 7, 7], "restart chooses saved period without waiting for optional discovery")
await restored.close()
} finally { release(); releaseDiscovery(); await fixture.close(); await service.close(); await rm(root, { recursive: true, force: true }) }
})

View file

@ -41,5 +41,13 @@ await withProductRuntime(process.argv[2], async ({ root }) => {
assert.equal(result.stats.models.length, 1); assert.equal(result.stats.models[0].model.providerID, "fixture")
assert.deepEqual(result.stats.tools, { mode: "none" })
assert.equal(result.stats.activity.reduce((sum, row) => sum + row.steps, 0), 4)
const query = { from, to: Date.now() + 1000, timezone: "UTC" }, timings = []
for (let index = 0; index < 3; index++) {
const start = performance.now()
const unchanged = await usage.read("fixture", query)
timings.push(Math.round((performance.now() - start) * 100) / 100)
assert.equal(unchanged.stats.steps, 4)
}
console.log(`Native Stats repeated identical service reads (4 synthetic sessions, tools:none), ms: ${JSON.stringify(timings)}`)
console.log("PASS explicitly authorized service-wide usage including independent clone, durable tokens/models/activity and tools:none")
})