mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
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:
parent
b818a28c3d
commit
6f8881d5ae
25 changed files with 964 additions and 83 deletions
3
.github/workflows/pr-build.yml
vendored
3
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
71
packages/server/src/opencode/service-usage-cache.ts
Normal file
71
packages/server/src/opencode/service-usage-cache.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const settingsMessages = {
|
||||
"settings.usage.cached": "הנתונים הסטטיסטיים האחרונים שנשמרו · ממתין לרענון",
|
||||
"settings.native.openFailed": "לא ניתן היה לפתוח את ההעדפות בחלון שלהן. הפעילו מחדש או עדכנו את אפליקציית CodeNomad ונסו שוב.",
|
||||
"settings.accounts.current": "חשבון פעיל",
|
||||
"settings.usage.title": "סטטיסטיקות",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const settingsMessages = {
|
||||
"settings.usage.cached": "前回保存した統計 · 更新待ち",
|
||||
"settings.native.openFailed": "設定ウィンドウを開けませんでした。CodeNomad アプリを再起動または更新してから、もう一度お試しください。",
|
||||
"settings.accounts.current": "有効なアカウント",
|
||||
"settings.usage.title": "統計",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const settingsMessages = {
|
||||
"settings.usage.cached": "पछिल्लो सुरक्षित तथ्याङ्क · अद्यावधिकको प्रतीक्षामा",
|
||||
"settings.native.openFailed": "प्राथमिकताहरू तिनको विन्डोमा खोल्न सकिएन। CodeNomad एप पुनः सुरु गर्नुहोस् वा अद्यावधिक गर्नुहोस्, त्यसपछि फेरि प्रयास गर्नुहोस्।",
|
||||
"settings.accounts.current": "सक्रिय खाता",
|
||||
"settings.usage.title": "तथ्याङ्क",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const settingsMessages = {
|
||||
"settings.usage.cached": "Последняя сохранённая статистика · ожидание обновления",
|
||||
"settings.native.openFailed": "Не удалось открыть настройки в их окне. Перезапустите или обновите приложение CodeNomad и повторите попытку.",
|
||||
"settings.accounts.current": "Активный аккаунт",
|
||||
"settings.usage.title": "Статистика",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export const settingsMessages = {
|
||||
"settings.usage.cached": "上次保存的统计数据 · 等待刷新",
|
||||
"settings.native.openFailed": "无法在设置窗口中打开偏好设置。请重启或更新 CodeNomad 应用后重试。",
|
||||
"settings.accounts.current": "活动账户",
|
||||
"settings.usage.title": "统计",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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" } } }) }
|
||||
|
|
|
|||
|
|
@ -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 }) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue