From e415a1aff475905740656952e956adc149c102f3 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 20:36:53 +0800 Subject: [PATCH] fix(desktop): name the session in the usage activity task column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Usage Statistics activity log's 任务 (Task) column showed `未命名会话 · ` for every row. #3833 fixed this by carrying `SessionHeader.name` through the old usage-stats-store.ts pipeline, but that store — the new pipeline only carried `sessionId`, so `UsageRequestLog.sessionName` was declared but never populated and the UI always hit the untitled fallback. Resolve the title on the Host, where every usage-bearing session is reachable. HostUsagePricingCoordinator takes a session-title reader (sessionStore.readHeaderSnapshot) and, per logs page, resolves the title for each row's sessionId, emitting it as a new bounded `sessionTitle` field on the LLM/tool usage projections. Reading the durable header by id bypasses the catalog's role/preparing/ledger-v0 filters, so reserved-role, coordination, and legacy sessions are named too; an unreadable session is tolerated per-row and simply stays untitled. The desktop layer copies `sessionTitle` onto `sessionName`; the renderer (usageSessionDisplayLabel) already owns the untitled fallback. Bump the Host compatibility epoch (76 → 77): the new projection field is rejected by older Clients, so the same protocol version no longer guarantees safe interoperability. Closes #4218 Generated-by: Claude Code (claude-opus-4-8) --- .../runtime-host-usage-ipc-main.test.ts | 93 +++++++++++++++ .../src/main/runtime-host-usage-ipc-main.ts | 6 + .../__tests__/usage-pricing-protocol.test.ts | 31 +++++ .../usage-pricing-two-client-uds.test.ts | 107 ++++++++++++++++++ packages/runtime-host/src/protocol/index.ts | 5 +- .../src/protocol/usage-pricing.ts | 8 ++ .../src/server/execution-composition.ts | 4 + .../src/server/usage-pricing-coordinator.ts | 58 +++++++++- 8 files changed, 308 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts index 6199baa55b..3b7b3df61f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -439,6 +439,99 @@ test("settings usage stats truncate the activity log at the cap instead of error assert.equal(stats.logs.filter((row) => row.kind === "model").length, 50_000); }); +test("settings usage stats name each row from the Host-resolved session title", async () => { + const handlers = new Map(); + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 2, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + // The Host carries `sessionTitle` on the projection (or omits it for + // untitled/unreadable sessions). The desktop layer just surfaces it. + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [ + { + ...llmRow(0), + sessionId: "session-named", + sessionTitle: "重构使用统计页请求日志的任务列", + }, + { ...llmRow(1), sessionId: "session-untitled" }, + ], + offset: 0, + total: 2, + nextOffset: null, + provenance: provenance(), + } satisfies UsageQueryResult) + : ({ + kind: "logs", + source: "tool", + rows: [ + { + ...toolRow(0), + sessionId: "session-named", + sessionTitle: "重构使用统计页请求日志的任务列", + }, + ], + offset: 0, + total: 1, + nextOffset: null, + } satisfies UsageQueryResult); + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 0, + entries: [], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + const stats = await handler({} as never, "all") as UsageStats; + // A model row and a tool row carrying the title both surface it as sessionName. + assert.equal( + stats.logs.find((row) => row.id === "llm-0")?.sessionName, + "重构使用统计页请求日志的任务列", + ); + assert.equal( + stats.logs.find((row) => row.id === "tool-0")?.sessionName, + "重构使用统计页请求日志的任务列", + ); + // A row the Host left untitled stays nameless so the UI falls back. + assert.equal(stats.logs.find((row) => row.id === "llm-1")?.sessionName, undefined); +}); + function llmRow(index: number) { return { source: "llm" as const, diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index 91a162a0ad..d3b18b6cf3 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -275,12 +275,17 @@ async function loadAllLogs( } } +// The Task column names the session each usage row belongs to. The Host resolves +// the human-readable title (from the durable session header) and carries it on +// the projection as `sessionTitle`; untitled/unreadable sessions omit it, and the +// renderer falls back to the untitled label. function projectLlmLog(row: LlmUsageLogProjection): UsageStats["logs"][number] { return { id: row.id, ts: row.ts, kind: "model", ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.sessionTitle === undefined ? {} : { sessionName: row.sessionTitle }), ...(row.turnId === undefined ? {} : { turnId: row.turnId }), provider: row.providerId, model: row.modelId, @@ -302,6 +307,7 @@ function projectToolLog(row: ToolUsageLogProjection): UsageStats["logs"][number] ts: row.ts, kind: "tool", ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.sessionTitle === undefined ? {} : { sessionName: row.sessionTitle }), ...(row.turnId === undefined ? {} : { turnId: row.turnId }), provider: row.providerId ?? "", model: row.modelId ?? "", diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index 9a5ed0dba2..bed834df9a 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -208,6 +208,28 @@ describe('Usage/Pricing protocol', () => { nextOffset: null, }), ); + // The Host-resolved session title rides on both log kinds as bounded text. + assert.doesNotThrow(() => + usageResponse({ + kind: 'logs', + source: 'llm', + rows: [{ ...validLog(), sessionId: 'session-1', sessionTitle: '重构任务列' }], + offset: 0, + total: 1, + nextOffset: null, + provenance: validProvenance(), + }), + ); + assert.doesNotThrow(() => + usageResponse({ + kind: 'logs', + source: 'tool', + rows: [{ ...validToolLog(), sessionId: 'session-1', sessionTitle: '重构任务列' }], + offset: 0, + total: 1, + nextOffset: null, + }), + ); const tooMany = Array.from({ length: USAGE_PAGE_MAX_ITEMS + 1 }, () => validBucket()); const byteHeavy = Array.from({ length: 50 }, (_, index) => ({ @@ -244,6 +266,15 @@ describe('Usage/Pricing protocol', () => { nextOffset: null, provenance: validProvenance(), }, + { + kind: 'logs', + source: 'llm', + rows: [{ ...validLog(), sessionTitle: 'x'.repeat(USAGE_PROJECTION_TEXT_MAX_BYTES + 1) }], + offset: 0, + total: 1, + nextOffset: null, + provenance: validProvenance(), + }, { kind: 'logs', source: 'llm', diff --git a/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts index ede75459dd..217659fbf2 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts @@ -30,6 +30,7 @@ import { import type { PricingConfig } from '@maka/core/usage-stats/types'; import { BUILTIN_PRICING } from '@maka/runtime/telemetry'; import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; +import { SessionNotFoundError } from '@maka/storage/session-store'; import { resolveRootControlNamespace, resolveStorageRoot, @@ -295,6 +296,112 @@ test('pricing root identity failure requests drain while expected failures do no }); }); +test('usage logs carry the Host-resolved session title and tolerate unreadable sessions', async () => { + await withUsageAuthority('session-title', async ({ stores }) => { + await Promise.all([ + stores.telemetry.recordLlmCall({ + ...usageRecord('llm-named', 10, 'openai', 'gpt-a'), + sessionId: 'session-named', + turnId: 'turn-1', + }), + stores.telemetry.recordLlmCall({ + ...usageRecord('llm-blank', 11, 'openai', 'gpt-a'), + sessionId: 'session-blank', + turnId: 'turn-2', + }), + stores.telemetry.recordLlmCall({ + ...usageRecord('llm-missing', 12, 'openai', 'gpt-a'), + sessionId: 'session-missing', + turnId: 'turn-3', + }), + stores.telemetry.recordToolInvocation({ + ...toolRecord('tool-a', 13), + sessionId: 'session-named', + }), + ]); + const titles = new Map([ + // Leading/trailing whitespace must be trimmed; a blank title is not a title. + ['session-named', ' 重构使用统计页请求日志的任务列 '], + ['session-blank', ' '], + ]); + const coordinator = new HostUsagePricingCoordinator( + stores, + () => {}, + new RuntimePolicyActivationGate(), + () => {}, + async (sessionId) => { + // A genuinely missing session is tolerated: its row stays untitled. + if (sessionId === 'session-missing') throw new SessionNotFoundError('session-missing'); + return titles.get(sessionId); + }, + ); + + const llm = await coordinator.handlers['usage.query']( + { kind: 'logs', source: 'llm', query: { range: 'all' }, offset: 0, limit: 100 }, + CONNECTION_CONTEXT, + ); + assert.ok(llm.ok); + assert.equal(llm.result.kind, 'logs'); + if (llm.result.kind !== 'logs' || llm.result.source !== 'llm') + throw new Error('expected llm logs'); + const llmById = new Map(llm.result.rows.map((row) => [row.id, row])); + assert.equal(llmById.get('llm-named')?.sessionTitle, '重构使用统计页请求日志的任务列'); + // Whitespace-only title is dropped; the row stays untitled and the UI falls back. + assert.equal(llmById.get('llm-blank')?.sessionTitle, undefined); + // A genuinely missing session is tolerated — one missing session never blanks the rest. + assert.equal(llmById.get('llm-missing')?.sessionTitle, undefined); + assert.equal(llmById.get('llm-named')?.sessionId, 'session-named'); + + const tool = await coordinator.handlers['usage.query']( + { kind: 'logs', source: 'tool', query: { range: 'all' }, offset: 0, limit: 100 }, + CONNECTION_CONTEXT, + ); + assert.ok(tool.ok); + if (tool.result.kind !== 'logs' || tool.result.source !== 'tool') + throw new Error('expected tool logs'); + assert.equal( + tool.result.rows.find((row) => row.id === 'tool-a')?.sessionTitle, + '重构使用统计页请求日志的任务列', + ); + }); +}); + +test('a non–not-found title read failure propagates out of usage.query instead of blanking the row', async () => { + await withUsageAuthority('session-title-failure', async ({ stores }) => { + await stores.telemetry.recordLlmCall({ + ...usageRecord('llm-live', 20, 'openai', 'gpt-a'), + sessionId: 'session-live', + turnId: 'turn-1', + }); + const coordinator = new HostUsagePricingCoordinator( + stores, + () => {}, + new RuntimePolicyActivationGate(), + () => {}, + // The production reader is SessionStore.readHeaderSnapshot. A closed + // metadata store rejects with exactly this generic error — not a + // usage-store lifecycle type — so it classifies as `unknown` and must + // reach #queryUsage's failure mapping rather than be swallowed into an + // untitled row. + async () => { + throw new Error('SQLite session metadata store is closed'); + }, + ); + + // Not swallowed: the read failure propagates rather than yielding a + // false-success page. (A genuinely draining host is caught earlier by the + // primary usage read; this narrow case is a session store that fails on its + // own, which surfaces instead of masking the problem.) + await assert.rejects( + coordinator.handlers['usage.query']( + { kind: 'logs', source: 'llm', query: { range: 'all' }, offset: 0, limit: 100 }, + CONNECTION_CONTEXT, + ), + /SQLite session metadata store is closed/, + ); + }); +}); + test('pricing query rejects continue offsets at and past the effective catalog end', async () => { await withUsageAuthority('pricing-offset', async ({ stores }) => { const coordinator = new HostUsagePricingCoordinator( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 0092796aee..bfaa5ee28f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 76 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 77 as const; +// 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the +// Host-resolved session name for the usage Task column). Older Clients reject +// the unknown field, so a newer Host's usage logs are unreadable to them. // 76: Peer Mesh endpoint and Mesh display names are signed, persisted facts // managed through Host operations rather than local-only Client labels. // 75: Peer Mesh routes identify whether a peer is a Client or Runtime Host so diff --git a/packages/runtime-host/src/protocol/usage-pricing.ts b/packages/runtime-host/src/protocol/usage-pricing.ts index 266fa11bab..007bcda93d 100644 --- a/packages/runtime-host/src/protocol/usage-pricing.ts +++ b/packages/runtime-host/src/protocol/usage-pricing.ts @@ -102,6 +102,7 @@ const LLM_USAGE_LOG_FIELDS = new Set([ 'status', 'errorClass', 'sessionId', + 'sessionTitle', 'turnId', ]); const TOOL_USAGE_LOG_FIELDS = new Set([ @@ -121,6 +122,7 @@ const TOOL_USAGE_LOG_FIELDS = new Set([ 'bytesOut', 'startedAt', 'sessionId', + 'sessionTitle', 'turnId', ]); const TOOL_RESULT_SUMMARY_FIELDS = new Set([ @@ -167,6 +169,8 @@ export interface LlmUsageLogProjection { readonly status: 'success' | 'error' | 'aborted'; readonly errorClass?: string; readonly sessionId?: string; + /** Human-readable session title, resolved on the Host; absent for untitled sessions. */ + readonly sessionTitle?: string; readonly turnId?: string; } @@ -187,6 +191,8 @@ export interface ToolUsageLogProjection { readonly bytesOut: number; readonly startedAt: number; readonly sessionId?: string; + /** Human-readable session title, resolved on the Host; absent for untitled sessions. */ + readonly sessionTitle?: string; readonly turnId?: string; } @@ -992,6 +998,7 @@ function decodeLlmUsageLog(value: unknown): LlmUsageLogProjection { status: decodeUsageLogStatus(row.status), ...optionalProjectionText(row, 'errorClass'), ...optionalProjectionText(row, 'sessionId'), + ...optionalProjectionText(row, 'sessionTitle'), ...optionalProjectionText(row, 'turnId'), }; } @@ -1030,6 +1037,7 @@ function decodeToolUsageLog(value: unknown): ToolUsageLogProjection { bytesOut: requireCount(row.bytesOut, 'tool usage log bytes out'), startedAt: nonnegativeFinite(row.startedAt, 'tool usage log start time'), ...optionalProjectionText(row, 'sessionId'), + ...optionalProjectionText(row, 'sessionTitle'), ...optionalProjectionText(row, 'turnId'), }; } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index a1294093dc..d98a8de3be 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1098,6 +1098,10 @@ export async function createExecutionRuntimeHostComposition( context.requestDrain, runtimePolicyActivation, registerBackendInvalidation, + // Name the Task column from the durable session header. Reads by id + // straight from the session store, so it also names reserved-role, + // coordination, and legacy sessions the filtered catalog omits. + async (sessionId) => (await stores.sessionStore.readHeaderSnapshot(sessionId)).name, ); const webSearch = new HostWebSearchCoordinator(webSearchService); const networkProxy = new HostNetworkProxyCoordinator(runtimePolicyStores.operations); diff --git a/packages/runtime-host/src/server/usage-pricing-coordinator.ts b/packages/runtime-host/src/server/usage-pricing-coordinator.ts index 01557bd642..1404664657 100644 --- a/packages/runtime-host/src/server/usage-pricing-coordinator.ts +++ b/packages/runtime-host/src/server/usage-pricing-coordinator.ts @@ -40,6 +40,7 @@ import { type InteractiveUsageStoresFailureClassification, type InteractiveUsageStoresWriter, } from '@maka/storage/usage-stores'; +import { isSessionNotFoundError } from '@maka/storage/execution-stores'; import { encodePricingQueryResult, encodeUsageQueryResult, @@ -75,6 +76,10 @@ export class HostUsagePricingCoordinator { readonly #requestDrain: () => void; readonly #activation: RuntimePolicyActivationGate; readonly #onCommittedPricingMutation: () => void; + // Resolves a session's human-readable title for the Task column. Reads the + // durable session header directly (unfiltered, in-process), so it covers + // reserved-role, coordination, and legacy sessions the catalog omits. + readonly #readSessionTitle?: (sessionId: string) => Promise; #poisonDrainRequested = false; constructor( @@ -82,11 +87,42 @@ export class HostUsagePricingCoordinator { requestDrain: () => void, activation: RuntimePolicyActivationGate, onCommittedPricingMutation: () => void = () => {}, + readSessionTitle?: (sessionId: string) => Promise, ) { this.#stores = authenticateInteractiveUsageStoresWriter(stores); this.#requestDrain = requestDrain; this.#activation = activation; this.#onCommittedPricingMutation = onCommittedPricingMutation; + this.#readSessionTitle = readSessionTitle; + } + + // Resolve titles for exactly the sessions on this page. A session that no + // longer exists is simply left untitled — one deleted session never blanks + // the rest. Store lifecycle, persistence, and malformed-header failures are + // *not* swallowed: they propagate so #queryUsage maps them to host_draining/ + // persistence_failed and the Desktop keeps its normal reconnect path. + async #resolveSessionTitles( + rows: ReadonlyArray<{ readonly sessionId?: string }>, + ): Promise> { + const titles = new Map(); + const read = this.#readSessionTitle; + if (!read) return titles; + const ids = [ + ...new Set(rows.map((row) => row.sessionId).filter((id): id is string => id !== undefined)), + ]; + await Promise.all( + ids.map(async (id) => { + try { + const title = (await read(id))?.trim(); + if (title) titles.set(id, title); + } catch (error) { + // A genuinely missing session is left untitled so the UI falls back; + // any other failure is a store problem and must reach #queryUsage. + if (!isSessionNotFoundError(error)) throw error; + } + }), + ); + return titles; } /** @@ -154,10 +190,17 @@ export class HostUsagePricingCoordinator { if (input.source === 'tool') { const page = await this.#stores.telemetry.toolLogs(input.query, offset, limit); if (offset > page.total) return invalidUsageOffset(); + const titles = await this.#resolveSessionTitles(page.rows); return { ok: true, result: encodeUsageQueryResult( - usageLogPage('tool', page.rows.map(projectToolUsageLog), page.total, offset, limit), + usageLogPage( + 'tool', + page.rows.map((row) => projectToolUsageLog(row, titles)), + page.total, + offset, + limit, + ), ), }; } @@ -174,12 +217,13 @@ export class HostUsagePricingCoordinator { limit, ); if (offset > merged.total) return invalidUsageOffset(); + const titles = await this.#resolveSessionTitles(merged.rows); return { ok: true, result: encodeUsageQueryResult( usageLogPage( 'llm', - merged.rows.map(projectUsageLog), + merged.rows.map((row) => projectUsageLog(row, titles)), merged.total, offset, limit, @@ -547,9 +591,13 @@ function projectUsageBucket(bucket: UsageBucket): UsageBucket { }; } -function projectUsageLog(row: UsageLogRow): LlmUsageLogProjection { +function projectUsageLog( + row: UsageLogRow, + titles: ReadonlyMap, +): LlmUsageLogProjection { const cacheMissInputSource = (row as UsageLogRow & { readonly cacheMissInputSource?: unknown }) .cacheMissInputSource; + const title = row.sessionId === undefined ? undefined : titles.get(row.sessionId); return { source: 'llm', id: projectIdentity(row.id), @@ -577,6 +625,7 @@ function projectUsageLog(row: UsageLogRow): LlmUsageLogProjection { status: row.status, ...(row.errorClass === undefined ? {} : { errorClass: projectText(row.errorClass) }), ...(row.sessionId === undefined ? {} : { sessionId: projectIdentity(row.sessionId) }), + ...(title === undefined ? {} : { sessionTitle: projectText(title) }), ...(row.turnId === undefined ? {} : { turnId: projectIdentity(row.turnId) }), }; } @@ -588,7 +637,9 @@ function projectToolUsageLog( readonly bytesOut: number; readonly ts: number; }, + titles: ReadonlyMap, ): ToolUsageLogProjection { + const title = row.sessionId === undefined ? undefined : titles.get(row.sessionId); return { source: 'tool', id: projectIdentity(row.id), @@ -616,6 +667,7 @@ function projectToolUsageLog( bytesOut: row.bytesOut, startedAt: row.startedAt, ...(row.sessionId === undefined ? {} : { sessionId: projectIdentity(row.sessionId) }), + ...(title === undefined ? {} : { sessionTitle: projectText(title) }), ...(row.turnId === undefined ? {} : { turnId: projectIdentity(row.turnId) }), }; }