From 2390987e1287357194f92bb8f2f696b213b174e6 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:31:34 +0100 Subject: [PATCH 1/6] Account Anthropic prompt-cache usage instead of dropping it The Anthropic client logged cache_creation_input_tokens and cache_read_input_tokens and then returned only input_tokens and output_tokens, so every cached Patrol run reached the usage ledger, the cost preview, the budget check and the weekly digest with its cache buckets missing. Pulse #2350 reproduced the loss on both the Chat and ChatStream paths, and #2118's reporter was told to trust the provider's billing view over Pulse's totals until this was fixed. Both response paths now carry the two buckets as fields disjoint from InputTokens (streaming assigns them from message_start and the final cumulative message_delta rather than summing). The agentic loops, Patrol responses and run records sum them beside the ordinary totals, the usage ledger and its persisted copies keep them, and cost.EstimateUsageUSD prices each bucket at Anthropic's published cache rates (5-minute write 1.25x, read 0.1x of the input rate), falling back to the input rate for providers without a known cache price. Spend summaries, the digest spend, the cost projection medians and the usage export expose the buckets and price them the same way. InputTokens keeps its meaning everywhere, so the telemetry token buckets are unchanged. --- .../v6/internal/subsystems/agent-lifecycle.md | 7 + .../v6/internal/subsystems/ai-runtime.md | 24 +++ .../v6/internal/subsystems/api-contracts.md | 13 ++ .../internal/subsystems/storage-recovery.md | 9 + internal/ai/chat/agentic.go | 14 +- internal/ai/chat/agentic_control.go | 14 ++ internal/ai/chat/agentic_final.go | 4 + internal/ai/chat/service.go | 42 +++-- internal/ai/chat/session_title.go | 22 ++- internal/ai/cost/pricing.go | 68 ++++++- internal/ai/cost/pricing_anthropic_test.go | 48 ++++- internal/ai/cost/store.go | 176 +++++++++++------- internal/ai/cost/store_test.go | 52 ++++++ internal/ai/cost_persistence.go | 52 +++--- internal/ai/cost_persistence_test.go | 24 +++ internal/ai/patrol.go | 14 +- internal/ai/patrol_ai.go | 95 +++++----- internal/ai/patrol_cost_projection.go | 51 +++-- internal/ai/patrol_cost_projection_test.go | 30 +++ internal/ai/patrol_digest.go | 10 +- internal/ai/patrol_digest_test.go | 23 +++ internal/ai/patrol_history_persistence.go | 4 + internal/ai/patrol_model_readiness.go | 56 +++--- internal/ai/patrol_run.go | 4 + internal/ai/providers/anthropic.go | 44 ++++- internal/ai/providers/anthropic_test.go | 84 +++++++++ internal/ai/providers/provider.go | 9 + internal/ai/report_fleet_narrator.go | 20 +- internal/ai/report_narrator.go | 20 +- internal/ai/service.go | 144 ++++++++------ internal/api/ai_handlers.go | 8 +- internal/api/ai_handlers_more_test.go | 72 +++++++ internal/api/chat_service_adapter.go | 16 +- internal/config/persistence.go | 40 ++-- 34 files changed, 1002 insertions(+), 311 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4c18ec8692..7336ee263b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8433,3 +8433,10 @@ update commands and credential authority unchanged. `internal/api/onboarding_handlers.go` changed only the human-readable pairing readiness messages so they name Settings > Pulse Mobile. No agent registration, lifecycle or install path changed. + +### AI usage export columns only + +`internal/api/ai_handlers.go` and `internal/api/chat_service_adapter.go` +changed only to carry prompt-cache token buckets through the AI usage export +and the Patrol stream response. No agent registration, enrolment, install, +update or removal path changed. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 70d798f6ac..cb391dace3 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -8494,3 +8494,27 @@ turning AI back on needs no new path. This is presentation gating only: Patrol runtime, findings, schedules and the capability's server derivation are unchanged. `AppLayout.test.tsx`, `CommandPaletteModal.test.tsx`, `KeyboardShortcutsModal.test.tsx` and `App.architecture.test.ts` pin it. + +### Prompt-cache usage is accounted, not just logged + +Anthropic reports `cache_creation_input_tokens` and `cache_read_input_tokens` +as buckets disjoint from `input_tokens`. The provider client now carries both +buckets on `providers.ChatResponse` and the streaming `DoneEvent` (assigned +from `message_start` and the final cumulative `message_delta`, never summed +across events), the agentic loops and Patrol responses sum them beside the +ordinary totals, and the usage ledger (`cost.UsageEvent`), Patrol run records +and their persisted copies keep them. `cost.EstimateUsageUSD` prices each +bucket at the provider's cache rate when the table knows it (Anthropic: +5-minute write 1.25x and read 0.1x of the input rate) and at the ordinary +input rate otherwise; `cost.EstimateUSD` remains the no-cache wrapper, so +callers without cache data are unchanged. Spend summaries, the Patrol digest +spend, the cost projection (per-run medians now carry cache buckets) and the +usage export expose the buckets and price them the same way. `InputTokens` +keeps its meaning everywhere, so the telemetry token buckets and the +`patrol_input_tokens` semantics are unchanged; cache tokens are reported but +not folded into those counters. Proofs: `anthropic_test.go` (both response +paths keep the buckets, the final delta does not double them), +`pricing_anthropic_test.go`, `store_test.go`, `patrol_digest_test.go`, +`patrol_cost_projection_test.go` and `cost_persistence_test.go`. This repairs +the lost accounting data from Pulse #2350; it is not a measured provider bill +or a claim of savings across six-hourly runs. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 80c6ecef07..e8109e21cf 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -11084,3 +11084,16 @@ the renamed Settings > Pulse Mobile section instead of Remote Access. Codes, severities and the response shape are unchanged. The API reference heading for the relay protocol records that Pulse Mobile retires on 31 March 2027, and the published copy stays identical to `docs/API.md`. + +### AI usage export carries prompt-cache buckets + +`GET /api/ai/cost/export` now includes `cache_creation_input_tokens` and +`cache_read_input_tokens` beside the existing input and output counts, in +the JSON events (as optional fields of the usage event) and as two CSV +columns after `output_tokens`, and prices each event with the cache-aware +estimator so `estimated_usd` matches the summary surfaces. Existing columns, +their order, the query parameters and the authentication boundary are +unchanged; the chat service adapter passes the same buckets through the +Patrol stream response without changing its request shape. +`ai_handlers_more_test.go` covers the export in both formats with a cached +Anthropic event. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 97ffabafe7..533a534204 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6334,3 +6334,12 @@ metrics target, health threshold, persistence or recovery authority changes. cover repeated paths/groups, direct children, scoped legacy snapshots, missing ownership and live target replacement. Synthetic presentation proof does not establish native appliance, installation or recovery acceptance. + +### Prompt-cache usage buckets open no storage or recovery path + +The AI usage ledger and Patrol run records gain two optional integer fields +(`cache_creation_input_tokens`, `cache_read_input_tokens`) in the files +Pulse already writes, with `omitempty` encoding so existing records load +unchanged and older builds ignore the fields. `internal/api/ai_handlers.go` +reads them for the usage export only. No new file, directory, retention +window, backup, migration or recovery authority is introduced. diff --git a/internal/ai/chat/agentic.go b/internal/ai/chat/agentic.go index 79921ad7f9..bdfa617b14 100644 --- a/internal/ai/chat/agentic.go +++ b/internal/ai/chat/agentic.go @@ -667,11 +667,13 @@ type AgenticLoop struct { modelName string // Token accumulation across all turns - totalInputTokens int - totalOutputTokens int - totalToolCalls int - totalModelTurns int - totalEvidenceCalls int + totalInputTokens int + totalOutputTokens int + totalCacheCreationTokens int + totalCacheReadTokens int + totalToolCalls int + totalModelTurns int + totalEvidenceCalls int // State for ongoing executions mu sync.Mutex @@ -1222,6 +1224,8 @@ func (a *AgenticLoop) executeWithTools(ctx context.Context, sessionID string, me stopReason = data.StopReason toolCalls = agentcapabilities.NormalizeProviderToolCallsForExecution(data.ToolCalls) a.totalInputTokens += data.InputTokens + a.totalCacheCreationTokens += data.CacheCreationInputTokens + a.totalCacheReadTokens += data.CacheReadInputTokens a.totalOutputTokens += data.OutputTokens log.Info(). Int("turn", turn). diff --git a/internal/ai/chat/agentic_control.go b/internal/ai/chat/agentic_control.go index 12545d18c8..830c7c347c 100644 --- a/internal/ai/chat/agentic_control.go +++ b/internal/ai/chat/agentic_control.go @@ -165,6 +165,18 @@ func (a *AgenticLoop) GetTotalOutputTokens() int { return a.totalOutputTokens } +// GetTotalCacheCreationTokens returns the accumulated prompt-cache writes +// across all turns; disjoint from GetTotalInputTokens. +func (a *AgenticLoop) GetTotalCacheCreationTokens() int { + return a.totalCacheCreationTokens +} + +// GetTotalCacheReadTokens returns the accumulated prompt-cache reads across +// all turns; disjoint from GetTotalInputTokens. +func (a *AgenticLoop) GetTotalCacheReadTokens() int { + return a.totalCacheReadTokens +} + // GetTotalToolCalls returns the accepted model-selected tool call count across all turns. func (a *AgenticLoop) GetTotalToolCalls() int { return a.totalToolCalls @@ -186,5 +198,7 @@ func (a *AgenticLoop) GetTotalEvidenceCalls() int { func (a *AgenticLoop) ResetTokenCounts() { a.totalInputTokens = 0 a.totalOutputTokens = 0 + a.totalCacheCreationTokens = 0 + a.totalCacheReadTokens = 0 a.totalToolCalls = 0 } diff --git a/internal/ai/chat/agentic_final.go b/internal/ai/chat/agentic_final.go index a04f5a3411..8e1119b753 100644 --- a/internal/ai/chat/agentic_final.go +++ b/internal/ai/chat/agentic_final.go @@ -117,6 +117,10 @@ func (a *AgenticLoop) ensureFinalTextResponseWithSystemPrompt( case "done": if data, ok := event.Data.(providers.DoneEvent); ok { a.totalInputTokens += data.InputTokens + + a.totalCacheCreationTokens += data.CacheCreationInputTokens + + a.totalCacheReadTokens += data.CacheReadInputTokens a.totalOutputTokens += data.OutputTokens } } diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 63f90189f1..b7a4edc7f1 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -371,7 +371,9 @@ func (s *Service) recordChatTurnCost(loop *AgenticLoop, requestModel, contextSco } inputTokens := loop.GetTotalInputTokens() outputTokens := loop.GetTotalOutputTokens() - if inputTokens == 0 && outputTokens == 0 { + cacheCreationTokens := loop.GetTotalCacheCreationTokens() + cacheReadTokens := loop.GetTotalCacheReadTokens() + if inputTokens == 0 && outputTokens == 0 && cacheCreationTokens == 0 && cacheReadTokens == 0 { return } providerName := "" @@ -381,15 +383,17 @@ func (s *Service) recordChatTurnCost(loop *AgenticLoop, requestModel, contextSco } } store.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: requestModel, - UseCase: "chat", - ContextScope: strings.TrimSpace(contextScope), - ToolCallCount: loop.GetTotalToolCalls(), - InputTokens: inputTokens, - OutputTokens: outputTokens, - SessionID: strings.TrimSpace(sessionID), + Timestamp: time.Now(), + Provider: providerName, + RequestModel: requestModel, + UseCase: "chat", + ContextScope: strings.TrimSpace(contextScope), + ToolCallCount: loop.GetTotalToolCalls(), + InputTokens: inputTokens, + OutputTokens: outputTokens, + CacheCreationInputTokens: cacheCreationTokens, + CacheReadInputTokens: cacheReadTokens, + SessionID: strings.TrimSpace(sessionID), }) } @@ -2779,6 +2783,10 @@ type PatrolResponse struct { Content string `json:"content"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` + // Prompt-cache buckets summed across the loop's turns; disjoint from + // InputTokens, see providers.ChatResponse. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` } // ExecutePatrolStream creates a temporary agentic loop for patrol execution. @@ -2924,8 +2932,10 @@ func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, ca } } return &PatrolResponse{ - InputTokens: tempLoop.GetTotalInputTokens(), - OutputTokens: tempLoop.GetTotalOutputTokens(), + InputTokens: tempLoop.GetTotalInputTokens(), + OutputTokens: tempLoop.GetTotalOutputTokens(), + CacheCreationInputTokens: tempLoop.GetTotalCacheCreationTokens(), + CacheReadInputTokens: tempLoop.GetTotalCacheReadTokens(), }, err } @@ -2971,9 +2981,11 @@ func (s *Service) ExecutePatrolStream(ctx context.Context, req PatrolRequest, ca callback(StreamEvent{Type: "done", Data: doneData}) return &PatrolResponse{ - Content: contentBuilder.String(), - InputTokens: tempLoop.GetTotalInputTokens(), - OutputTokens: tempLoop.GetTotalOutputTokens(), + Content: contentBuilder.String(), + InputTokens: tempLoop.GetTotalInputTokens(), + OutputTokens: tempLoop.GetTotalOutputTokens(), + CacheCreationInputTokens: tempLoop.GetTotalCacheCreationTokens(), + CacheReadInputTokens: tempLoop.GetTotalCacheReadTokens(), }, nil } diff --git a/internal/ai/chat/session_title.go b/internal/ai/chat/session_title.go index 7c04bcfe13..4f88ba5763 100644 --- a/internal/ai/chat/session_title.go +++ b/internal/ai/chat/session_title.go @@ -129,16 +129,18 @@ func (s *Service) generateSessionTitle(ctx context.Context, sessionID string) er } if costStore != nil && response != nil && (response.InputTokens != 0 || response.OutputTokens != 0) { costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: provider.Name(), - RequestModel: requestModel, - ResponseModel: strings.TrimSpace(response.Model), - UseCase: "chat", - InputTokens: response.InputTokens, - OutputTokens: response.OutputTokens, - TargetType: "assistant_session_title", - TargetID: normalizedSessionID, - SessionID: normalizedSessionID, + Timestamp: time.Now(), + Provider: provider.Name(), + RequestModel: requestModel, + ResponseModel: strings.TrimSpace(response.Model), + UseCase: "chat", + InputTokens: response.InputTokens, + OutputTokens: response.OutputTokens, + CacheCreationInputTokens: response.CacheCreationInputTokens, + CacheReadInputTokens: response.CacheReadInputTokens, + TargetType: "assistant_session_title", + TargetID: normalizedSessionID, + SessionID: normalizedSessionID, }) } diff --git a/internal/ai/cost/pricing.go b/internal/ai/cost/pricing.go index c2717f18ca..488fe26e9d 100644 --- a/internal/ai/cost/pricing.go +++ b/internal/ai/cost/pricing.go @@ -7,19 +7,68 @@ import "strings" type TokenPrice struct { InputUSDPerMTok float64 OutputUSDPerMTok float64 - AsOf string + // CacheWriteUSDPerMTok and CacheReadUSDPerMTok price the prompt-cache + // buckets a provider reports beside ordinary input tokens. Zero means this + // table has no separate cache price for the provider, and the bucket is + // charged at the ordinary input rate, the conservative choice. + CacheWriteUSDPerMTok float64 + CacheReadUSDPerMTok float64 + AsOf string } +// TokenUsage is one call's token counts by billing bucket. InputTokens is the +// provider's ordinary, uncached input count; the cache buckets are disjoint +// from it, so PromptTokens is their sum. +type TokenUsage struct { + InputTokens int64 + OutputTokens int64 + CacheCreationInputTokens int64 + CacheReadInputTokens int64 +} + +// PromptTokens is every token sent to the model, cached or not. +func (u TokenUsage) PromptTokens() int64 { + return u.InputTokens + u.CacheCreationInputTokens + u.CacheReadInputTokens +} + +// Anthropic prices prompt-cache writes and reads as multiples of the model's +// input rate: a 5-minute cache write costs 1.25x and a cache read 0.1x +// (https://platform.claude.com/docs/en/about-claude/pricing, read 2026-10-03). +// Pulse only requests the 5-minute cache, so the 1-hour write rate is never +// charged. +const ( + anthropicCacheWriteMultiplier = 1.25 + anthropicCacheReadMultiplier = 0.10 +) + // EstimateUSD returns an estimated USD cost for the given provider/model and token counts. // If the model pricing is unknown, ok is false and usd is 0. func EstimateUSD(provider, model string, inputTokens, outputTokens int64) (usd float64, ok bool, price TokenPrice) { - price, ok = lookupPrice(provider, model, inputTokens) + return EstimateUsageUSD(provider, model, TokenUsage{InputTokens: inputTokens, OutputTokens: outputTokens}) +} + +// EstimateUsageUSD prices every bucket of a call's usage: ordinary input and +// output at the model's rates, and prompt-cache writes and reads at the +// provider's cache rates when the table knows them, otherwise at the input +// rate. Tier selection uses the whole prompt, cached or not, because that is +// the context size providers tier on. +func EstimateUsageUSD(provider, model string, usage TokenUsage) (usd float64, ok bool, price TokenPrice) { + price, ok = lookupPrice(provider, model, usage.PromptTokens()) if !ok { return 0, false, TokenPrice{} } - - usd = (float64(inputTokens)/1_000_000.0)*price.InputUSDPerMTok + - (float64(outputTokens)/1_000_000.0)*price.OutputUSDPerMTok + cacheWrite := price.CacheWriteUSDPerMTok + if cacheWrite <= 0 { + cacheWrite = price.InputUSDPerMTok + } + cacheRead := price.CacheReadUSDPerMTok + if cacheRead <= 0 { + cacheRead = price.InputUSDPerMTok + } + usd = (float64(usage.InputTokens)/1_000_000.0)*price.InputUSDPerMTok + + (float64(usage.OutputTokens)/1_000_000.0)*price.OutputUSDPerMTok + + (float64(usage.CacheCreationInputTokens)/1_000_000.0)*cacheWrite + + (float64(usage.CacheReadInputTokens)/1_000_000.0)*cacheRead return usd, true, price } @@ -173,11 +222,16 @@ func lookupPrice(provider, model string, inputTokens int64) (TokenPrice, bool) { if asOf == "" { asOf = pricingAsOf } - return TokenPrice{ + price := TokenPrice{ InputUSDPerMTok: tier.InputUSDPerMTok, OutputUSDPerMTok: tier.OutputUSDPerMTok, AsOf: asOf, - }, true + } + if provider == "anthropic" { + price.CacheWriteUSDPerMTok = tier.InputUSDPerMTok * anthropicCacheWriteMultiplier + price.CacheReadUSDPerMTok = tier.InputUSDPerMTok * anthropicCacheReadMultiplier + } + return price, true } } return TokenPrice{}, false diff --git a/internal/ai/cost/pricing_anthropic_test.go b/internal/ai/cost/pricing_anthropic_test.go index 89bd9a48be..0dd95ef6b0 100644 --- a/internal/ai/cost/pricing_anthropic_test.go +++ b/internal/ai/cost/pricing_anthropic_test.go @@ -1,6 +1,9 @@ package cost -import "testing" +import ( + "math" + "testing" +) func TestLookupPriceUsesCurrentAnthropicOpusPricing(t *testing.T) { tests := []struct { @@ -78,3 +81,46 @@ func TestLookupPriceUsesCurrentAnthropicGeneration5Pricing(t *testing.T) { }) } } + +func TestEstimateUsageUSDPricesAnthropicCacheBucketsAtCacheRates(t *testing.T) { + usage := TokenUsage{InputTokens: 100_000, OutputTokens: 10_000, CacheCreationInputTokens: 200_000, CacheReadInputTokens: 1_000_000} + usd, ok, price := EstimateUsageUSD("anthropic", "claude-sonnet-5", usage) + if !ok { + t.Fatal("expected known pricing for claude-sonnet-5") + } + // Sonnet 5 input is 2.00/M, so a 5-minute cache write is 2.50/M and a + // cache read 0.20/M. + if price.CacheWriteUSDPerMTok != 2.5 || price.CacheReadUSDPerMTok != 0.2 { + t.Fatalf("unexpected cache rates: %+v", price) + } + want := 0.1*2.00 + 0.01*10.00 + 0.2*2.50 + 1.0*0.20 + if math.Abs(usd-want) > 1e-9 { + t.Fatalf("usd = %f, want %f", usd, want) + } + // Charging the whole prompt at the input rate overstates a cached run. + flat, _, _ := EstimateUSD("anthropic", "claude-sonnet-5", usage.PromptTokens(), usage.OutputTokens) + if flat <= usd { + t.Fatalf("flat input-rate estimate %f should exceed cache-aware %f", flat, usd) + } +} + +func TestEstimateUsageUSDFallsBackToInputRateWithoutCachePrices(t *testing.T) { + usage := TokenUsage{InputTokens: 100_000, CacheReadInputTokens: 100_000} + usd, ok, price := EstimateUsageUSD("openai", "gpt-4o-mini", usage) + if !ok { + t.Fatal("expected known pricing for gpt-4o-mini") + } + if price.CacheWriteUSDPerMTok != 0 || price.CacheReadUSDPerMTok != 0 { + t.Fatalf("openai rows carry no cache prices: %+v", price) + } + // Both buckets at 0.15/M: conservative, never below the provider's bill. + if want := 0.2 * 0.15; math.Abs(usd-want) > 1e-9 { + t.Fatalf("usd = %f, want %f", usd, want) + } + // Without cache buckets the two estimators agree exactly. + a, _, _ := EstimateUSD("openai", "gpt-4o-mini", 100_000, 2_000) + b, _, _ := EstimateUsageUSD("openai", "gpt-4o-mini", TokenUsage{InputTokens: 100_000, OutputTokens: 2_000}) + if a != b { + t.Fatalf("EstimateUSD %f != EstimateUsageUSD %f", a, b) + } +} diff --git a/internal/ai/cost/store.go b/internal/ai/cost/store.go index b529c4bb94..4a50bf97a5 100644 --- a/internal/ai/cost/store.go +++ b/internal/ai/cost/store.go @@ -21,14 +21,28 @@ type UsageEvent struct { ToolCallCount int `json:"tool_call_count,omitempty"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` - TargetType string `json:"target_type,omitempty"` - TargetID string `json:"target_id,omitempty"` - FindingID string `json:"finding_id,omitempty"` + // CacheCreationInputTokens and CacheReadInputTokens are prompt-cache + // buckets reported beside InputTokens (Anthropic); they never overlap it. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` + TargetType string `json:"target_type,omitempty"` + TargetID string `json:"target_id,omitempty"` + FindingID string `json:"finding_id,omitempty"` // SessionID ties chat-session spend (turns, compaction, title calls) // together so per-session cost can be summed for the drawer footer. SessionID string `json:"session_id,omitempty"` } +// TokenUsage returns the event's counts by billing bucket. +func (e UsageEvent) TokenUsage() TokenUsage { + return TokenUsage{ + InputTokens: int64(e.InputTokens), + OutputTokens: int64(e.OutputTokens), + CacheCreationInputTokens: int64(e.CacheCreationInputTokens), + CacheReadInputTokens: int64(e.CacheReadInputTokens), + } +} + // Persistence defines the storage contract for usage history. type Persistence interface { SaveUsageHistory(events []UsageEvent) error @@ -151,7 +165,7 @@ func (s *Store) SessionCostUSD(sessionID string) (usd float64, known bool) { } matched = true provider, model := ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) - eventUSD, eventKnown, _ := EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) + eventUSD, eventKnown, _ := EstimateUsageUSD(provider, model, e.TokenUsage()) if !eventKnown { return 0, false } @@ -231,7 +245,7 @@ func (s *Store) GetSummary(days int) Summary { pmTotals := make(map[pmKey]*ProviderModelSummary) dailyTotals := make(map[string]*DailySummary) - var totalInput, totalOutput int64 + var totalInput, totalOutput, totalCacheCreation, totalCacheRead int64 for _, e := range events { provider, model := ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) @@ -244,11 +258,15 @@ func (s *Store) GetSummary(days int) Summary { } pm.InputTokens += int64(e.InputTokens) pm.OutputTokens += int64(e.OutputTokens) + pm.CacheCreationInputTokens += int64(e.CacheCreationInputTokens) + pm.CacheReadInputTokens += int64(e.CacheReadInputTokens) totalInput += int64(e.InputTokens) totalOutput += int64(e.OutputTokens) + totalCacheCreation += int64(e.CacheCreationInputTokens) + totalCacheRead += int64(e.CacheReadInputTokens) - usd, known, _ := EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) + usd, known, _ := EstimateUsageUSD(provider, model, e.TokenUsage()) if known { pm.EstimatedUSD += usd pm.PricingKnown = true @@ -262,6 +280,8 @@ func (s *Store) GetSummary(days int) Summary { } ds.InputTokens += int64(e.InputTokens) ds.OutputTokens += int64(e.OutputTokens) + ds.CacheCreationInputTokens += int64(e.CacheCreationInputTokens) + ds.CacheReadInputTokens += int64(e.CacheReadInputTokens) if known { ds.EstimatedUSD += usd } @@ -269,7 +289,7 @@ func (s *Store) GetSummary(days int) Summary { providerModels := make([]ProviderModelSummary, 0, len(pmTotals)) for _, pm := range pmTotals { - pm.TotalTokens = pm.InputTokens + pm.OutputTokens + pm.TotalTokens = pm.InputTokens + pm.CacheCreationInputTokens + pm.CacheReadInputTokens + pm.OutputTokens providerModels = append(providerModels, *pm) } sort.Slice(providerModels, func(i, j int) bool { @@ -281,7 +301,7 @@ func (s *Store) GetSummary(days int) Summary { daily := make([]DailySummary, 0, len(dailyTotals)) for _, ds := range dailyTotals { - ds.TotalTokens = ds.InputTokens + ds.OutputTokens + ds.TotalTokens = ds.InputTokens + ds.CacheCreationInputTokens + ds.CacheReadInputTokens + ds.OutputTokens daily = append(daily, *ds) } sort.Slice(daily, func(i, j int) bool { @@ -289,10 +309,12 @@ func (s *Store) GetSummary(days int) Summary { }) totals := ProviderModelSummary{ - Provider: "all", - InputTokens: totalInput, - OutputTokens: totalOutput, - TotalTokens: totalInput + totalOutput, + Provider: "all", + InputTokens: totalInput, + OutputTokens: totalOutput, + CacheCreationInputTokens: totalCacheCreation, + CacheReadInputTokens: totalCacheRead, + TotalTokens: totalInput + totalCacheCreation + totalCacheRead + totalOutput, } for _, pm := range providerModels { @@ -415,44 +437,60 @@ func (s *Store) GetPersistenceStatus() (lastError error, lastSaveTime time.Time, // ProviderModelSummary is a rollup for a provider/model pair. type ProviderModelSummary struct { - Provider string `json:"provider"` - Model string `json:"model"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - EstimatedUSD float64 `json:"estimated_usd,omitempty"` - PricingKnown bool `json:"pricing_known"` + Provider string `json:"provider"` + Model string `json:"model"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + // Prompt-cache buckets, disjoint from InputTokens; TotalTokens counts + // every token in and out, cached or not. + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` + TotalTokens int64 `json:"total_tokens"` + EstimatedUSD float64 `json:"estimated_usd,omitempty"` + PricingKnown bool `json:"pricing_known"` } // DailySummary is a rollup for a single day across all providers. type DailySummary struct { - Date string `json:"date"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - EstimatedUSD float64 `json:"estimated_usd,omitempty"` + Date string `json:"date"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + // Prompt-cache buckets, disjoint from InputTokens; TotalTokens counts + // every token in and out, cached or not. + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` + TotalTokens int64 `json:"total_tokens"` + EstimatedUSD float64 `json:"estimated_usd,omitempty"` } // UseCaseSummary is a rollup for a use-case (e.g. "chat", "patrol"). type UseCaseSummary struct { - UseCase string `json:"use_case"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - EstimatedUSD float64 `json:"estimated_usd,omitempty"` - PricingKnown bool `json:"pricing_known"` + UseCase string `json:"use_case"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + // Prompt-cache buckets, disjoint from InputTokens; TotalTokens counts + // every token in and out, cached or not. + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` + TotalTokens int64 `json:"total_tokens"` + EstimatedUSD float64 `json:"estimated_usd,omitempty"` + PricingKnown bool `json:"pricing_known"` } // TargetSummary is a rollup for a Pulse target (e.g. vm/container/node). type TargetSummary struct { - TargetType string `json:"target_type"` - TargetID string `json:"target_id"` - Calls int64 `json:"calls"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - EstimatedUSD float64 `json:"estimated_usd,omitempty"` - PricingKnown bool `json:"pricing_known"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id"` + Calls int64 `json:"calls"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + // Prompt-cache buckets, disjoint from InputTokens; TotalTokens counts + // every token in and out, cached or not. + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` + TotalTokens int64 `json:"total_tokens"` + EstimatedUSD float64 `json:"estimated_usd,omitempty"` + PricingKnown bool `json:"pricing_known"` } // Summary is returned by the cost summary API. @@ -473,10 +511,12 @@ type Summary struct { func summarizeUseCases(events []UsageEvent) []UseCaseSummary { type totals struct { - input int64 - output int64 - usd float64 - known bool + input int64 + output int64 + cacheCreation int64 + cacheRead int64 + usd float64 + known bool } perUseCase := make(map[string]*totals) @@ -493,10 +533,12 @@ func summarizeUseCases(events []UsageEvent) []UseCaseSummary { t.input += int64(e.InputTokens) t.output += int64(e.OutputTokens) + t.cacheCreation += int64(e.CacheCreationInputTokens) + t.cacheRead += int64(e.CacheReadInputTokens) provider, model := ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) - usd, known, _ := EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) + usd, known, _ := EstimateUsageUSD(provider, model, e.TokenUsage()) if known { t.usd += usd t.known = true @@ -506,12 +548,14 @@ func summarizeUseCases(events []UsageEvent) []UseCaseSummary { out := make([]UseCaseSummary, 0, len(perUseCase)) for useCase, t := range perUseCase { out = append(out, UseCaseSummary{ - UseCase: useCase, - InputTokens: t.input, - OutputTokens: t.output, - TotalTokens: t.input + t.output, - EstimatedUSD: t.usd, - PricingKnown: t.known, + UseCase: useCase, + InputTokens: t.input, + OutputTokens: t.output, + CacheCreationInputTokens: t.cacheCreation, + CacheReadInputTokens: t.cacheRead, + TotalTokens: t.input + t.cacheCreation + t.cacheRead + t.output, + EstimatedUSD: t.usd, + PricingKnown: t.known, }) } @@ -540,11 +584,13 @@ func summarizeTargets(events []UsageEvent) []TargetSummary { targetID string } type totals struct { - calls int64 - input int64 - output int64 - usd float64 - known bool + calls int64 + input int64 + output int64 + cacheCreation int64 + cacheRead int64 + usd float64 + known bool } perTarget := make(map[key]*totals) @@ -563,9 +609,11 @@ func summarizeTargets(events []UsageEvent) []TargetSummary { t.calls++ t.input += int64(e.InputTokens) t.output += int64(e.OutputTokens) + t.cacheCreation += int64(e.CacheCreationInputTokens) + t.cacheRead += int64(e.CacheReadInputTokens) provider, model := ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) - usd, known, _ := EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) + usd, known, _ := EstimateUsageUSD(provider, model, e.TokenUsage()) if known { t.usd += usd t.known = true @@ -575,14 +623,16 @@ func summarizeTargets(events []UsageEvent) []TargetSummary { out := make([]TargetSummary, 0, len(perTarget)) for k, t := range perTarget { out = append(out, TargetSummary{ - TargetType: k.targetType, - TargetID: k.targetID, - Calls: t.calls, - InputTokens: t.input, - OutputTokens: t.output, - TotalTokens: t.input + t.output, - EstimatedUSD: t.usd, - PricingKnown: t.known, + TargetType: k.targetType, + TargetID: k.targetID, + Calls: t.calls, + InputTokens: t.input, + OutputTokens: t.output, + CacheCreationInputTokens: t.cacheCreation, + CacheReadInputTokens: t.cacheRead, + TotalTokens: t.input + t.cacheCreation + t.cacheRead + t.output, + EstimatedUSD: t.usd, + PricingKnown: t.known, }) } diff --git a/internal/ai/cost/store_test.go b/internal/ai/cost/store_test.go index ca2cb0934f..a16bc4d57b 100644 --- a/internal/ai/cost/store_test.go +++ b/internal/ai/cost/store_test.go @@ -540,3 +540,55 @@ func TestSessionCostUSD(t *testing.T) { t.Errorf("expected free session to be known at 0, got usd=%v known=%v", usd, known) } } + +func TestSummaryCountsAndPricesPromptCacheBuckets(t *testing.T) { + store := NewStore(90) + now := time.Now() + store.Record(UsageEvent{ + Timestamp: now, + Provider: "anthropic", + RequestModel: "anthropic:claude-sonnet-5", + UseCase: "patrol", + TargetType: "node", + TargetID: "pve1", + InputTokens: 100_000, + OutputTokens: 10_000, + CacheCreationInputTokens: 200_000, + CacheReadInputTokens: 1_000_000, + }) + + // 0.1M*2.00 + 0.01M*10.00 + 0.2M*2.50 + 1.0M*0.20 + wantUSD := 0.2 + 0.1 + 0.5 + 0.2 + check := func(name string, in, out, creation, read, total int64, usd float64, known bool) { + t.Helper() + if in != 100_000 || out != 10_000 || creation != 200_000 || read != 1_000_000 { + t.Fatalf("%s buckets = in %d out %d creation %d read %d", name, in, out, creation, read) + } + if total != 1_310_000 { + t.Fatalf("%s total tokens = %d, want every bucket counted", name, total) + } + if !known || math.Abs(usd-wantUSD) > 1e-9 { + t.Fatalf("%s estimated usd = %f (known=%v), want %f", name, usd, known, wantUSD) + } + } + + summary := store.GetSummary(7) + tot := summary.Totals + check("totals", tot.InputTokens, tot.OutputTokens, tot.CacheCreationInputTokens, tot.CacheReadInputTokens, tot.TotalTokens, tot.EstimatedUSD, tot.PricingKnown) + if len(summary.ProviderModels) != 1 || len(summary.UseCases) != 1 || len(summary.Targets) != 1 || len(summary.DailyTotals) != 1 { + t.Fatalf("unexpected summary shape: %+v", summary) + } + pm := summary.ProviderModels[0] + check("provider model", pm.InputTokens, pm.OutputTokens, pm.CacheCreationInputTokens, pm.CacheReadInputTokens, pm.TotalTokens, pm.EstimatedUSD, pm.PricingKnown) + uc := summary.UseCases[0] + check("use case", uc.InputTokens, uc.OutputTokens, uc.CacheCreationInputTokens, uc.CacheReadInputTokens, uc.TotalTokens, uc.EstimatedUSD, uc.PricingKnown) + tg := summary.Targets[0] + check("target", tg.InputTokens, tg.OutputTokens, tg.CacheCreationInputTokens, tg.CacheReadInputTokens, tg.TotalTokens, tg.EstimatedUSD, tg.PricingKnown) + ds := summary.DailyTotals[0] + check("daily", ds.InputTokens, ds.OutputTokens, ds.CacheCreationInputTokens, ds.CacheReadInputTokens, ds.TotalTokens, ds.EstimatedUSD, true) + + events := store.ListEvents(7) + if len(events) != 1 || events[0].CacheCreationInputTokens != 200_000 || events[0].CacheReadInputTokens != 1_000_000 { + t.Fatalf("listed event lost cache buckets: %+v", events) + } +} diff --git a/internal/ai/cost_persistence.go b/internal/ai/cost_persistence.go index d57edcce48..74b506a306 100644 --- a/internal/ai/cost_persistence.go +++ b/internal/ai/cost_persistence.go @@ -25,18 +25,20 @@ func (a *CostPersistenceAdapter) SaveUsageHistory(events []cost.UsageEvent) erro records := make([]config.AIUsageEventRecord, len(events)) for i, e := range events { records[i] = config.AIUsageEventRecord{ - Timestamp: e.Timestamp, - Provider: e.Provider, - RequestModel: e.RequestModel, - ResponseModel: e.ResponseModel, - UseCase: e.UseCase, - ContextScope: e.ContextScope, - ToolCallCount: e.ToolCallCount, - InputTokens: e.InputTokens, - OutputTokens: e.OutputTokens, - TargetType: e.TargetType, - TargetID: e.TargetID, - FindingID: e.FindingID, + Timestamp: e.Timestamp, + Provider: e.Provider, + RequestModel: e.RequestModel, + ResponseModel: e.ResponseModel, + UseCase: e.UseCase, + ContextScope: e.ContextScope, + ToolCallCount: e.ToolCallCount, + InputTokens: e.InputTokens, + OutputTokens: e.OutputTokens, + CacheCreationInputTokens: e.CacheCreationInputTokens, + CacheReadInputTokens: e.CacheReadInputTokens, + TargetType: e.TargetType, + TargetID: e.TargetID, + FindingID: e.FindingID, } } return a.config.SaveAIUsageHistory(records) @@ -55,18 +57,20 @@ func (a *CostPersistenceAdapter) LoadUsageHistory() ([]cost.UsageEvent, error) { events := make([]cost.UsageEvent, len(data.Events)) for i, r := range data.Events { events[i] = cost.UsageEvent{ - Timestamp: r.Timestamp, - Provider: r.Provider, - RequestModel: r.RequestModel, - ResponseModel: r.ResponseModel, - UseCase: r.UseCase, - ContextScope: r.ContextScope, - ToolCallCount: r.ToolCallCount, - InputTokens: r.InputTokens, - OutputTokens: r.OutputTokens, - TargetType: r.TargetType, - TargetID: r.TargetID, - FindingID: r.FindingID, + Timestamp: r.Timestamp, + Provider: r.Provider, + RequestModel: r.RequestModel, + ResponseModel: r.ResponseModel, + UseCase: r.UseCase, + ContextScope: r.ContextScope, + ToolCallCount: r.ToolCallCount, + InputTokens: r.InputTokens, + OutputTokens: r.OutputTokens, + CacheCreationInputTokens: r.CacheCreationInputTokens, + CacheReadInputTokens: r.CacheReadInputTokens, + TargetType: r.TargetType, + TargetID: r.TargetID, + FindingID: r.FindingID, } } return events, nil diff --git a/internal/ai/cost_persistence_test.go b/internal/ai/cost_persistence_test.go index 3065dcd9af..607c0102f3 100644 --- a/internal/ai/cost_persistence_test.go +++ b/internal/ai/cost_persistence_test.go @@ -149,3 +149,27 @@ func TestCostPersistenceAdapter_NilConfigFailsClosed(t *testing.T) { t.Fatal("expected load to return normalized empty slice on failure") } } + +func TestCostPersistenceAdapterRoundTripsPromptCacheBuckets(t *testing.T) { + adapter := NewCostPersistenceAdapter(config.NewConfigPersistence(t.TempDir())) + saved := []cost.UsageEvent{{ + Timestamp: time.Now(), + Provider: "anthropic", + RequestModel: "anthropic:claude-sonnet-5", + UseCase: "patrol", + InputTokens: 100, + OutputTokens: 20, + CacheCreationInputTokens: 1000, + CacheReadInputTokens: 5000, + }} + if err := adapter.SaveUsageHistory(saved); err != nil { + t.Fatalf("SaveUsageHistory: %v", err) + } + loaded, err := adapter.LoadUsageHistory() + if err != nil { + t.Fatalf("LoadUsageHistory: %v", err) + } + if len(loaded) != 1 || loaded[0].CacheCreationInputTokens != 1000 || loaded[0].CacheReadInputTokens != 5000 || loaded[0].InputTokens != 100 { + t.Fatalf("round trip lost cache buckets: %+v", loaded) + } +} diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 068d9441b0..eb93e20b73 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -162,9 +162,11 @@ type PatrolRunRecord struct { TriageFlags int `json:"triage_flags"` // Number of deterministic flags found TriageSkippedLLM bool `json:"triage_skipped_llm,omitempty"` // Legacy: true for older quiet-triage runs // AI Analysis details - AIAnalysis string `json:"ai_analysis,omitempty"` // Display-safe AI run analysis summary - InputTokens int `json:"input_tokens,omitempty"` // Tokens sent to AI - OutputTokens int `json:"output_tokens,omitempty"` // Tokens received from AI + AIAnalysis string `json:"ai_analysis,omitempty"` // Display-safe AI run analysis summary + InputTokens int `json:"input_tokens,omitempty"` // Tokens sent to AI + OutputTokens int `json:"output_tokens,omitempty"` // Tokens received from AI + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // Prompt-cache writes, disjoint from InputTokens + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // Prompt-cache reads, disjoint from InputTokens // Tool call traces ToolCalls []ToolCallRecord `json:"tool_calls,omitempty"` ToolCallCount int `json:"tool_call_count"` @@ -211,6 +213,8 @@ type patrolRunRecordJSON struct { AIAnalysis string `json:"ai_analysis,omitempty"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // Prompt-cache writes, disjoint from InputTokens + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // Prompt-cache reads, disjoint from InputTokens ToolCalls []ToolCallRecord `json:"tool_calls,omitempty"` ToolCallCount int `json:"tool_call_count"` } @@ -343,6 +347,8 @@ func (r PatrolRunRecord) MarshalJSON() ([]byte, error) { AIAnalysis: normalized.AIAnalysis, InputTokens: normalized.InputTokens, OutputTokens: normalized.OutputTokens, + CacheCreationInputTokens: normalized.CacheCreationInputTokens, + CacheReadInputTokens: normalized.CacheReadInputTokens, ToolCalls: normalized.ToolCalls, ToolCallCount: normalized.ToolCallCount, }) @@ -396,6 +402,8 @@ func (r *PatrolRunRecord) UnmarshalJSON(data []byte) error { AIAnalysis: payload.AIAnalysis, InputTokens: payload.InputTokens, OutputTokens: payload.OutputTokens, + CacheCreationInputTokens: payload.CacheCreationInputTokens, + CacheReadInputTokens: payload.CacheReadInputTokens, ToolCalls: payload.ToolCalls, ToolCallCount: payload.ToolCallCount, }) diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index 02e1bf151d..d770cc8641 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -34,20 +34,23 @@ import ( // AIAnalysisResult contains the results of an AI analysis type AIAnalysisResult struct { - Response string // The AI's raw response text - Findings []*Finding // Parsed findings from the response - RejectedFindings int // Findings rejected by threshold validation - TriageFlags int // Number of deterministic triage flags - TriageSkippedLLM bool // Legacy: true for older records where quiet triage skipped LLM - InputTokens int - OutputTokens int - ToolCalls []ToolCallRecord // Tool invocations during this analysis - ReportedIDs []string // Finding IDs reported (created/re-reported) this run - NewFindingIDs []string // Finding IDs first created during this run - ResolvedIDs []string // Finding IDs explicitly resolved by LLM this run - Assessments []PatrolFindingAssessment // Explicit verdicts for existing findings this run - SeededFindingIDs []string // Finding IDs that were presented in seed context - QueriedFindingIDs []string // Finding IDs returned by patrol_get_findings this run + Response string // The AI's raw response text + Findings []*Finding // Parsed findings from the response + RejectedFindings int // Findings rejected by threshold validation + TriageFlags int // Number of deterministic triage flags + TriageSkippedLLM bool // Legacy: true for older records where quiet triage skipped LLM + InputTokens int + OutputTokens int + // Prompt-cache buckets, disjoint from InputTokens (providers.ChatResponse). + CacheCreationInputTokens int + CacheReadInputTokens int + ToolCalls []ToolCallRecord // Tool invocations during this analysis + ReportedIDs []string // Finding IDs reported (created/re-reported) this run + NewFindingIDs []string // Finding IDs first created during this run + ResolvedIDs []string // Finding IDs explicitly resolved by LLM this run + Assessments []PatrolFindingAssessment // Explicit verdicts for existing findings this run + SeededFindingIDs []string // Finding IDs that were presented in seed context + QueriedFindingIDs []string // Finding IDs returned by patrol_get_findings this run // Forecasts are the deterministic capacity forecasts computed this run, // stamped onto matching findings so the surface shows a first-class // urgency signal instead of relying on model prose. Carried as the @@ -802,26 +805,28 @@ func (p *PatrolService) runAIAnalysisState(ctx context.Context, snap patrolRunti } return attempt, chatErr } - buildAnalysisResult := func(content string, toolCalls []ToolCallRecord, inputTokens, outputTokens int) *AIAnalysisResult { + buildAnalysisResult := func(content string, toolCalls []ToolCallRecord, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int) *AIAnalysisResult { adapter.findingsMu.Lock() rejectedCount := adapter.rejectedCount adapter.findingsMu.Unlock() return &AIAnalysisResult{ - Response: CleanThinkingTokens(content), - Findings: adapter.getCollectedFindings(), - RejectedFindings: rejectedCount, - TriageFlags: len(triageResult.Flags), - TriageSkippedLLM: false, - InputTokens: inputTokens, - OutputTokens: outputTokens, - ToolCalls: append([]ToolCallRecord(nil), toolCalls...), - ReportedIDs: adapter.getReportedFindingIDs(), - NewFindingIDs: adapter.getNewFindingIDs(), - ResolvedIDs: adapter.getResolvedIDs(), - Assessments: adapter.getAssessments(), - SeededFindingIDs: seededFindingIDs, - QueriedFindingIDs: adapter.getQueriedFindingIDs(), - Forecasts: triageResult.Intel.forecasts, + Response: CleanThinkingTokens(content), + Findings: adapter.getCollectedFindings(), + RejectedFindings: rejectedCount, + TriageFlags: len(triageResult.Flags), + TriageSkippedLLM: false, + InputTokens: inputTokens, + OutputTokens: outputTokens, + CacheCreationInputTokens: cacheCreationTokens, + CacheReadInputTokens: cacheReadTokens, + ToolCalls: append([]ToolCallRecord(nil), toolCalls...), + ReportedIDs: adapter.getReportedFindingIDs(), + NewFindingIDs: adapter.getNewFindingIDs(), + ResolvedIDs: adapter.getResolvedIDs(), + Assessments: adapter.getAssessments(), + SeededFindingIDs: seededFindingIDs, + QueriedFindingIDs: adapter.getQueriedFindingIDs(), + Forecasts: triageResult.Intel.forecasts, } } @@ -859,15 +864,17 @@ func (p *PatrolService) runAIAnalysisState(ctx context.Context, snap patrolRunti if chatErr != nil { var partialResult *AIAnalysisResult if attempt != nil && attempt.response != nil { - p.recordPatrolUsage(attempt.response.InputTokens, attempt.response.OutputTokens) + p.recordPatrolUsage(attempt.response.InputTokens, attempt.response.OutputTokens, attempt.response.CacheCreationInputTokens, attempt.response.CacheReadInputTokens) } if attempt != nil { - inputTokens, outputTokens := 0, 0 + inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens := 0, 0, 0, 0 if attempt.response != nil { inputTokens = attempt.response.InputTokens outputTokens = attempt.response.OutputTokens + cacheCreationTokens = attempt.response.CacheCreationInputTokens + cacheReadTokens = attempt.response.CacheReadInputTokens } - candidate := buildAnalysisResult(attempt.finalContent, attempt.toolCalls, inputTokens, outputTokens) + candidate := buildAnalysisResult(attempt.finalContent, attempt.toolCalls, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens) if candidate.Response != "" || inputTokens > 0 || outputTokens > 0 || len(candidate.ToolCalls) > 0 || len(candidate.Findings) > 0 || len(candidate.ResolvedIDs) > 0 || len(candidate.Assessments) > 0 || len(candidate.QueriedFindingIDs) > 0 { partialResult = candidate @@ -883,7 +890,9 @@ func (p *PatrolService) runAIAnalysisState(ctx context.Context, snap patrolRunti finalContent := attempt.finalContent inputTokens = attempt.response.InputTokens outputTokens = attempt.response.OutputTokens - p.recordPatrolUsage(attempt.response.InputTokens, attempt.response.OutputTokens) + cacheCreationTokens := attempt.response.CacheCreationInputTokens + cacheReadTokens := attempt.response.CacheReadInputTokens + p.recordPatrolUsage(attempt.response.InputTokens, attempt.response.OutputTokens, attempt.response.CacheCreationInputTokens, attempt.response.CacheReadInputTokens) log.Debug(). Int("input_tokens", inputTokens). @@ -900,7 +909,7 @@ func (p *PatrolService) runAIAnalysisState(ctx context.Context, snap patrolRunti // The original conversation owns the diagnosis and finding decisions. // Omitted assessments remain explicit in the result. A separate model // session must not infer them from old finding excerpts or signal counts. - return buildAnalysisResult(finalContent, attempt.toolCalls, inputTokens, outputTokens), nil + return buildAnalysisResult(finalContent, attempt.toolCalls, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens), nil } // These are execution limits, independent of heuristic flags or inventory size. @@ -912,7 +921,7 @@ func patrolDetectionMaxTurns(scope *PatrolScope) int { return 40 } -func (p *PatrolService) recordPatrolUsage(inputTokens, outputTokens int) { +func (p *PatrolService) recordPatrolUsage(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int) { if p == nil || p.aiService == nil || (inputTokens <= 0 && outputTokens <= 0) { return } @@ -947,12 +956,14 @@ func (p *PatrolService) recordPatrolUsage(inputTokens, outputTokens int) { } store.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: model, - UseCase: "patrol", - InputTokens: inputTokens, - OutputTokens: outputTokens, + Timestamp: time.Now(), + Provider: providerName, + RequestModel: model, + UseCase: "patrol", + InputTokens: inputTokens, + OutputTokens: outputTokens, + CacheCreationInputTokens: cacheCreationTokens, + CacheReadInputTokens: cacheReadTokens, }) } diff --git a/internal/ai/patrol_cost_projection.go b/internal/ai/patrol_cost_projection.go index 1154456bab..93c4ad00a3 100644 --- a/internal/ai/patrol_cost_projection.go +++ b/internal/ai/patrol_cost_projection.go @@ -120,12 +120,21 @@ type PatrolCostProjection struct { PricingAsOf string `json:"pricing_as_of,omitempty"` InputUSDPerMTok float64 `json:"input_usd_per_mtok"` OutputUSDPerMTok float64 `json:"output_usd_per_mtok"` + // Cache rates are reported when the provider prices prompt-cache writes + // and reads separately (Anthropic); zero means the input rate applies. + CacheWriteUSDPerMTok float64 `json:"cache_write_usd_per_mtok,omitempty"` + CacheReadUSDPerMTok float64 `json:"cache_read_usd_per_mtok,omitempty"` - PerRunInputTokens int64 `json:"per_run_input_tokens"` - PerRunOutputTokens int64 `json:"per_run_output_tokens"` - PerRunSource string `json:"per_run_source"` - HistoryRunCount int `json:"history_run_count"` - PerRunUSD float64 `json:"per_run_usd"` + PerRunInputTokens int64 `json:"per_run_input_tokens"` + PerRunOutputTokens int64 `json:"per_run_output_tokens"` + // Per-run prompt-cache buckets from the install's own history, disjoint + // from PerRunInputTokens. The measured default has none because it was + // taken from an uncached run. + PerRunCacheCreationInputTokens int64 `json:"per_run_cache_creation_input_tokens,omitempty"` + PerRunCacheReadInputTokens int64 `json:"per_run_cache_read_input_tokens,omitempty"` + PerRunSource string `json:"per_run_source"` + HistoryRunCount int `json:"history_run_count"` + PerRunUSD float64 `json:"per_run_usd"` IntervalMinutes int `json:"interval_minutes"` ScheduledRunsPerDay float64 `json:"scheduled_runs_per_day"` @@ -221,7 +230,8 @@ func ProjectPatrolCost(in PatrolCostProjectionInput) PatrolCostProjection { // enough priced history, else the measured default. windowStart := now.AddDate(0, 0, -patrolCostProjectionWindowDays) oldest := now - var fullInputs, fullOutputs, trigInputs, trigOutputs []int64 + var fullInputs, fullOutputs, fullCacheWrites, fullCacheReads []int64 + var trigInputs, trigOutputs, trigCacheWrites, trigCacheReads []int64 triggeredCount := 0 for _, run := range in.Runs { if run.InputTokens <= 0 || run.StartedAt.Before(windowStart) { @@ -233,11 +243,15 @@ func ProjectPatrolCost(in PatrolCostProjectionInput) PatrolCostProjection { if patrolRunIsFull(run) { fullInputs = append(fullInputs, int64(run.InputTokens)) fullOutputs = append(fullOutputs, int64(run.OutputTokens)) + fullCacheWrites = append(fullCacheWrites, int64(run.CacheCreationInputTokens)) + fullCacheReads = append(fullCacheReads, int64(run.CacheReadInputTokens)) continue } triggeredCount++ trigInputs = append(trigInputs, int64(run.InputTokens)) trigOutputs = append(trigOutputs, int64(run.OutputTokens)) + trigCacheWrites = append(trigCacheWrites, int64(run.CacheCreationInputTokens)) + trigCacheReads = append(trigCacheReads, int64(run.CacheReadInputTokens)) } out.PerRunSource = PatrolCostPerRunSourceDefault out.PerRunInputTokens = DefaultPatrolRunInputTokens @@ -246,6 +260,8 @@ func ProjectPatrolCost(in PatrolCostProjectionInput) PatrolCostProjection { out.PerRunSource = PatrolCostPerRunSourceHistory out.PerRunInputTokens = medianInt64(fullInputs) out.PerRunOutputTokens = medianInt64(fullOutputs) + out.PerRunCacheCreationInputTokens = medianInt64(fullCacheWrites) + out.PerRunCacheReadInputTokens = medianInt64(fullCacheReads) out.HistoryRunCount = len(fullInputs) } @@ -267,24 +283,37 @@ func ProjectPatrolCost(in PatrolCostProjectionInput) PatrolCostProjection { } // Pricing. - _, known, price := cost.EstimateUSD(provider, model, out.PerRunInputTokens, out.PerRunOutputTokens) + perRunUsage := cost.TokenUsage{ + InputTokens: out.PerRunInputTokens, + OutputTokens: out.PerRunOutputTokens, + CacheCreationInputTokens: out.PerRunCacheCreationInputTokens, + CacheReadInputTokens: out.PerRunCacheReadInputTokens, + } + _, known, price := cost.EstimateUsageUSD(provider, model, perRunUsage) out.PricingKnown = known if known { out.PricingAsOf = price.AsOf out.InputUSDPerMTok = price.InputUSDPerMTok out.OutputUSDPerMTok = price.OutputUSDPerMTok + out.CacheWriteUSDPerMTok = price.CacheWriteUSDPerMTok + out.CacheReadUSDPerMTok = price.CacheReadUSDPerMTok out.BilledPerToken = price.InputUSDPerMTok > 0 || price.OutputUSDPerMTok > 0 } - perRun := func(inputTokens, outputTokens int64) float64 { + perRun := func(usage cost.TokenUsage) float64 { if !known { return 0 } - usd, _, _ := cost.EstimateUSD(provider, model, inputTokens, outputTokens) + usd, _, _ := cost.EstimateUsageUSD(provider, model, usage) return usd } - out.PerRunUSD = roundUSD(perRun(out.PerRunInputTokens, out.PerRunOutputTokens)) + out.PerRunUSD = roundUSD(perRun(perRunUsage)) if triggeredCount > 0 { - out.TriggeredPerRunUSD = roundUSD(perRun(medianInt64(trigInputs), medianInt64(trigOutputs))) + out.TriggeredPerRunUSD = roundUSD(perRun(cost.TokenUsage{ + InputTokens: medianInt64(trigInputs), + OutputTokens: medianInt64(trigOutputs), + CacheCreationInputTokens: medianInt64(trigCacheWrites), + CacheReadInputTokens: medianInt64(trigCacheReads), + })) } scheduled30d := func(intervalMinutes int) float64 { if intervalMinutes <= 0 { diff --git a/internal/ai/patrol_cost_projection_test.go b/internal/ai/patrol_cost_projection_test.go index c31098272c..f889fcd6b0 100644 --- a/internal/ai/patrol_cost_projection_test.go +++ b/internal/ai/patrol_cost_projection_test.go @@ -231,3 +231,33 @@ func TestBlockOnExhaustedBudget_SetsPatrolBlockedState(t *testing.T) { t.Fatalf("non-budget failures must not block, got %q", other.lastBlockedCause) } } + +func TestProjectPatrolCost_HistoryCarriesPromptCacheBuckets(t *testing.T) { + now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) + runs := []PatrolRunRecord{ + {StartedAt: now.Add(-6 * time.Hour), TriggerReason: string(TriggerReasonScheduled), InputTokens: 10_000, OutputTokens: 4_000, CacheCreationInputTokens: 20_000, CacheReadInputTokens: 100_000}, + {StartedAt: now.Add(-12 * time.Hour), TriggerReason: string(TriggerReasonScheduled), InputTokens: 12_000, OutputTokens: 4_000, CacheCreationInputTokens: 22_000, CacheReadInputTokens: 110_000}, + {StartedAt: now.Add(-18 * time.Hour), TriggerReason: string(TriggerReasonScheduled), InputTokens: 14_000, OutputTokens: 4_000, CacheCreationInputTokens: 24_000, CacheReadInputTokens: 120_000}, + } + got := ProjectPatrolCost(PatrolCostProjectionInput{ + Provider: "anthropic", + Model: "claude-sonnet-5", + IntervalMinutes: 360, + BudgetUSD30d: 20, + Runs: runs, + Now: now, + }) + if got.PerRunSource != PatrolCostPerRunSourceHistory || got.HistoryRunCount != 3 { + t.Fatalf("expected history-based estimate, got %+v", got) + } + if got.PerRunInputTokens != 12_000 || got.PerRunCacheCreationInputTokens != 22_000 || got.PerRunCacheReadInputTokens != 110_000 { + t.Fatalf("expected median cache buckets, got in=%d creation=%d read=%d", got.PerRunInputTokens, got.PerRunCacheCreationInputTokens, got.PerRunCacheReadInputTokens) + } + if got.CacheWriteUSDPerMTok != 2.5 || got.CacheReadUSDPerMTok != 0.2 { + t.Fatalf("expected Anthropic cache rates on the projection, got %+v", got) + } + // 0.012M*2.00 + 0.004M*10.00 + 0.022M*2.50 + 0.11M*0.20 = 0.141 per run. + if got.PerRunUSD < 0.140 || got.PerRunUSD > 0.142 { + t.Fatalf("per-run cost %.4f should price cache reads at a tenth of input", got.PerRunUSD) + } +} diff --git a/internal/ai/patrol_digest.go b/internal/ai/patrol_digest.go index 330b385dde..1a97e19772 100644 --- a/internal/ai/patrol_digest.go +++ b/internal/ai/patrol_digest.go @@ -113,7 +113,11 @@ type PatrolDigestSpend struct { PricingKnown bool `json:"pricing_known"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` - Calls int `json:"calls"` + // Prompt-cache buckets, disjoint from InputTokens and priced at the + // provider's cache rates when known. + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"` + Calls int `json:"calls"` } // PatrolDigest is the wire payload for GET /api/ai/patrol/digest. @@ -403,8 +407,10 @@ func summarizeDigestSpend(events []cost.UsageEvent, inWindow func(time.Time) boo summary.Calls++ summary.InputTokens += int64(event.InputTokens) summary.OutputTokens += int64(event.OutputTokens) + summary.CacheCreationInputTokens += int64(event.CacheCreationInputTokens) + summary.CacheReadInputTokens += int64(event.CacheReadInputTokens) provider, model := cost.ResolveProviderAndModel(event.Provider, event.RequestModel, event.ResponseModel) - usd, known, _ := cost.EstimateUSD(provider, model, int64(event.InputTokens), int64(event.OutputTokens)) + usd, known, _ := cost.EstimateUsageUSD(provider, model, event.TokenUsage()) if !known { summary.PricingKnown = false continue diff --git a/internal/ai/patrol_digest_test.go b/internal/ai/patrol_digest_test.go index 051b0a74f6..aea8169251 100644 --- a/internal/ai/patrol_digest_test.go +++ b/internal/ai/patrol_digest_test.go @@ -1,6 +1,7 @@ package ai import ( + "math" "testing" "time" @@ -196,3 +197,25 @@ func TestBuildPatrolDigestEmptyInputIsZeroNotNil(t *testing.T) { t.Fatalf("outcome order = %v", order) } } + +func TestSummarizeDigestSpendPricesPromptCacheBuckets(t *testing.T) { + t.Parallel() + now := time.Now() + events := []cost.UsageEvent{ + {Timestamp: now, Provider: "anthropic", RequestModel: "anthropic:claude-sonnet-5", UseCase: "patrol", + InputTokens: 100_000, OutputTokens: 10_000, CacheCreationInputTokens: 200_000, CacheReadInputTokens: 1_000_000}, + // Not Patrol: ignored. + {Timestamp: now, Provider: "anthropic", RequestModel: "anthropic:claude-sonnet-5", UseCase: "chat", InputTokens: 5}, + } + spend := summarizeDigestSpend(events, func(time.Time) bool { return true }) + if spend.Calls != 1 || spend.InputTokens != 100_000 || spend.OutputTokens != 10_000 { + t.Fatalf("spend = %+v", spend) + } + if spend.CacheCreationInputTokens != 200_000 || spend.CacheReadInputTokens != 1_000_000 { + t.Fatalf("cache buckets missing from digest spend: %+v", spend) + } + // Priced at Anthropic cache rates, not the input rate for every token. + if want := 0.2 + 0.1 + 0.5 + 0.2; !spend.PricingKnown || math.Abs(spend.EstimatedUSD-want) > 1e-9 { + t.Fatalf("estimated usd = %f (known=%v), want %f", spend.EstimatedUSD, spend.PricingKnown, want) + } +} diff --git a/internal/ai/patrol_history_persistence.go b/internal/ai/patrol_history_persistence.go index 85b7bf0441..b298f2ae1a 100644 --- a/internal/ai/patrol_history_persistence.go +++ b/internal/ai/patrol_history_persistence.go @@ -78,6 +78,8 @@ func (a *PatrolHistoryPersistenceAdapter) SavePatrolRunHistory(runs []PatrolRunR AIAnalysis: normalized.AIAnalysis, InputTokens: normalized.InputTokens, OutputTokens: normalized.OutputTokens, + CacheCreationInputTokens: normalized.CacheCreationInputTokens, + CacheReadInputTokens: normalized.CacheReadInputTokens, ToolCalls: convertAIToolCallsToConfig(normalized.ToolCalls), ToolCallCount: normalized.ToolCallCount, }) @@ -137,6 +139,8 @@ func (a *PatrolHistoryPersistenceAdapter) LoadPatrolRunHistory() ([]PatrolRunRec AIAnalysis: r.AIAnalysis, InputTokens: r.InputTokens, OutputTokens: r.OutputTokens, + CacheCreationInputTokens: r.CacheCreationInputTokens, + CacheReadInputTokens: r.CacheReadInputTokens, ToolCalls: convertConfigToolCallsToAI(r.ToolCalls), ToolCallCount: r.ToolCallCount, }) diff --git a/internal/ai/patrol_model_readiness.go b/internal/ai/patrol_model_readiness.go index fe4ae53a09..91fd0c4d12 100644 --- a/internal/ai/patrol_model_readiness.go +++ b/internal/ai/patrol_model_readiness.go @@ -127,14 +127,16 @@ type PatrolModelReadinessResult struct { // Details carries per-scenario probe and validator evidence ("expected // exactly one tool call, got 0", "nonce did not match"). Without it a // failed evaluation is undiagnosable from the result alone (#1624, #1614). - Details []string `json:"details,omitempty"` - Metadata *PatrolModelReadinessMetadata `json:"metadata,omitempty"` - Dimensions PatrolModelReadinessDimensions `json:"dimensions"` - Modes PatrolModelReadinessModes `json:"modes"` - CacheKey string `json:"-"` - inputTokens int - outputTokens int - providerCalls int + Details []string `json:"details,omitempty"` + Metadata *PatrolModelReadinessMetadata `json:"metadata,omitempty"` + Dimensions PatrolModelReadinessDimensions `json:"dimensions"` + Modes PatrolModelReadinessModes `json:"modes"` + CacheKey string `json:"-"` + inputTokens int + outputTokens int + cacheCreationTokens int + cacheReadTokens int + providerCalls int } type patrolModelReadinessCache struct { @@ -583,13 +585,15 @@ type patrolReadinessScenario struct { } type patrolReadinessProbeOutcome struct { - toolCalls []providers.ToolCall - stopReason string - duration time.Duration - firstResponse time.Duration - inputTokens int - outputTokens int - completionEvent bool + toolCalls []providers.ToolCall + stopReason string + duration time.Duration + firstResponse time.Duration + inputTokens int + outputTokens int + cacheCreationTokens int + cacheReadTokens int + completionEvent bool } func runPatrolModelReadinessWithProvider(ctx context.Context, cfg *config.AIConfig, providerName, model, modelString string, provider providers.Provider) PatrolModelReadinessResult { @@ -706,6 +710,8 @@ func runPatrolModelReadinessWithProvider(ctx context.Context, cfg *config.AIConf outcome, err := runPatrolReadinessStreamProbe(ctx, streamingProvider, req) result.inputTokens += outcome.inputTokens result.outputTokens += outcome.outputTokens + result.cacheCreationTokens += outcome.cacheCreationTokens + result.cacheReadTokens += outcome.cacheReadTokens if err != nil { probeErr = err details = append(details, fmt.Sprintf("Scenario %q probe failed: %v", scenario.name, err)) @@ -780,6 +786,8 @@ func runPatrolModelReadinessWithProvider(ctx context.Context, cfg *config.AIConf outcome, err := runPatrolReadinessStreamProbe(ctx, streamingProvider, followup) result.inputTokens += outcome.inputTokens result.outputTokens += outcome.outputTokens + result.cacheCreationTokens += outcome.cacheCreationTokens + result.cacheReadTokens += outcome.cacheReadTokens if err != nil { probeErr = err details = append(details, fmt.Sprintf("Multi-turn continuation probe failed: %v", err)) @@ -1106,12 +1114,16 @@ func runPatrolReadinessStreamProbe(ctx context.Context, provider providers.Strea outcome.stopReason = data.StopReason outcome.inputTokens = data.InputTokens outcome.outputTokens = data.OutputTokens + outcome.cacheCreationTokens = data.CacheCreationInputTokens + outcome.cacheReadTokens = data.CacheReadInputTokens outcome.completionEvent = true } else if data, ok := event.Data.(*providers.DoneEvent); ok && data != nil { outcome.toolCalls = append([]providers.ToolCall(nil), data.ToolCalls...) outcome.stopReason = data.StopReason outcome.inputTokens = data.InputTokens outcome.outputTokens = data.OutputTokens + outcome.cacheCreationTokens = data.CacheCreationInputTokens + outcome.cacheReadTokens = data.CacheReadInputTokens outcome.completionEvent = true } } @@ -1261,11 +1273,13 @@ func (s *Service) recordPatrolModelReadinessUsage(result PatrolModelReadinessRes return } costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: result.Provider, - RequestModel: modelStringForReadinessResult(result), - UseCase: "patrol_readiness", - InputTokens: result.inputTokens, - OutputTokens: result.outputTokens, + Timestamp: time.Now(), + Provider: result.Provider, + RequestModel: modelStringForReadinessResult(result), + UseCase: "patrol_readiness", + InputTokens: result.inputTokens, + OutputTokens: result.outputTokens, + CacheCreationInputTokens: result.cacheCreationTokens, + CacheReadInputTokens: result.cacheReadTokens, }) } diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index fb499b5c4f..67c08b5e60 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -797,6 +797,8 @@ func (p *PatrolService) runPatrolWithTriggerStart(ctx context.Context, trigger T runRecord.FindingAssessments = append([]PatrolFindingAssessment(nil), runStats.aiAnalysis.Assessments...) runRecord.InputTokens = runStats.aiAnalysis.InputTokens runRecord.OutputTokens = runStats.aiAnalysis.OutputTokens + runRecord.CacheCreationInputTokens = runStats.aiAnalysis.CacheCreationInputTokens + runRecord.CacheReadInputTokens = runStats.aiAnalysis.CacheReadInputTokens runRecord.TriageFlags = runStats.triageFlags runRecord.TriageSkippedLLM = runStats.triageSkippedLLM toolCalls := runStats.aiAnalysis.ToolCalls @@ -1266,6 +1268,8 @@ func (p *PatrolService) runScopedPatrolWithStart(ctx context.Context, scope Patr runRecord.FindingAssessments = append([]PatrolFindingAssessment(nil), runStats.aiAnalysis.Assessments...) runRecord.InputTokens = runStats.aiAnalysis.InputTokens runRecord.OutputTokens = runStats.aiAnalysis.OutputTokens + runRecord.CacheCreationInputTokens = runStats.aiAnalysis.CacheCreationInputTokens + runRecord.CacheReadInputTokens = runStats.aiAnalysis.CacheReadInputTokens runRecord.TriageFlags = runStats.triageFlags runRecord.TriageSkippedLLM = runStats.triageSkippedLLM toolCalls := runStats.aiAnalysis.ToolCalls diff --git a/internal/ai/providers/anthropic.go b/internal/ai/providers/anthropic.go index b4ba514002..3ccb08554c 100644 --- a/internal/ai/providers/anthropic.go +++ b/internal/ai/providers/anthropic.go @@ -483,6 +483,10 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo ToolCalls: toolCalls, InputTokens: anthropicResp.Usage.InputTokens, OutputTokens: anthropicResp.Usage.OutputTokens, + // Cache buckets are billed at their own rates and are not part of + // input_tokens, so they travel separately into cost accounting. + CacheCreationInputTokens: anthropicResp.Usage.CacheCreationInputTokens, + CacheReadInputTokens: anthropicResp.Usage.CacheReadInputTokens, }, nil } @@ -622,6 +626,23 @@ type anthropicDelta struct { StopReason string `json:"stop_reason,omitempty"` } +// captureAnthropicPromptUsage copies the prompt-side usage counters from a +// stream event. Anthropic reports input_tokens, cache_creation_input_tokens +// and cache_read_input_tokens as disjoint buckets and repeats them as +// cumulative values in message_start and the final message_delta, so each is +// assigned when present, never summed across events. +func captureAnthropicPromptUsage(usage anthropicUsage, input, cacheCreation, cacheRead *int) { + if usage.InputTokens > 0 { + *input = usage.InputTokens + } + if usage.CacheCreationInputTokens > 0 { + *cacheCreation = usage.CacheCreationInputTokens + } + if usage.CacheReadInputTokens > 0 { + *cacheRead = usage.CacheReadInputTokens + } +} + // ChatStream sends a chat request and streams the response via callback func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callback StreamCallback) error { // Convert messages to Anthropic format (same as Chat) @@ -735,7 +756,7 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb var currentToolID string var currentToolName string var currentToolInput strings.Builder - var inputTokens, outputTokens int + var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens int for { n, err := reader.Read(buf) @@ -769,8 +790,8 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb switch event.Type { case "message_start": - if event.Message != nil && event.Message.Usage.InputTokens > 0 { - inputTokens = event.Message.Usage.InputTokens + if event.Message != nil { + captureAnthropicPromptUsage(event.Message.Usage, &inputTokens, &cacheCreationTokens, &cacheReadTokens) } case "content_block_start": @@ -838,8 +859,11 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb } case "message_delta": - if event.Delta != nil && event.Delta.StopReason != "" { - if event.Usage != nil { + if event.Usage != nil { + // The final message_delta repeats the prompt-side usage + // as cumulative values, so this assigns rather than adds. + captureAnthropicPromptUsage(*event.Usage, &inputTokens, &cacheCreationTokens, &cacheReadTokens) + if event.Delta != nil && event.Delta.StopReason != "" { outputTokens = event.Usage.OutputTokens } } @@ -852,10 +876,12 @@ func (c *AnthropicClient) ChatStream(ctx context.Context, req ChatRequest, callb callback(StreamEvent{ Type: "done", Data: DoneEvent{ - StopReason: stopReason, - ToolCalls: toolCalls, - InputTokens: inputTokens, - OutputTokens: outputTokens, + StopReason: stopReason, + ToolCalls: toolCalls, + InputTokens: inputTokens, + OutputTokens: outputTokens, + CacheCreationInputTokens: cacheCreationTokens, + CacheReadInputTokens: cacheReadTokens, }, }) diff --git a/internal/ai/providers/anthropic_test.go b/internal/ai/providers/anthropic_test.go index 4879e59931..c0e647b33d 100644 --- a/internal/ai/providers/anthropic_test.go +++ b/internal/ai/providers/anthropic_test.go @@ -902,3 +902,87 @@ func TestAnthropicClient_ChatStream_CacheableSystemPrefix(t *testing.T) { t.Fatalf("tool breakpoint must be dropped when the system block is cached: %+v", got.Tools[0]) } } + +// Issue #2350: cache-creation and cache-read usage must survive both response +// paths so cost accounting can price each bucket at its own rate. +func TestAnthropicClient_Chat_KeepsPromptCacheUsageBuckets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(anthropicResponse{ + ID: "msg_cache", + Type: "message", + Role: "assistant", + Model: "claude-sonnet-5", + StopReason: "end_turn", + Content: []anthropicContent{{Type: "text", Text: "ok"}}, + Usage: anthropicUsage{ + InputTokens: 100, + CacheCreationInputTokens: 1000, + CacheReadInputTokens: 5000, + OutputTokens: 20, + }, + }) + })) + defer server.Close() + + client := NewAnthropicClientWithBaseURL("test-key", "claude-sonnet-5", server.URL, 0) + resp, err := client.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hi"}}, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.InputTokens != 100 || resp.OutputTokens != 20 { + t.Fatalf("ordinary usage changed: in=%d out=%d", resp.InputTokens, resp.OutputTokens) + } + if resp.CacheCreationInputTokens != 1000 || resp.CacheReadInputTokens != 5000 { + t.Fatalf("cache buckets dropped: creation=%d read=%d", resp.CacheCreationInputTokens, resp.CacheReadInputTokens) + } +} + +func TestAnthropicClient_ChatStream_KeepsPromptCacheUsageBuckets(t *testing.T) { + // message_start carries the prompt-side usage; the final message_delta + // repeats it as cumulative values, which must assign, not double. + stream := []string{ + `{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_creation_input_tokens":1000,"cache_read_input_tokens":5000,"output_tokens":1}}}`, + `{"type":"content_block_start","content_block":{"type":"text"}}`, + `{"type":"content_block_delta","delta":{"type":"text_delta","text":"ok"}}`, + `{"type":"content_block_stop"}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":100,"cache_creation_input_tokens":1000,"cache_read_input_tokens":5000,"output_tokens":20}}`, + `{"type":"message_stop"}`, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + for _, event := range stream { + _, _ = w.Write([]byte("data: " + event + "\n\n")) + w.(http.Flusher).Flush() + } + })) + defer server.Close() + + client := NewAnthropicClientWithBaseURL("test-key", "claude-sonnet-5", server.URL, 0) + var done DoneEvent + doneCalled := false + err := client.ChatStream(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hi"}}, + }, func(event StreamEvent) { + if event.Type == "done" { + if data, ok := event.Data.(DoneEvent); ok { + done = data + doneCalled = true + } + } + }) + if err != nil { + t.Fatalf("ChatStream: %v", err) + } + if !doneCalled { + t.Fatal("no done event") + } + if done.InputTokens != 100 || done.OutputTokens != 20 { + t.Fatalf("ordinary usage changed: in=%d out=%d", done.InputTokens, done.OutputTokens) + } + if done.CacheCreationInputTokens != 1000 || done.CacheReadInputTokens != 5000 { + t.Fatalf("cache buckets dropped or doubled: creation=%d read=%d", done.CacheCreationInputTokens, done.CacheReadInputTokens) + } +} diff --git a/internal/ai/providers/provider.go b/internal/ai/providers/provider.go index 86c9d7e13c..5f28dade53 100644 --- a/internal/ai/providers/provider.go +++ b/internal/ai/providers/provider.go @@ -146,6 +146,12 @@ type ChatResponse struct { ToolCalls []ToolCall `json:"tool_calls"` // Tool invocations InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` + // CacheCreationInputTokens and CacheReadInputTokens are the prompt-cache + // buckets a provider reports beside InputTokens (Anthropic). InputTokens + // stays the provider's ordinary, uncached input count, so the three never + // overlap; the cost ledger prices each bucket at its own rate. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` } func (r ChatResponse) NormalizeCollections() ChatResponse { @@ -273,6 +279,9 @@ type DoneEvent struct { ToolCalls []ToolCall `json:"tool_calls"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` + // Prompt-cache buckets, disjoint from InputTokens; see ChatResponse. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` } func (e DoneEvent) NormalizeCollections() DoneEvent { diff --git a/internal/ai/report_fleet_narrator.go b/internal/ai/report_fleet_narrator.go index 354974e556..3e6343c6bb 100644 --- a/internal/ai/report_fleet_narrator.go +++ b/internal/ai/report_fleet_narrator.go @@ -204,15 +204,17 @@ func (s *Service) NarrateFleet(ctx context.Context, in reporting.FleetNarrativeI providerName = provider.Name() } costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: model, - ResponseModel: resp.Model, - UseCase: reportFleetNarratorUseCase, - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, - TargetType: "fleet", - TargetID: strings.TrimSpace(in.Title), + Timestamp: time.Now(), + Provider: providerName, + RequestModel: model, + ResponseModel: resp.Model, + UseCase: reportFleetNarratorUseCase, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, + TargetType: "fleet", + TargetID: strings.TrimSpace(in.Title), }) } diff --git a/internal/ai/report_narrator.go b/internal/ai/report_narrator.go index 0f083cee9b..8ea890cbc0 100644 --- a/internal/ai/report_narrator.go +++ b/internal/ai/report_narrator.go @@ -225,15 +225,17 @@ func (s *Service) Narrate(ctx context.Context, in reporting.NarrativeInput) (rep providerName = provider.Name() } costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: model, - ResponseModel: resp.Model, - UseCase: reportNarratorUseCase, - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, - TargetType: in.ResourceType, - TargetID: in.ResourceID, + Timestamp: time.Now(), + Provider: providerName, + RequestModel: model, + ResponseModel: resp.Model, + UseCase: reportNarratorUseCase, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, + TargetType: in.ResourceType, + TargetID: in.ResourceID, }) } diff --git a/internal/ai/service.go b/internal/ai/service.go index 13a637f48a..ddd974b733 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -226,6 +226,10 @@ type PatrolStreamResponse struct { Content string `json:"content"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` + // Prompt-cache buckets summed across the run's provider calls; disjoint + // from InputTokens, see providers.ChatResponse. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // ToolCalls carries calls observed by bounded Patrol follow-up passes so // the parent run's durable audit contains every provider action. ToolCalls []ToolCallRecord `json:"tool_calls,omitempty"` @@ -1978,14 +1982,16 @@ func (s *Service) QuickAnalysis(ctx context.Context, req QuickAnalysisRequest) ( providerName = provider.Name() } costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: model, - ResponseModel: resp.Model, - UseCase: useCase, - TargetType: strings.TrimSpace(req.TargetType), - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, + Timestamp: time.Now(), + Provider: providerName, + RequestModel: model, + ResponseModel: resp.Model, + UseCase: useCase, + TargetType: strings.TrimSpace(req.TargetType), + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, }) } @@ -2169,12 +2175,16 @@ func assistantContextScopeForExecuteRequest(req ExecuteRequest) string { // ExecuteResponse represents the AI's response type ExecuteResponse struct { - Content string `json:"content"` - Model string `json:"model"` - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - ToolCalls []ToolExecution `json:"tool_calls"` // Commands that were executed - PendingApprovals []ApprovalNeededData `json:"pending_approvals"` // Commands that require approval (non-streaming) + Content string `json:"content"` + Model string `json:"model"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + // Prompt-cache buckets summed across the loop's provider calls; disjoint + // from InputTokens, see providers.ChatResponse. + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` + ToolCalls []ToolExecution `json:"tool_calls"` // Commands that were executed + PendingApprovals []ApprovalNeededData `json:"pending_approvals"` // Commands that require approval (non-streaming) } func EmptyExecuteResponse() ExecuteResponse { @@ -2357,6 +2367,8 @@ Always execute the commands rather than telling the user how to do it.` var toolExecutions []ToolExecution totalInputTokens := 0 totalOutputTokens := 0 + totalCacheCreationTokens := 0 + totalCacheReadTokens := 0 var finalContent string var model string @@ -2393,22 +2405,26 @@ Always execute the commands rather than telling the user how to do it.` providerName = provider.Name() } costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: modelString, - ResponseModel: resp.Model, - UseCase: req.UseCase, - ContextScope: assistantContextScopeForExecuteRequest(req), - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, - TargetType: req.TargetType, - TargetID: req.TargetID, - FindingID: req.FindingID, + Timestamp: time.Now(), + Provider: providerName, + RequestModel: modelString, + ResponseModel: resp.Model, + UseCase: req.UseCase, + ContextScope: assistantContextScopeForExecuteRequest(req), + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, + TargetType: req.TargetType, + TargetID: req.TargetID, + FindingID: req.FindingID, }) } totalInputTokens += resp.InputTokens totalOutputTokens += resp.OutputTokens + totalCacheCreationTokens += resp.CacheCreationInputTokens + totalCacheReadTokens += resp.CacheReadInputTokens model = resp.Model finalContent = resp.Content @@ -2452,22 +2468,26 @@ Always execute the commands rather than telling the user how to do it.` // The caller can execute approvals via /api/ai/run-command and continue. if len(pendingApprovals) > 0 { return &ExecuteResponse{ - Content: finalContent, - Model: model, - InputTokens: totalInputTokens, - OutputTokens: totalOutputTokens, - ToolCalls: toolExecutions, - PendingApprovals: pendingApprovals, + Content: finalContent, + Model: model, + InputTokens: totalInputTokens, + OutputTokens: totalOutputTokens, + CacheCreationInputTokens: totalCacheCreationTokens, + CacheReadInputTokens: totalCacheReadTokens, + ToolCalls: toolExecutions, + PendingApprovals: pendingApprovals, }, nil } } return &ExecuteResponse{ - Content: finalContent, - Model: model, - InputTokens: totalInputTokens, - OutputTokens: totalOutputTokens, - ToolCalls: toolExecutions, + Content: finalContent, + Model: model, + InputTokens: totalInputTokens, + OutputTokens: totalOutputTokens, + CacheCreationInputTokens: totalCacheCreationTokens, + CacheReadInputTokens: totalCacheReadTokens, + ToolCalls: toolExecutions, }, nil } @@ -2583,6 +2603,8 @@ Always execute the commands rather than telling the user how to do it.` var toolExecutions []ToolExecution totalInputTokens := 0 totalOutputTokens := 0 + totalCacheCreationTokens := 0 + totalCacheReadTokens := 0 var finalContent string var model string @@ -2636,17 +2658,19 @@ Always execute the commands rather than telling the user how to do it.` providerName = provider.Name() } costStore.Record(cost.UsageEvent{ - Timestamp: time.Now(), - Provider: providerName, - RequestModel: modelString, - ResponseModel: resp.Model, - UseCase: req.UseCase, - ContextScope: assistantContextScopeForExecuteRequest(req), - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, - TargetType: req.TargetType, - TargetID: req.TargetID, - FindingID: req.FindingID, + Timestamp: time.Now(), + Provider: providerName, + RequestModel: modelString, + ResponseModel: resp.Model, + UseCase: req.UseCase, + ContextScope: assistantContextScopeForExecuteRequest(req), + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, + TargetType: req.TargetType, + TargetID: req.TargetID, + FindingID: req.FindingID, }) } @@ -2654,6 +2678,8 @@ Always execute the commands rather than telling the user how to do it.` totalInputTokens += resp.InputTokens totalOutputTokens += resp.OutputTokens + totalCacheCreationTokens += resp.CacheCreationInputTokens + totalCacheReadTokens += resp.CacheReadInputTokens model = resp.Model finalContent = resp.Content @@ -2877,11 +2903,13 @@ Always execute the commands rather than telling the user how to do it.` callback(StreamEvent{Type: "done"}) return &ExecuteResponse{ - Content: finalContent, - Model: model, - InputTokens: totalInputTokens, - OutputTokens: totalOutputTokens, - ToolCalls: toolExecutions, + Content: finalContent, + Model: model, + InputTokens: totalInputTokens, + OutputTokens: totalOutputTokens, + CacheCreationInputTokens: totalCacheCreationTokens, + CacheReadInputTokens: totalCacheReadTokens, + ToolCalls: toolExecutions, }, nil } @@ -3747,11 +3775,13 @@ func (s *Service) AnalyzeForDiscovery(ctx context.Context, prompt string) (strin providerName, _ = config.ParseModelString(model) } costStore.Record(cost.UsageEvent{ - Provider: providerName, - RequestModel: model, - UseCase: "discovery", - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, + Provider: providerName, + RequestModel: model, + UseCase: "discovery", + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, }) } diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 0b367402dc..62ee996eff 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -7265,7 +7265,7 @@ func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r * exported := make([]exportEvent, 0, len(events)) for _, e := range events { provider, model := cost.ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) - usd, ok, _ := cost.EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) + usd, ok, _ := cost.EstimateUsageUSD(provider, model, e.TokenUsage()) exported = append(exported, exportEvent{ UsageEvent: e, EstimatedUSD: usd, @@ -7292,6 +7292,8 @@ func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r * "use_case", "input_tokens", "output_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", "estimated_usd", "pricing_known", "target_type", @@ -7300,7 +7302,7 @@ func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r * }) for _, e := range events { provider, model := cost.ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) - usd, ok, _ := cost.EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) + usd, ok, _ := cost.EstimateUsageUSD(provider, model, e.TokenUsage()) _ = cw.Write([]string{ e.Timestamp.UTC().Format(time.RFC3339Nano), @@ -7310,6 +7312,8 @@ func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r * e.UseCase, strconv.Itoa(e.InputTokens), strconv.Itoa(e.OutputTokens), + strconv.Itoa(e.CacheCreationInputTokens), + strconv.Itoa(e.CacheReadInputTokens), strconv.FormatFloat(usd, 'f', 6, 64), strconv.FormatBool(ok), e.TargetType, diff --git a/internal/api/ai_handlers_more_test.go b/internal/api/ai_handlers_more_test.go index 12bc7899fc..f1d8973d81 100644 --- a/internal/api/ai_handlers_more_test.go +++ b/internal/api/ai_handlers_more_test.go @@ -14,6 +14,7 @@ import ( "github.com/gorilla/websocket" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -719,3 +720,74 @@ func TestBuildPatrolDigestReportsAvailability(t *testing.T) { t.Fatal("digest must report available once a Patrol service exists") } } + +// Pulse #2350: the usage export must carry the prompt-cache buckets and price +// them at the provider's cache rates, in both formats. +func TestHandleExportAICostHistoryCarriesPromptCacheBuckets(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + handler := newTestAISettingsHandler(cfg, persistence, nil) + store := handler.defaultAIService.CostStore() + if store == nil { + t.Fatal("expected the AI service to own a cost store") + } + store.Record(cost.UsageEvent{ + Timestamp: time.Now(), + Provider: "anthropic", + RequestModel: "anthropic:claude-sonnet-5", + UseCase: "patrol", + InputTokens: 100_000, + OutputTokens: 10_000, + CacheCreationInputTokens: 200_000, + CacheReadInputTokens: 1_000_000, + }) + // 0.1M*2.00 + 0.01M*10.00 + 0.2M*2.50 + 1.0M*0.20 at Anthropic cache rates. + const wantUSD = 1.0 + + req := newLoopbackRequest(http.MethodGet, "/api/ai/cost/export?days=7&format=json", nil) + rec := httptest.NewRecorder() + handler.HandleExportAICostHistory(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("json export status = %d: %s", rec.Code, rec.Body.String()) + } + var exported struct { + Events []struct { + cost.UsageEvent + EstimatedUSD float64 `json:"estimated_usd"` + PricingKnown bool `json:"pricing_known"` + } `json:"events"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &exported); err != nil { + t.Fatalf("decode json export: %v", err) + } + if len(exported.Events) != 1 { + t.Fatalf("expected one exported event, got %d", len(exported.Events)) + } + got := exported.Events[0] + if got.CacheCreationInputTokens != 200_000 || got.CacheReadInputTokens != 1_000_000 || got.InputTokens != 100_000 { + t.Fatalf("json export lost cache buckets: %+v", got.UsageEvent) + } + if !got.PricingKnown || got.EstimatedUSD < wantUSD-1e-6 || got.EstimatedUSD > wantUSD+1e-6 { + t.Fatalf("json export estimated_usd = %f (known=%v), want %f", got.EstimatedUSD, got.PricingKnown, wantUSD) + } + + req = newLoopbackRequest(http.MethodGet, "/api/ai/cost/export?days=7&format=csv", nil) + rec = httptest.NewRecorder() + handler.HandleExportAICostHistory(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("csv export status = %d: %s", rec.Code, rec.Body.String()) + } + lines := strings.Split(strings.TrimSpace(rec.Body.String()), "\n") + if len(lines) != 2 { + t.Fatalf("expected header and one row, got %d lines: %q", len(lines), rec.Body.String()) + } + if !strings.Contains(lines[0], "input_tokens,output_tokens,cache_creation_input_tokens,cache_read_input_tokens,estimated_usd,") { + t.Fatalf("csv header missing cache columns after output_tokens: %s", lines[0]) + } + if !strings.Contains(lines[1], ",100000,10000,200000,1000000,1.000000,true,") { + t.Fatalf("csv row missing cache buckets or cache-rate pricing: %s", lines[1]) + } +} diff --git a/internal/api/chat_service_adapter.go b/internal/api/chat_service_adapter.go index e84b647df5..8870e97752 100644 --- a/internal/api/chat_service_adapter.go +++ b/internal/api/chat_service_adapter.go @@ -38,17 +38,21 @@ func (a *chatServiceAdapter) ExecutePatrolStream(ctx context.Context, req ai.Pat if err != nil { if resp != nil { return &ai.PatrolStreamResponse{ - Content: resp.Content, - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, + Content: resp.Content, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, }, err } return nil, err } return &ai.PatrolStreamResponse{ - Content: resp.Content, - InputTokens: resp.InputTokens, - OutputTokens: resp.OutputTokens, + Content: resp.Content, + InputTokens: resp.InputTokens, + OutputTokens: resp.OutputTokens, + CacheCreationInputTokens: resp.CacheCreationInputTokens, + CacheReadInputTokens: resp.CacheReadInputTokens, }, nil } diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 17d5eb11cf..c1eefbf941 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -2969,9 +2969,11 @@ type PatrolRunRecord struct { TriageFlags int `json:"triage_flags"` TriageSkippedLLM bool `json:"triage_skipped_llm,omitempty"` // AI Analysis details - AIAnalysis string `json:"ai_analysis,omitempty"` // The AI's raw response/analysis - InputTokens int `json:"input_tokens,omitempty"` // Tokens sent to AI - OutputTokens int `json:"output_tokens,omitempty"` // Tokens received from AI + AIAnalysis string `json:"ai_analysis,omitempty"` // The AI's raw response/analysis + InputTokens int `json:"input_tokens,omitempty"` // Tokens sent to AI + OutputTokens int `json:"output_tokens,omitempty"` // Tokens received from AI + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // Prompt-cache writes, disjoint from InputTokens + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // Prompt-cache reads, disjoint from InputTokens // Tool call traces ToolCalls []ToolCallRecord `json:"tool_calls,omitempty"` ToolCallCount int `json:"tool_call_count"` @@ -3017,6 +3019,8 @@ type patrolRunRecordJSON struct { AIAnalysis string `json:"ai_analysis,omitempty"` InputTokens int `json:"input_tokens,omitempty"` OutputTokens int `json:"output_tokens,omitempty"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // Prompt-cache writes, disjoint from InputTokens + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // Prompt-cache reads, disjoint from InputTokens ToolCalls []ToolCallRecord `json:"tool_calls,omitempty"` ToolCallCount int `json:"tool_call_count"` } @@ -3108,6 +3112,8 @@ func (r PatrolRunRecord) MarshalJSON() ([]byte, error) { AIAnalysis: normalized.AIAnalysis, InputTokens: normalized.InputTokens, OutputTokens: normalized.OutputTokens, + CacheCreationInputTokens: normalized.CacheCreationInputTokens, + CacheReadInputTokens: normalized.CacheReadInputTokens, ToolCalls: normalized.ToolCalls, ToolCallCount: normalized.ToolCallCount, }) @@ -3160,6 +3166,8 @@ func (r *PatrolRunRecord) UnmarshalJSON(data []byte) error { AIAnalysis: payload.AIAnalysis, InputTokens: payload.InputTokens, OutputTokens: payload.OutputTokens, + CacheCreationInputTokens: payload.CacheCreationInputTokens, + CacheReadInputTokens: payload.CacheReadInputTokens, ToolCalls: payload.ToolCalls, ToolCallCount: payload.ToolCallCount, } @@ -3188,18 +3196,20 @@ type AIUsageHistoryData struct { // AIUsageEventRecord is a persisted usage event for an AI provider call. // This intentionally excludes prompt/response content for privacy. type AIUsageEventRecord struct { - Timestamp time.Time `json:"timestamp"` - Provider string `json:"provider"` - RequestModel string `json:"request_model"` - ResponseModel string `json:"response_model,omitempty"` - UseCase string `json:"use_case,omitempty"` // "chat" or "patrol" - ContextScope string `json:"context_scope,omitempty"` - ToolCallCount int `json:"tool_call_count,omitempty"` - InputTokens int `json:"input_tokens,omitempty"` - OutputTokens int `json:"output_tokens,omitempty"` - TargetType string `json:"target_type,omitempty"` - TargetID string `json:"target_id,omitempty"` - FindingID string `json:"finding_id,omitempty"` + Timestamp time.Time `json:"timestamp"` + Provider string `json:"provider"` + RequestModel string `json:"request_model"` + ResponseModel string `json:"response_model,omitempty"` + UseCase string `json:"use_case,omitempty"` // "chat" or "patrol" + ContextScope string `json:"context_scope,omitempty"` + ToolCallCount int `json:"tool_call_count,omitempty"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` // Prompt-cache writes, disjoint from InputTokens + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` // Prompt-cache reads, disjoint from InputTokens + TargetType string `json:"target_type,omitempty"` + TargetID string `json:"target_id,omitempty"` + FindingID string `json:"finding_id,omitempty"` } const ( From 237130a2cb6e99c16b8b998decefde06e1cafe0c Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:08:37 +0100 Subject: [PATCH 2/6] Warn on inherited npm advisories when the lockfile is unchanged GHSA-vfj7-8cjw-p6xm (braces <= 3.0.3, no patched release) reached the frontend graph only through dev-only tailwindcss 3 and jscpd 4, and from 3 October it failed the required Frontend check on every open pull request, Go-only ones included. The production audit is clean, and a change that leaves package.json and package-lock.json untouched has the base commit's graph, so it can neither add a finding nor remove one. Failing it blocked every fix and made nothing safer. The runner now applies the split it already used for an unreachable endpoint to findings too. A change that moves the graph, or edits the runner, still fails on any finding at any severity. A change whose graph is the base commit's warns, naming every finding. Only an explicit NPM_AUDIT_REQUIRE_RESULT=false selects that mode; an empty or unrecognised value, as a missing workflow output would give, stays strict, which also tightens the outage path. The scheduled npm-audit scan and the daily dependency advisory watch keep the strict default, so an inherited advisory still fails somewhere every day until the dependency graph is fixed. No severity threshold, allowlist or suppression is added. --- .github/workflows/README.md | 14 ++-- .github/workflows/build-and-test.yml | 23 +++--- .../workflows/dependency-advisory-watch.yml | 10 ++- .../subsystems/deployment-installability.md | 40 +++++++--- .../installtests/build_release_assets_test.go | 8 +- scripts/npm-audit-retry.sh | 59 +++++++++----- scripts/tests/test-npm-audit-retry.sh | 33 ++++++-- .../tests/test_dependency_advisory_watch.py | 33 ++++++++ scripts/tests/test_npm_audit_retry.py | 77 +++++++++++++------ 9 files changed, 215 insertions(+), 82 deletions(-) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 4769fa6b49..f28df761e2 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -120,10 +120,12 @@ exposes independent marker damage in the same evidence packet; it never admits the release or enables later delivery checks unless both trust checks pass. Scheduled and push-time npm audits classify JSON results, use one-minute registry attempts, and retry an unavailable audit endpoint. Advisory findings -still fail immediately, even if the same response contains a transport error. The -scheduled security scan and dependency-changing builds fail if three attempts -produce no result; a build with an unchanged dependency graph warns and uses -the base commit's passing answer. Audit steps defer their aggregate verdict so +are conclusive immediately, even if the same response contains a transport error. The +scheduled security scan, the dependency advisory watch and dependency-changing +builds fail on any finding and when three attempts produce no result. A build +with an unchanged dependency graph warns on both instead and names every +finding, because its graph is the base commit's, so any advisory it sees is one +the base already has and that only a dependency change can remove. Audit steps defer their aggregate verdict so an unavailable endpoint cannot suppress independent frontend checks or the production bundle build; the preceding clean install disables npm's duplicate best-effort audit request. @@ -170,8 +172,8 @@ one that passed. It uploads nothing and gates nothing. `dependency-advisory-watch.yml` runs the required build-and-test frontend audit daily against `main` and every active `release/v.` line (the latest stable's line and newer), because a new npm advisory against an -unchanged lockfile otherwise surfaces only when it fails every pull request on -that branch. Each line is a separate matrix job that reads only that line's +unchanged lockfile otherwise surfaces only as a warning on that branch's pull +requests; this watch is where it fails. Each line is a separate matrix job that reads only that line's `frontend-modern/package.json` and `package-lock.json` (it never checks out, installs or runs another branch's code in the default branch's scope) and audits them with the same Node.js pin and `scripts/npm-audit-retry.sh all`. A failing job names diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 701861305e..7a0d49b3a2 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -105,10 +105,12 @@ jobs: fi echo "code=$code" >> "$GITHUB_OUTPUT" # Whether this change moves the frontend dependency graph. It decides - # what an unreachable npm advisory endpoint means: unknown answer - # (must block) versus the base commit's already-passing answer. A - # change to the audit runner itself also demands a real result, so it - # can never be relaxed under cover of its own tolerant mode. + # whether the audit blocks it: a change that moves the graph answers + # for any finding and for an unreachable endpoint, while one that + # leaves the graph alone only warns, because its findings are the + # base commit's. A change to the audit runner itself also demands a + # clean result, so it can never be relaxed under cover of its own + # tolerant mode. if printf '%s\n' "$changed" | grep -qE '^frontend-modern/package(-lock)?\.json$|^scripts/npm-audit-retry\.sh$'; then frontend_deps=true else @@ -193,14 +195,15 @@ jobs: run: npm ci --no-audit # npm audit exits 1 both for a real advisory and for an unreachable - # advisory endpoint. The runner keeps the advisory verdict exactly as - # strict and only retries the endpoint being down; see the script header. + # advisory endpoint. The runner retries the endpoint being down, fails a + # change that moves the dependency graph on any finding, and only warns, + # naming every finding, for a change whose graph is the base commit's; + # see the script header. # # Only the complete graph is audited here. `--omit=dev` audits a subset - # of these packages, so it can only report a subset of these advisories; - # and because the complete audit fails the job on any finding, the - # production step could only ever run in the cases where it was already - # guaranteed clean. The dev-versus-production split is still reported + # of these packages, so it can only report a subset of these advisories, + # under the same blocking rule; a separate production step could never + # block a change the complete audit lets through. The dev-versus-production split is still reported # for every workspace by the scheduled npm-audit job in # security-scan.yml, where it informs rather than blocks delivery. - name: Audit complete frontend dependency graph diff --git a/.github/workflows/dependency-advisory-watch.yml b/.github/workflows/dependency-advisory-watch.yml index 94bf5aeacf..966a3dbfb0 100644 --- a/.github/workflows/dependency-advisory-watch.yml +++ b/.github/workflows/dependency-advisory-watch.yml @@ -1,12 +1,14 @@ name: Dependency Advisory Watch -# A new npm advisory against an unchanged lockfile fails the required +# A new npm advisory against an unchanged lockfile used to fail the required # "Audit complete frontend dependency graph" step in build-and-test.yml on # every pull request to that branch. On 30 Sep-1 Oct 2026 two advisories did # exactly that to main, release/v6.4 and release/v6.5 at once, and nothing -# owned the fix. This watch runs the same audit daily against main and every -# active release line, fails when any of them would block, and names the -# branch, the advisories and the fix in the step summary. Scheduled workflows +# owned the fix. Since 3 Oct 2026 that step only warns for a pull request that +# leaves the lockfile alone, so this watch is where such an advisory fails: it +# runs the same audit daily against main and every active release line with +# NPM_AUDIT_REQUIRE_RESULT=true, fails on any finding, and names the branch, +# the advisories and the fix in the step summary. Scheduled workflows # only run on the default branch. The audit needs only each line's # frontend-modern/package.json and package-lock.json, so this job reads those # two files with git and never checks out, installs or executes another diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index efa5d4e8c8..43ec755a66 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4788,17 +4788,17 @@ borrowing the local dev-runtime orchestration tests. The canonical `npm audit` after a clean install, through `scripts/npm-audit-retry.sh`. The production-only `npm audit --omit=dev` is deliberately not on that per-pull-request path: it audits a subset of the same packages, so it can only -report a subset of the same advisories, and because the complete audit fails -the job on any finding, the production step could only ever execute in the -cases where it was already guaranteed clean. The dev-versus-production split +report a subset of the same advisories under the same blocking rule, so a +production step could never block a change the complete audit lets through. The dev-versus-production split is reported instead by the scheduled `npm-audit` job in `.github/workflows/security-scan.yml`, which covers every npm workspace and informs rather than blocks delivery. That runner exists because `npm audit` exits non-zero both for a real advisory and for an unreachable advisory endpoint: on 2026-09-03 registry.npmjs.org returned 503s and timeouts for over an hour and no pull request could land, including changes that touch -no JavaScript. It separates the two and nothing else. A conclusive result is -acted on immediately and any vulnerability at any severity still fails, even +no JavaScript. It separates the two, and decides whether a finding blocks the change by +whether the change moves the dependency graph. A conclusive result is +acted on immediately and any vulnerability at any severity is a finding, even if the same response also carries a transport error, so a severity threshold must never be introduced; only an unreachable endpoint is retried. A nonempty package-finding map or any positive integer @@ -4835,13 +4835,29 @@ by attempt count or by budget, the run fails if the change touches `frontend-modern/package.json`, `frontend-modern/package-lock.json`, or the runner itself, because then the answer is genuinely unknown and the runner may never be relaxed under cover of its own tolerant mode, and warns without -failing when it does not, because the dependency graph is then identical to the base commit that -already produced a passing answer. Advisories published later against -unchanged dependencies are the responsibility of Dependabot security updates -and the scheduled scan, not of a per-pull-request audit. -`scripts/tests/test-npm-audit-retry.sh` pins -that split, including that a real advisory fails even when the tolerant mode -is active, that an unparseable or unrecognised report is never read as +failing when it does not, because the dependency graph is then identical to its +base commit. The same split governs a finding. A change that moves the graph, +or edits the runner, fails on any finding. A change that leaves +`package.json` and `package-lock.json` untouched warns instead, still printing +every finding, because any advisory it sees is one its base commit already has +and only a dependency change can remove it. Only `NPM_AUDIT_REQUIRE_RESULT=false` +selects that mode; an empty or unrecognised value keeps the strict verdict. On +3 October 2026 the unpatched `braces` advisory `GHSA-vfj7-8cjw-p6xm`, reached +only through the dev-only `tailwindcss` 3 and `jscpd` 4 chains with a clean +`--omit=dev` audit, failed the required Frontend check on every open pull +request, Go-only ones included, which made nothing safer and blocked every +fix. Advisories against unchanged dependencies are owned by the jobs that audit +the graph as it stands: the scheduled `npm-audit` scan and the daily +`dependency-advisory-watch.yml`, which keep the strict default and fail on any +finding, and Dependabot security updates; +`scripts/tests/test_dependency_advisory_watch.py` runs the real runner with the +watch's own environment and proves an advisory that only warns a pull request +fails the watch. +`scripts/tests/test-npm-audit-retry.sh` and +`scripts/tests/test_npm_audit_retry.py` pin that split, including that a real +advisory fails whenever the graph moves or the mode value is not exactly +`false`, that an inherited advisory warns and is still named, that an +unparseable or unrecognised report is never read as clean, and that neither a hung attempt nor an exhausted budget can outlive its bound. `frontend-modern/src/security/__tests__/dependencySecurity.test.ts` pins the known safe floors for advisories remediated by commit `6ba85a185`, diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 94688451c0..c7d55a6c30 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -4213,8 +4213,11 @@ func TestFrontendDependencySecurityAuditsAreRequired(t *testing.T) { ) // The gate itself must stay strict. An unreachable endpoint may be // retried, but no severity threshold may be introduced that lets a real - // advisory through. Any positive package, severity or total evidence - // must fail, even when the summary is missing or contradictory. + // advisory through. Any positive package, severity or total evidence is + // a finding, even when the summary is missing or contradictory, and it + // fails every change that moves the dependency graph. Only an explicit + // NPM_AUDIT_REQUIRE_RESULT=false, a graph identical to its base, may turn + // a finding the base already has into a warning. runnerPath := repoFile("scripts", "npm-audit-retry.sh") runner, err := os.ReadFile(runnerPath) if err != nil { @@ -4233,6 +4236,7 @@ func TestFrontendDependencySecurityAuditsAreRequired(t *testing.T) { `isinstance(report, dict) and not report.get("error")`, `len(counts) == len(count_names) and all(count == 0 for count in counts.values())`, `and ("vulnerabilities" not in report or isinstance(findings, dict))`, + `if [ "${REQUIRE_RESULT}" = "false" ]; then`, ) // Retrying must be bounded by wall clock, not by attempt count alone. // npm's own fetch-timeout defaults to five minutes and it retries diff --git a/scripts/npm-audit-retry.sh b/scripts/npm-audit-retry.sh index 95ddc1d2d9..c80d6529f8 100755 --- a/scripts/npm-audit-retry.sh +++ b/scripts/npm-audit-retry.sh @@ -10,15 +10,18 @@ # returned 503s and timeouts for over an hour and no pull request could land, # including Go-only ones. Four consecutive failures, zero advisories. # -# This keeps the gate exactly as strict about advisories — any vulnerability at -# any severity still fails, and suppression is never a valid closure — and -# changes only what happens when npm cannot answer: +# Any vulnerability at any severity is still reported, no severity threshold +# exists, and suppression is never a valid closure. What depends on the change +# is only whether the verdict blocks it: # -# * a conclusive answer is acted on immediately, pass or fail; +# * a conclusive answer is acted on immediately; # * an unreachable endpoint is retried with backoff; -# * if it is still unreachable after every attempt, the run fails when this -# change touches the dependency graph (NPM_AUDIT_REQUIRE_RESULT=true) and -# warns without failing when it does not. +# * a change that moves the dependency graph (NPM_AUDIT_REQUIRE_RESULT=true, +# or any value other than exactly "false") fails on any finding and on an +# endpoint that never answers; +# * a change that leaves the graph identical to its base +# (NPM_AUDIT_REQUIRE_RESULT=false) warns on both instead, naming every +# finding, because any finding it sees is one the base commit already has. # # The retry budget is wall-clock, not just an attempt count, because attempt # count alone does not bound anything: npm's own `fetch-timeout` defaults to @@ -30,13 +33,19 @@ # attempt is bounded, npm's internal retry loop is disabled in favour of this # one, and the whole sequence stops at a deadline. # -# That last split is the whole safety argument. When package.json and +# That split is the whole safety argument. When package.json and # package-lock.json are untouched, the audit answer for this change is the one -# the base commit already produced, so skipping it adds no risk from this -# change; advisories published later against unchanged dependencies are caught -# by Dependabot security updates, not by a per-pull-request audit. When the -# dependency graph does move, the answer is unknown and only then does an -# unreachable endpoint have to block. +# the base commit would produce, so this change can neither add a finding nor +# remove one. Failing it on an advisory the base already has blocks unrelated +# work without making anything safer: on 2026-10-03 GHSA-vfj7-8cjw-p6xm +# (braces, reached only through dev-only tailwindcss and jscpd, with no patched +# release) failed the required Frontend check on every open pull request, +# Go-only ones included. Advisories against unchanged dependencies are owned by +# the jobs that audit the graph as it stands: the scheduled npm-audit scan and +# the dependency advisory watch, which keep the strict default and fail, and +# Dependabot security updates. When the dependency graph does move, the +# change is answerable for the result, so any finding and any missing answer +# block it. # # Env: # NPM_AUDIT_ATTEMPTS attempts before giving up (default 3) @@ -45,8 +54,11 @@ # NPM_AUDIT_ATTEMPT_TIMEOUT seconds one npm invocation may run (default 60) # NPM_AUDIT_MAX_SECONDS total wall-clock budget for all attempts # (default 240) -# NPM_AUDIT_REQUIRE_RESULT "true" to fail when no answer was obtained -# (default true — the safe default) +# NPM_AUDIT_REQUIRE_RESULT "false" only when this change leaves +# package.json and package-lock.json identical to +# its base; findings and a missing answer then +# warn. Any other value fails on both (default +# true — the safe default) # NPM_AUDIT_CMD npm executable to invoke (test seam) set -uo pipefail @@ -70,6 +82,13 @@ MAX_SECONDS="${NPM_AUDIT_MAX_SECONDS:-240}" REQUIRE_RESULT="${NPM_AUDIT_REQUIRE_RESULT:-true}" NPM_BIN="${NPM_AUDIT_CMD:-npm}" +# Only an explicit "false" may relax anything. An empty or misspelled value, +# for instance from a missing workflow output, keeps the strict verdict. +graph_unchanged=false +if [ "${REQUIRE_RESULT}" = "false" ]; then + graph_unchanged=true +fi + # This script is the retry layer. npm's own fetch retry loop would multiply # every attempt by an unbounded amount of hidden waiting, which is exactly # what made a bounded-looking three attempts run for eleven minutes. @@ -240,10 +259,14 @@ while [ "${attempt}" -le "${ATTEMPTS}" ]; do ;; vulnerable) echo "npm audit (${SCOPE}): vulnerabilities present (${summary})" - echo "::error::npm audit (${SCOPE}) found vulnerabilities: ${summary}" # Keep diagnostics bound to the conclusive response, with no extra # registry request outside the attempt/total wall-clock limits. printf '%s\n' "${verdict_output}" | tail -n +3 + if [ "${graph_unchanged}" = "true" ]; then + echo "::warning::npm audit (${SCOPE}) found vulnerabilities the base commit already has: ${summary}. This change does not touch package.json or package-lock.json, so it neither introduced them nor can remove them; the scheduled audit and the dependency advisory watch keep failing until the dependency graph is fixed." + exit 0 + fi + echo "::error::npm audit (${SCOPE}) found vulnerabilities: ${summary}" exit 1 ;; *) @@ -271,10 +294,10 @@ else gave_up="after ${ATTEMPTS} attempts" fi -if [ "${REQUIRE_RESULT}" = "true" ]; then +if [ "${graph_unchanged}" != "true" ]; then echo "::error::npm audit (${SCOPE}) could not reach the advisory endpoint ${gave_up}, and this change touches the dependency graph, so the result cannot be assumed." exit 1 fi -echo "::warning::npm audit (${SCOPE}) could not reach the advisory endpoint ${gave_up}. This change does not touch package.json or package-lock.json, so the dependency graph is identical to the base commit that already passed; continuing without a fresh result." +echo "::warning::npm audit (${SCOPE}) could not reach the advisory endpoint ${gave_up}. This change does not touch package.json or package-lock.json, so the dependency graph is identical to its base commit; continuing without a fresh result." exit 0 diff --git a/scripts/tests/test-npm-audit-retry.sh b/scripts/tests/test-npm-audit-retry.sh index e005e7e9d4..165af753a7 100755 --- a/scripts/tests/test-npm-audit-retry.sh +++ b/scripts/tests/test-npm-audit-retry.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # -# Smoke tests for scripts/npm-audit-retry.sh — the gate must stay exactly as -# strict about advisories and only tolerate an unreachable endpoint. +# Smoke tests for scripts/npm-audit-retry.sh — a change that moves the +# dependency graph must fail on any advisory and on an unreachable endpoint; +# only a change whose graph is the base commit's may warn instead. set -euo pipefail @@ -79,10 +80,28 @@ run_case() { run_case "clean audit passes" 0 "$(make_fake_npm npm-clean "${CLEAN}")" true \ "no vulnerabilities" -# A vulnerability fails, and must fail even when the dependency graph is -# untouched — the outage tolerance must never soften a real finding. -run_case "vulnerability fails" 1 "$(make_fake_npm npm-vuln "${VULN}")" false \ - "vulnerabilities present" +# A vulnerability fails a change that moves the dependency graph. +run_case "vulnerability fails when dependencies changed" 1 \ + "$(make_fake_npm npm-vuln "${VULN}")" true \ + "vulnerabilities present" "::error::" + +# A change whose graph is the base commit's cannot have introduced the +# finding, so it warns and still names it rather than reading it as clean. +run_case "inherited vulnerability warns when dependencies unchanged" 0 \ + "$(make_fake_npm npm-vuln-inherited "${VULN}")" false \ + "vulnerabilities present" "::warning::" "base commit already has" + +# Only an explicit "false" relaxes the verdict. An empty or misspelled value, +# as a missing workflow output would produce, keeps it strict. +run_case "empty mode value keeps a vulnerability failing" 1 \ + "$(make_fake_npm npm-vuln-empty "${VULN}")" "" \ + "vulnerabilities present" "::error::" +run_case "unrecognised mode value keeps a vulnerability failing" 1 \ + "$(make_fake_npm npm-vuln-typo "${VULN}")" False \ + "vulnerabilities present" "::error::" +run_case "empty mode value keeps an outage failing" 1 \ + "$(make_fake_npm npm-out-empty "${ENOAUDIT}")" "" \ + "could not reach the advisory endpoint" # A transient endpoint failure that clears on retry passes. run_case "retry recovers from a transient outage" 0 \ @@ -164,7 +183,7 @@ NPM_AUDIT_ATTEMPTS=50 NPM_AUDIT_ATTEMPT_TIMEOUT=1 NPM_AUDIT_MAX_SECONDS=3 \ # only ever change what happens to an unreachable endpoint. NPM_AUDIT_ATTEMPT_TIMEOUT=1 NPM_AUDIT_MAX_SECONDS=3 \ run_case "a vulnerability still fails under a tight budget" 1 \ - "$(make_fake_npm npm-vuln-budget "${VULN}")" false \ + "$(make_fake_npm npm-vuln-budget "${VULN}")" true \ "vulnerabilities present" if [ "${failures}" -ne 0 ]; then diff --git a/scripts/tests/test_dependency_advisory_watch.py b/scripts/tests/test_dependency_advisory_watch.py index 9019c1818c..6cdd6b6beb 100644 --- a/scripts/tests/test_dependency_advisory_watch.py +++ b/scripts/tests/test_dependency_advisory_watch.py @@ -4,6 +4,7 @@ from __future__ import annotations import importlib.util import json +import os from pathlib import Path import re import subprocess @@ -202,6 +203,38 @@ class WorkflowContractTest(unittest.TestCase): self.assertEqual(step(audit, "Audit complete frontend dependency graph")["env"]["NPM_AUDIT_REQUIRE_RESULT"], "true") + def test_watch_fails_on_an_advisory_pull_requests_only_warn_on(self) -> None: + # A pull request that leaves the lockfile alone only warns on an + # advisory its base already has; this watch is where that advisory + # must fail. Run the real runner with the watch's own environment. + audit_step = next( + item for item in self.workflow["jobs"]["audit"]["steps"] + if item.get("name") == "Audit complete frontend dependency graph" + ) + vulnerable = json.dumps({ + "metadata": {"vulnerabilities": { + "info": 0, "low": 0, "moderate": 0, "high": 1, "critical": 0, "total": 1, + }}, + "vulnerabilities": {"braces": {"name": "braces", "severity": "high"}}, + }) + with tempfile.TemporaryDirectory() as directory: + fake_npm = Path(directory) / "npm" + fake_npm.write_text(f"#!/bin/sh\nprintf '%s\\n' '{vulnerable}'\nexit 1\n", encoding="utf-8") + fake_npm.chmod(0o755) + outcomes = {} + for mode in (audit_step["env"]["NPM_AUDIT_REQUIRE_RESULT"], "false"): + result = subprocess.run( + ["bash", str(ROOT / "scripts" / "npm-audit-retry.sh"), "all"], + env={**os.environ, "NPM_AUDIT_CMD": str(fake_npm), + "NPM_AUDIT_REQUIRE_RESULT": mode, "NPM_AUDIT_RETRY_DELAY": "0"}, + text=True, capture_output=True, check=False, + ) + outcomes[mode] = result + self.assertEqual(1, outcomes["true"].returncode, outcomes["true"].stdout) + self.assertIn("::error::", outcomes["true"].stdout) + self.assertEqual(0, outcomes["false"].returncode, outcomes["false"].stdout) + self.assertIn("::warning::", outcomes["false"].stdout) + def test_checkout_pins_match_build_and_test(self) -> None: pins = set(re.findall(r"uses: (\S+@[0-9a-f]{40})", BUILD_AND_TEST.read_text(encoding="utf-8"))) for pin in re.findall(r"uses: (\S+@\S+)", self.text): diff --git a/scripts/tests/test_npm_audit_retry.py b/scripts/tests/test_npm_audit_retry.py index 667a4754ff..3bcb17c7cb 100755 --- a/scripts/tests/test_npm_audit_retry.py +++ b/scripts/tests/test_npm_audit_retry.py @@ -146,8 +146,29 @@ class NpmAuditRetryTest(unittest.TestCase): ["audit --json"], ) self.assertIn("vulnerabilities present", result.stdout) + self.assertIn("::error::", result.stdout) self.assertNotIn("retrying", result.stdout) + def test_inherited_vulnerability_warns_for_an_unchanged_dependency_graph(self) -> None: + result, calls = self.run_check("vulnerability", "all", require="false") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(calls, ["audit --json"]) + self.assertIn("vulnerabilities present", result.stdout) + self.assertIn("::warning::", result.stdout) + self.assertIn("base commit already has", result.stdout) + self.assertNotIn("::error::", result.stdout) + self.assertNotIn("no vulnerabilities", result.stdout) + self.assertNotIn("retrying", result.stdout) + + def test_only_an_explicit_false_relaxes_a_vulnerability(self) -> None: + for require in ["", "False", "0", "no", "true"]: + with self.subTest(require=require): + result, calls = self.run_check("vulnerability", "all", require=require) + self.assertEqual(result.returncode, 1, result.stdout) + self.assertEqual(calls, ["audit --json"]) + self.assertIn("::error::", result.stdout) + self.assertNotIn("::warning::", result.stdout) + def test_vulnerability_verdict_precedes_a_transport_error(self) -> None: result, calls = self.run_check("vulnerability-with-error", "all") self.assertEqual(result.returncode, 1) @@ -188,10 +209,13 @@ class NpmAuditRetryTest(unittest.TestCase): }, "error": {"detail": "must not be printed"}, } + # An inherited finding only warns, but it must be named exactly as a + # blocking one would be. result, calls = self.run_check( "captured-vulnerability", "all", require="false", report=report ) - self.assertEqual(result.returncode, 1) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("::warning::", result.stdout) self.assertEqual(calls, ["audit --json"]) finding_lines = [ line.removeprefix("audit finding ") @@ -241,7 +265,7 @@ class NpmAuditRetryTest(unittest.TestCase): self.assertNotIn("\n::error::injected", result.stdout) self.assertNotIn("\x1b", result.stdout) - def test_package_findings_fail_even_when_summary_is_missing_or_zero(self) -> None: + def test_package_findings_count_even_when_summary_is_missing_or_zero(self) -> None: for metadata in [None, {}, {"vulnerabilities": {"total": 0}}]: for require in ["true", "false"]: with self.subTest(metadata=metadata, require=require): @@ -255,30 +279,35 @@ class NpmAuditRetryTest(unittest.TestCase): result, calls = self.run_check( "captured-vulnerability", "all", require=require, report=report ) - self.assertEqual(result.returncode, 1, result.stdout) + self.assertEqual( + result.returncode, 1 if require == "true" else 0, result.stdout + ) self.assertEqual(calls, ["audit --json"], result.stdout) self.assertIn("vulnerabilities present", result.stdout) self.assertIn("package_records=1", result.stdout) self.assertNotIn("no vulnerabilities", result.stdout) - def test_positive_severity_counts_fail_without_a_consistent_total(self) -> None: + def test_positive_severity_counts_count_without_a_consistent_total(self) -> None: for severity in ["info", "low", "moderate", "high", "critical"]: for total in [None, 0]: - with self.subTest(severity=severity, total=total): - counts = {severity: 1} - if total is not None: - counts["total"] = total - report = { - "metadata": {"vulnerabilities": counts}, - "error": {"code": "ETIMEDOUT"}, - } - result, calls = self.run_check( - "captured-vulnerability", "production", - require="false", report=report, - ) - self.assertEqual(result.returncode, 1, result.stdout) - self.assertEqual(calls, ["audit --json --omit=dev"], result.stdout) - self.assertIn("vulnerabilities present", result.stdout) + for require in ["true", "false"]: + with self.subTest(severity=severity, total=total, require=require): + counts = {severity: 1} + if total is not None: + counts["total"] = total + report = { + "metadata": {"vulnerabilities": counts}, + "error": {"code": "ETIMEDOUT"}, + } + result, calls = self.run_check( + "captured-vulnerability", "production", + require=require, report=report, + ) + self.assertEqual( + result.returncode, 1 if require == "true" else 0, result.stdout + ) + self.assertEqual(calls, ["audit --json --omit=dev"], result.stdout) + self.assertIn("vulnerabilities present", result.stdout) def test_malformed_zero_verdicts_cannot_pass_as_clean(self) -> None: zero_counts = dict.fromkeys( @@ -340,10 +369,12 @@ class NpmAuditRetryTest(unittest.TestCase): self.assertIn("no vulnerabilities", result.stdout) def test_persistent_outage_fails_when_a_result_is_required(self) -> None: - result, calls = self.run_check("transient-failure", "all") - self.assertEqual(result.returncode, 1) - self.assertEqual(calls, ["audit --json"] * 3) - self.assertIn("could not reach", result.stdout) + for require in ["true", "", "False"]: + with self.subTest(require=require): + result, calls = self.run_check("transient-failure", "all", require=require) + self.assertEqual(result.returncode, 1) + self.assertEqual(calls, ["audit --json"] * 3) + self.assertIn("could not reach", result.stdout) def test_persistent_outage_warns_for_an_unchanged_dependency_graph(self) -> None: result, calls = self.run_check( From ae0158ba6038c70c851f2255199ea31a39a45451 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:25:59 +0100 Subject: [PATCH 3/6] Show the last backup in Proxmox coverage; keep tables whole on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds #2435 as one commit on current main. Its branch conflicted with the Tailwind 4 move (#2443) in a host-table test; the three original commits' reasons follow. Name the last backup in Proxmox coverage, not the newest snapshot: The Coverage table's Restore column showed the newest of every artifact, including guest snapshots. A VM with a snapshot from last night and a PBS backup from three weeks ago read "Restore 18h ago" in the healthy green beside a "Needs attention" posture, and the only way to see why was the row's hover text. Guest snapshots share the guest's storage; the posture engine already refuses to count them ("snapshots alone do not prove independent recovery"), so the row contradicted the server. The column is now Last backup: the newest completed PBS snapshot or PVE backup file. Snapshot-only rows show None and carry the engine's own reason on hover, the guest snapshot keeps its dedicated column, and the sort and the backup-location filter follow the same rule. The location filter also stops treating running or failed artifacts as a latest pointer, which the unfiltered model already did. Keep Proxmox backup and node tables whole on phones: On a 390px phone the Proxmox tables clipped the values users came for. The nodes table showed six "Anal…" rows for the Analytics A–F cluster: a sixth column (temperature) left the node name four characters wide with the toggle and status dot in front of it. The backup server row showed "H…" for Healthy, and the coverage and By date rows clipped "18h ago" to "18 …" and "Running" to "Runni…". Nodes now carry the same five columns as the guest table beneath them with a wider identity cell; temperature returns from 440px, where it fits. Backup ages use a suffix-free compact form ("18h") with the full timestamp on hover. The server row keeps its status word and used percentage whole and lets the backup count return at the next width, and its stale header width classes no longer fight the colgroup. The By date row keeps its source and state badges whole while the repository moves to the row's hover title. Just above the phone projection the servers' used/total pair and the coverage posture word now fit, and at desktop width "Automation Lab Standalone" no longer truncates in the nodes table. Measured with a scrollWidth-over-clientWidth sweep of every table cell at 1440px, 560px and 390px; only identity strings still clip, each with its full text on hover. Name the archive format in By date and widen phone guest names: The By date table's Details cell showed a PVE backup's full volid, "backup-vault:backup/vzdump-qemu-1002-2026_10_02-20_10_14.vma.zst", which repeats the storage, guest type, VMID, and timestamp the row already shows in their own columns and clipped in every one of its rows on a 1440px screen. The one fact it adds is the archive format, so the cell now reads "vma.zst" or "tar.zst", the volid stays on hover and in search, and a provider with no format falls back to the file name. The coverage disclosure's evidence table follows suit. The shared guest table's phone name cell carries the expansion toggle, status dot, and backup badge before the name, so the shared 30% identity anchor left a 390px phone 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". It now takes the same 38% anchor the Proxmox host table uses, which gives the name 95px while the four metric chips beside it still fit. Measured with a scrollWidth-over-clientWidth sweep at 1440px, 560px and 390px: the By date table has no clipped cell at desktop width, and on phones only long identity strings still clip. Contract-Neutral: Proxmox coverage, phone and mid-width column projections, By date Details and guest-table phone identity width are presentation only; owning unified-resources and storage-recovery notes updated; no workloads hot-path or frontend-primitives change --- .../internal/subsystems/storage-recovery.md | 46 ++++-------- .../internal/subsystems/unified-resources.md | 17 +++++ .../b87770c76dcc9d03.json | 62 ++++++++++++++++ .../Workloads/__tests__/GuestRow.test.tsx | 21 +++--- .../components/Workloads/guestRowModel.tsx | 15 +++- .../proxmox/ProxmoxBackupServersTable.tsx | 32 ++++----- .../features/proxmox/ProxmoxBackupsTable.tsx | 14 ++-- .../features/proxmox/ProxmoxCoverageTable.tsx | 39 +++++++++-- .../proxmox/ProxmoxRecoverableTable.tsx | 10 ++- .../__tests__/ProxmoxCoverageTable.test.tsx | 4 +- .../ProxmoxRecoverableTable.test.tsx | 5 +- ...oxmoxBackupRecoveryModel.branchcov.test.ts | 2 +- .../proxmoxBackupRecoveryModel.test.ts | 70 ++++++++++++++++++- .../proxmoxBackupsTablePresentation.test.ts | 7 +- .../proxmoxBackupsTableShared.test.tsx | 24 +++++++ .../__tests__/proxmoxHostTableModel.test.ts | 12 ++-- .../proxmox/proxmoxBackupRecoveryModel.ts | 35 ++++++++-- .../proxmoxBackupsTablePresentation.ts | 28 +++++--- .../proxmox/proxmoxBackupsTableShared.tsx | 24 +++++-- .../features/proxmox/proxmoxHostTableModel.ts | 33 +++++---- 20 files changed, 379 insertions(+), 121 deletions(-) create mode 100644 frontend-modern/browser-verification/b87770c76dcc9d03.json diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 97ffabafe7..8a656321d0 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2819,6 +2819,20 @@ transport-induced absence. Preserving this read-only evidence adds no backup, restore, retention, or recovery authority. The behavior is pinned by `useUnifiedResources.test.ts` and `useWorkloads.test.ts`. +### Coverage rows name the last backup, not the newest restore point + +The Proxmox coverage row's `Last backup` cell is the newest completed PBS +snapshot or PVE backup file. A guest snapshot never fills it: it shares the +guest's own storage, and the protection posture engine already refuses to count +snapshots as independent recovery, so a fresh snapshot beside a stale posture +must not read as a fresh backup. Snapshot-only rows show `None` and carry the +engine's own reason on hover; the guest snapshot keeps its dedicated column and +the restore-evidence disclosure. The backup-location filter re-derives that +pointer from completed, non-snapshot artifacts under the same rule. This is a +presentation and sort change only; it adds no backup, restore, or retention +authority. Pinned by `proxmoxBackupRecoveryModel.test.ts` and +`ProxmoxCoverageTable.test.tsx`. + ### Retained Patrol objectives do not create recovery authority The shared `internal/api` retained-objective endpoints may scope an outcome to @@ -6302,35 +6316,3 @@ This changes no collector, health thresholds, history policy or server identity. `frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` exercises snapshot replacement, in-place reconciliation, missing evidence, target changes and fault/recovery using the real keyed renderer. - -Touch inspection in the existing pool and physical-disk History charts selects -an actual stored reading rather than substituting the latest sample on focus. -Page pan/cancel and multi-contact gestures do not imply a selected observation; -browser-owned scrolling remains available. This changes no source target, API, -units, collection, retention, access or recovery boundary. The production -disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted -`HistoryChart.test.tsx` regressions verify presentation/input behaviour only, -not live appliance collection or recovery success. - -### Pool-to-physical-disk ownership in Storage details - -`storagePoolDetailPresentation.ts` must scope inferred ZFS device and UnRAID -array/cache-group membership to the owning host. An explicit disk parent equal -to the canonical pool ID or its retained resource reference remains a direct -association. Otherwise, matching canonical host parents establishes scope; -conflicting parents cannot be overridden by names, paths or group labels. -When canonical parents are unavailable, only a complete matching native -Proxmox instance/node pair establishes scope. `storageAdapters.ts` retains that -instance alongside the native node. Display names and unscoped hostnames alone -are insufficient. Unknown ownership must not present another host's SMART, -temperature, counters or topology counts as this pool's evidence. - -ZFS device matching compares full identifiers with only the optional `/dev/` -prefix normalized, not arbitrary suffixes or invented partition associations. -This is read-only presentation: no collector, canonical server identity, -metrics target, health threshold, persistence or recovery authority changes. -`storagePoolDiskOwnership.test.ts`, the mounted -`StoragePoolDetail.ownership.test.tsx` and `browser-tests/pool-ownership.cjs` -cover repeated paths/groups, direct children, scoped legacy snapshots, missing -ownership and live target replacement. Synthetic presentation proof does not -establish native appliance, installation or recovery acceptance. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae311147f4..8106739eb7 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -842,6 +842,23 @@ the daemon tally as width shrinks, keeps one five-value phone projection (cluster, health, OSDs, pools, capacity) whose values each fit whole, and leaves quorum membership and the FSID to the cluster disclosure, where the FSID stays searchable and is shown in full. +The Proxmox host table drops temperature from its phone projection +(container widths under 440px) so the node name, expansion toggle, and +status dot share the same five-column shape as the guest table beneath it, +and the identity cell takes a wider phone anchor than the shared default +because of that leading toggle. `ProxmoxBackupServersTable`, +`ProxmoxCoverageTable`, and `ProxmoxRecoverableTable` keep their phone +projections to values that fit whole: the server row keeps its status word +and used percentage and defers the backup count to the next width, backup +ages use a suffix-free compact form with the timestamp on hover, and the +recoverable row keeps its source and state badges while the repository +location moves to the row title. Header width classes must not compete with +the colgroup those tables size through the shared weighted helper. +The shared guest table's phone name cell carries the same leading toggle, +status dot, and backup badge, so it takes the same wider phone anchor. The +recoverable row's Details cell names the PVE archive format rather than the +volid, which repeats the storage, guest type, VMID, and timestamp the row +already shows in their own columns; the volid stays on hover and in search. Optional numeric table cells follow the same split: unified-resource consumers own which count or replica field is meaningful, whether the domain should zero-default an absent scheduler/service/inventory count, whether a diff --git a/frontend-modern/browser-verification/b87770c76dcc9d03.json b/frontend-modern/browser-verification/b87770c76dcc9d03.json new file mode 100644 index 0000000000..af97241487 --- /dev/null +++ b/frontend-modern/browser-verification/b87770c76dcc9d03.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "base_sha": "1f140a5d380c20dc5eaee14cde07666b75436a9d", + "verified_at": "2026-10-03T20:25:42Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/guestRowModel.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/guestRowModel.tsx": "7787854145257e13bc347c2513f89e595060e606857a9a7c4ae6c0a897b6c639", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b791a50845befbdcfdca979355188fb1e977878ce69e8a72cbbdf90fda5e8cbe", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "97a95f97c94795005300fe55fdca81756b6ce9f11e977d1a3f683775eb0dc88d", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "f85558ab1092078b9a3d160c986450d2167552b10b2f76c10087fceef5af0689", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "1dc95dd84f499e38a8fca77650ae72ff6c903b7c03c8fdc3cf366b309817e488", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "b872437fb4cfb61b07e0c949ae6f323d6fd3f2622e57480931f7ef74fafe79c2", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "0037e6f97e1180ef0c0d585ec5e9385dcbe4c4e1b51a7bc29d8631b4b0e87745", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "41202d1ae6095d882b124a55aed4110c74f38047d51b83b07bf52a6e95ca6b01", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "908af893071386d1a11a7191694b9fef399e4b134e78188bee90faa80f758e91" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/backups/date", + "/proxmox/backups/coverage" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 560, + "height": 900 + }, + { + "width": 440, + "height": 900 + }, + { + "width": 390, + "height": 900 + } + ], + "states": [ + "PR #2435 rebuilt as one commit on origin/main 1f140a5d38 (after Tailwind 4, #2443/#2444), checked on a private mock stack built from main with Vite serving this tree, light at every width and dark at 1440 and 390", + "Overview: the nodes table shows Node, CPU, Mem, Disk, Age at 390 and 440 with no clipped cell and adds Temp from 560; the guests table phone name cell is 95px wide (artifact-cache-310 needs 101px, so long mock names still truncate by a few characters); no table scrolls horizontally", + "Backups By date at 1440: Details reads vma.zst or the PBS file count, zero clipped cells in either table; at 560/440/390 only workload and datastore identity strings clip", + "Backups Coverage at 1440: the column is Last backup with ages such as 9d ago and the timestamp in the hover title; only London Edge Standalone clips; at 440/390 the row shows Workload, Posture, Age, PBS, Job and the only clipped hits are sr-only task labels; the mock emitted no snapshot-only workload, so the None state is covered by unit tests rather than seen live" + ], + "interactions": [ + "scrollWidth-over-clientWidth sweep of every visible table cell on the three routes at 1440, 560, 440 and 390, then DOM probes to separate sr-only spans from real clipping", + "Read the Last backup and Details column values and hover titles across all rows at 1440", + "vitest for src/features/proxmox, src/components/Workloads and src/features/platformPage on the rebuilt tree: 125 files, 2192 tests passed" + ] +} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 6e72824233..d6fb813c6b 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => { }); it('derives mobile overrides from the canonical guest column model', () => { + // The phone name cell carries the toggle, status dot, and backup badge, so + // it takes a wider anchor than the shared 30% and the metric chips share + // the rest. expect(getGuestColumnStyle('name', true)).toEqual({ - width: '30%', - 'max-width': '30%', + width: '38%', + 'max-width': '38%', }); expect(getGuestColumnStyle('cpu', true)).toEqual({ - width: '11.3235%', - 'max-width': '11.3235%', + width: '10.0294%', + 'max-width': '10.0294%', }); expect(getGuestColumnStyle('availability', true)).toEqual({ - width: '7.2059%', - 'max-width': '7.2059%', + width: '6.3824%', + 'max-width': '6.3824%', }); expect(getGuestColumnStyle('type', true)).toEqual({ - width: '9.2647%', - 'max-width': '9.2647%', + width: '8.2059%', + 'max-width': '8.2059%', }); - expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' }); + expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' }); expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' }); }); diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 40b81bf47e..0c7c67ea17 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({ maxWidth: width, }); +// The phone name cell carries the expansion toggle, status dot, and backup +// badge before the name, so the shared 30% identity anchor left a 390px phone +// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The +// four metric chips beside it need far less than an even split, so the name +// takes the same wider phone anchor the Proxmox host table uses. +const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + // Responsive weights are normalized against the currently visible column set. // That keeps each workload view mode full-width without assuming one fixed set. const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -548,9 +555,11 @@ const getResponsiveColumnOverride = ( activeIds, layoutMode === 'narrow' ? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); return typeof style.width === 'string' ? percentageColumn(style.width) : undefined; }; diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 0ed2264d9a..ec407b7b8c 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: { } header={ <> - + - + @@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: { - + - + Used @@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: { > {(datastore) => (
- + + + diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index ef84f0d194..f90874b0d5 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -43,6 +43,7 @@ import { import { buildProxmoxBackupRecoveryModel, coverageRowMatchesSearch, + isBackupArtifact, recoverableArtifactMatchesSearch, type RecoverableArtifact, type WorkloadCoverageRow, @@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{ return recoveryModel().coverageRows.flatMap((row) => { const artifacts = row.artifacts.filter(locationMatches); if (artifacts.length === 0) return []; + // Same rule as the model: running or failed artifacts stay listed but + // never become a "latest" pointer, and snapshots never become the backup. + const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed); return [ { ...row, artifacts, - latestRecovery: newestArtifact(artifacts), - latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')), + latestBackup: newestArtifact(completed.filter(isBackupArtifact)), + latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')), latestArchive: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'archive'), + completed.filter((artifact) => artifact.sourceKind === 'archive'), ), latestSnapshot: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'), + completed.filter((artifact) => artifact.sourceKind === 'snapshot'), ), pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length, archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length, @@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{ case 'workload': return cmpString(a.workload.label, b.workload.label, direction); case 'latest': - return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction); + return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction); case 'pbs': return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction); case 'archive': diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index dcafa1e9ae..ce0c4e3d86 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing // "None" instead of restating the source ("No PVE backup file"). The long form // truncated at most widths and stays available as the hover title. const COVERAGE_EMPTY_LABEL = 'None'; +const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file'; +// Matches the posture engine's own wording for a snapshot-only subject. +const COVERAGE_SNAPSHOT_ONLY_TITLE = + 'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.'; const coveragePostureVariant = ( posture: WorkloadCoverageRow['posture'], @@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: { headClass={getPlatformTableHeadClassForKind('text')} /> + {COVERAGE_EMPTY_LABEL} } > - {(artifact) => } + {(artifact) => ( + + )} @@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: { } > - {(artifact) => } + {(artifact) => ( + + )} @@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: { > {artifact.detail || '—'} diff --git a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx index 367d630696..f5a514a569 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx @@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: { artifact.workload.name || artifact.workload.label, artifact.workload.typeLabel, artifact.workload.vmid, + // The phone projection has no location column, so the row's + // hover title carries the repository instead. + columnVisible('location') ? undefined : artifact.location, ] .filter(Boolean) .join(' · ')} @@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: { - + @@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: { - + {artifact.detail || '—'} diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx index 50adeb3dd8..b9b7e75eaf 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx @@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => { expect(headers).toContain('Target ID'); expect(headers).toContain('Node'); expect(headers).toContain('Posture▲'); - expect(headers).toContain('Restore'); + expect(headers).toContain('Last backup'); expect(headers).toContain('PBS snapshot'); expect(headers).toContain('Guest snapshot'); expect(headers).not.toContain('PVE file'); @@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => { // cannot truncate to "No PVE backup f…". const empty = (title: string) => document.querySelector(`td span[title="${title}"]`); for (const title of [ - 'No restore point', + 'No PBS snapshot or PVE backup file', 'No PBS snapshot', 'No PVE backup file', 'No guest snapshot', diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx index 0dea167612..a8c0248ff5 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx @@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => { expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([ 'Workload', 'Via', - 'Loc', 'Age▼', 'State', ]); expect(document.body.textContent).toContain('VM 100'); - expect(document.body.textContent).toContain('main / pve1'); + // The phone projection has no location column; the row title carries it. + expect(document.body.textContent).not.toContain('main / pve1'); + expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull(); expect(document.body.textContent).not.toContain('5 PBS files'); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts index 8ae3acda04..6ab7884a78 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts @@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () => expect(model.coverageSummary.attention).toBe(0); }); - it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => { + it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => { const postures = new Map([ ['vm-830', posture('vm-830', 'unprotected')], ['vm-840', posture('vm-840', 'protected')], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts index a7e222ac29..43fc7e5a41 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts @@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => { ); }); + it('shows the archive format as its detail and keeps the volid on hover and in search', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: 'tar.zst' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const [artifact] = model.recoverableArtifacts; + expect(artifact.detail).toBe('tar.zst'); + expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true); + }); + + it('falls back to the archive file name when the provider reports no format', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: '' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + }); + it('keeps a terminally incomplete PBS artifact out of latest recovery', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], @@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => { const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed')); expect(failed).toMatchObject({ failed: true, running: false, verified: undefined }); - expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z'); + expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z'); expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true); }); + it('keeps a fresh guest snapshot from standing in for the last backup', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })], + archives: [], + snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z'); + expect(row.latestBackup?.sourceKind).toBe('pbs'); + expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z'); + expect( + getRecoveryAgeBand( + row.latestBackup?.createdMs, + model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'), + ), + ).toBe('aging'); + }); + + it('reports no last backup for a snapshot-only workload', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [], + snapshots: [snapshot()], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestBackup).toBeUndefined(); + expect(row.latestSnapshot?.sourceKind).toBe('snapshot'); + expect(row.artifacts).toHaveLength(1); + expect(model.coverageSummary.recoverableArtifacts).toBe(1); + }); + it('uses canonical workload attention while retaining the failed task as evidence', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts index 8817787792..a52bd7c075 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts @@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => { 'status', 'datastore', 'used', - 'backups', ]); expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' }); - expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' }); + expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' }); + expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' }); expect(ids(getBackupServerColumns('basic'))).toEqual([ 'server', 'status', @@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => { expect(ids(getRecoverableColumns('compact'))).toEqual([ 'workload', 'source', - 'location', 'created', 'state', ]); expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' }); - expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' }); + expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' }); expect(ids(getRecoverableColumns('basic'))).toEqual([ 'workload', 'source', diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx index 64f5895ce9..4ffe9073bd 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx @@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra import { ArtifactSourceBadge, ArtifactStateBadge, + ProxmoxBackupAgeText, ProxmoxBackupWorkloadTypeBadge, SortableHead, + formatCompactBackupAge, } from '../proxmoxBackupsTableShared'; import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel'; @@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => { } }); + it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => { + const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString(); + expect(formatCompactBackupAge(createdAt)).toBe('18h'); + expect(formatCompactBackupAge(new Date().toISOString())).toBe('now'); + + const artifact = { + id: 'pbs:1', + nativeId: '1', + sourceKind: 'pbs', + sourceLabel: 'PBS', + workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' }, + createdAt, + createdMs: Date.parse(createdAt), + location: 'main', + detail: '', + protected: false, + } as RecoverableArtifact; + render(() => ); + const age = screen.getByText('18h'); + expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt); + }); + it('keeps backup age text on the shared relative-time primitive', () => { expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue'); expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime('); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts index 66ee4d61d0..1b587b374a 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => { expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' }); }); - it('shows the full operational metric track at phone width', () => { + it('matches the guest table columns and keeps node names whole at phone width', () => { const columns = getProxmoxHostVisibleColumnsForLayout('phone'); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']); + expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']); expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-0'); - expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' }); - expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' }); - expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' }); - expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' }); + expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' }); + expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' }); }); it('prioritizes live utilization columns in the mobile host table', () => { diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts index c71846a6ae..b4288cf9f6 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts @@ -47,6 +47,8 @@ export interface RecoverableArtifact { /** Operator-facing source/repository label used by the backup-location filter. */ locationLabel?: string; detail: string; + /** Full provider identifier behind a shortened `detail`, shown on hover. */ + detailTitle?: string; protected: boolean; verified?: boolean; /** A still-running backup is writing this artifact; it is not recoverable yet. */ @@ -70,7 +72,13 @@ export interface WorkloadCoverageRow { key: string; workload: WorkloadReference; artifacts: RecoverableArtifact[]; - latestRecovery?: RecoverableArtifact; + /** + * Newest completed PBS snapshot or PVE backup file. Guest snapshots never + * fill this slot: they live on the guest's own storage and the protection + * posture engine does not count them as independent recovery, so a fresh + * snapshot must not read as a fresh backup beside a stale posture. + */ + latestBackup?: RecoverableArtifact; latestPBS?: RecoverableArtifact; latestArchive?: RecoverableArtifact; latestSnapshot?: RecoverableArtifact; @@ -138,12 +146,25 @@ export function getRecoveryAgeBand( return 'stale'; } +// A guest snapshot shares the guest's storage, so it is listed as restore +// evidence but never counted as a backup. Mirrors the server posture rule that +// "snapshots alone do not prove independent recovery". +export function isBackupArtifact(artifact: RecoverableArtifact): boolean { + return artifact.sourceKind !== 'snapshot'; +} + function parseTimestampMs(value: string | undefined): number | undefined { if (!value) return undefined; const ms = Date.parse(value); return Number.isFinite(ms) ? ms : undefined; } +function archiveFileName(volid: string | undefined): string { + const trimmed = volid?.trim() ?? ''; + if (!trimmed) return ''; + return trimmed.slice(trimmed.lastIndexOf('/') + 1); +} + function pbsBackupFileDetailLabel(fileCount: number): string { if (fileCount <= 0) return 'PBS files not listed'; if (fileCount === 1) return '1 PBS file'; @@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel( size: archive.size, location: archive.storage || archive.node || '—', ...artifactLocationScope('archive', archive.instance, archive.storage || archive.node), - detail: archive.volid || archive.format || archiveSource.detailFallbackLabel, + // The volid repeats the storage, guest type, VMID, and timestamp the row + // already shows in their own columns and clips at every width; the + // archive format is the one fact it adds, so that is the cell and the + // volid stays on hover and in search. + detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel, + detailTitle: archive.volid || undefined, protected: archive.protected, verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined, running: archive.inProgress === true, @@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel( // the "latest" pointers (which answer "what can I recover to?") skip them. // The artifacts themselves stay listed with an honest state. const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed); + row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs); row.latestPBS = newest( completed.filter((artifact) => artifact.sourceKind === 'pbs'), (artifact) => artifact.createdMs, @@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel( completed.filter((artifact) => artifact.sourceKind === 'snapshot'), (artifact) => artifact.createdMs, ); - row.latestRecovery = newest(completed, (artifact) => artifact.createdMs); } const coverageRows = Array.from(rows.values()).map((row) => { @@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel( coverageRows.sort((left, right) => { if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank; - return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0); + return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0); }); artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0)); @@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch( artifact.sourceTitle, artifact.location, artifact.detail, + artifact.detailTitle, artifact.verified === true ? 'verified' : artifact.verified === false diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts index b1342d3396..f0ad8ec765 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts @@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record< > = { // Reachability and datastore exhaustion are the two risks that can stop all // future backups. Host telemetry and space-efficiency context return only as - // the table gains enough room to keep those headline answers readable. - compact: ['server', 'status', 'datastore', 'used', 'backups'], + // the table gains enough room to keep those headline answers readable. The + // phone projection keeps those two answers whole ("Healthy", "33.6%") and + // lets the backup count return with the first wider layout. + compact: ['server', 'status', 'datastore', 'used'], basic: ['server', 'status', 'datastore', 'used', 'backups'], operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'], expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'], @@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 }, - basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 }, + compact: { server: 40, status: 23, datastore: 22, used: 15 }, + // Just above the phone projection the used cell carries its used/total + // pair again, the longest value in the row, so it takes the most room. + basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 }, operational: { server: 20, status: 12, @@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] = { id: 'targetId', label: 'Target ID', kind: 'text' }, { id: 'node', label: 'Node', kind: 'text' }, { id: 'posture', label: 'Posture', kind: 'text' }, - { id: 'latest', label: 'Restore', kind: 'numeric-value' }, + { id: 'latest', label: 'Last backup', kind: 'numeric-value' }, { id: 'pbs', label: 'PBS snapshot', kind: 'text' }, { id: 'archive', label: 'PVE file', kind: 'text' }, { id: 'snapshot', label: 'Guest snapshot', kind: 'text' }, @@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 }, + // Posture keeps its whole compact word ("Unknown"), the two age cells hold + // the suffix-free compact age, and the job dot takes what a "Job" header + // needs. + compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 }, // Above the phone projection the identity columns (workload, node) take the // slack that the short type badge, target id, and age cells cannot use, so // names stay whole instead of truncating beside empty space. - basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 }, + basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 }, operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 }, expanded: { workload: 19, @@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record getRecoveryAgeBand(props.artifact.createdMs); const title = () => { const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean); @@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) { return ( - + } + > + {formatCompactBackupAge(props.artifact.createdAt)} + ); } diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index 76ce153aa8..be6aaafe31 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record = { - node: 18, - version: 7, + node: 20, + version: 6, uptime: 7, cpu: 13, memory: 13, @@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record = { temp: 6, vms: 5, cts: 5, - cluster: 13, + cluster: 12, }; const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< disk: 15, uptime: 15, }, + // Phones carry the same five columns as the guest table below them. A sixth + // (temperature) left the node name with four characters, which made a + // cluster of "Analytics A".."F" rows unreadable. phone: { node: 32, - cpu: 13, - memory: 13, - disk: 13, - temp: 15, - uptime: 14, + cpu: 15, + memory: 15, + disk: 15, + uptime: 15, }, mobile: { node: 32, @@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = ( ); }; +// The node cell carries the expansion toggle and status dot before the name, +// so it needs more than the shared 30% phone identity anchor to show a +// typical node name whole. +const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + export const getProxmoxHostColumnWidthStyle = ( columnId: ProxmoxHostTableColumnId, layoutMode: WorkloadTableLayoutMode, @@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = ( visibleColumnIds, layoutMode === 'narrow' ? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); }; From 7d6b80dd3c1c89b67952034ec3d6905cb0418c42 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:43:46 +0100 Subject: [PATCH 4/6] Carry main's Patrol header note in api-contracts before merging main PR #2445 and this branch both appended a section to the end of api-contracts.md. Adding main's section here, ahead of this branch's, lets origin/main merge without a conflict the commit hooks cannot conclude; the merged file keeps both notes. --- .../v6/internal/subsystems/api-contracts.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index e8109e21cf..70e3a74fcc 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -11077,14 +11077,6 @@ boundaries and failures; `infrastructureAgentDoctorModel.test.ts` exercises the real lifecycle closures. These are modelled installer/privilege probes, not native system installation or publication. -### Pulse Mobile pairing readiness wording - -The onboarding readiness diagnostics for a disabled or unconnected relay name -the renamed Settings > Pulse Mobile section instead of Remote Access. Codes, -severities and the response shape are unchanged. The API reference heading -for the relay protocol records that Pulse Mobile retires on 31 March 2027, and -the published copy stays identical to `docs/API.md`. - ### AI usage export carries prompt-cache buckets `GET /api/ai/cost/export` now includes `cache_creation_input_tokens` and @@ -11097,3 +11089,19 @@ unchanged; the chat service adapter passes the same buckets through the Patrol stream response without changing its request shape. `ai_handlers_more_test.go` covers the export in both formats with a cached Anthropic event. + +### Pulse Mobile pairing readiness wording + +The onboarding readiness diagnostics for a disabled or unconnected relay name +the renamed Settings > Pulse Mobile section instead of Remote Access. Codes, +severities and the response shape are unchanged. The API reference heading +for the relay protocol records that Pulse Mobile retires on 31 March 2027, and +the published copy stays identical to `docs/API.md`. + +### Patrol attention detail header pinning + +Pinning the Patrol attention detail header on phones +(`frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx`) is a +layout-only change. It reads no new attention fields, sends no new request, +and leaves the attention projection, lifecycle, and queue ordering contracts +above unchanged. From 47e947c207cee3156926d1341ebf82ce7d3cd1fc Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:49:45 +0100 Subject: [PATCH 5/6] Restore two storage-recovery notes this rebuild dropped The original PR 2435 branch carried a stale copy of storage-recovery.md and deleted main's History touch-inspection and pool-to-disk ownership notes, which are unrelated to this change. The squashed rebuild inherited the deletion; this puts both notes back so the change only adds its own coverage clause. --- .../internal/subsystems/storage-recovery.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8a656321d0..cce59abd96 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6316,3 +6316,35 @@ This changes no collector, health thresholds, history policy or server identity. `frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` exercises snapshot replacement, in-place reconciliation, missing evidence, target changes and fault/recovery using the real keyed renderer. + +Touch inspection in the existing pool and physical-disk History charts selects +an actual stored reading rather than substituting the latest sample on focus. +Page pan/cancel and multi-contact gestures do not imply a selected observation; +browser-owned scrolling remains available. This changes no source target, API, +units, collection, retention, access or recovery boundary. The production +disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted +`HistoryChart.test.tsx` regressions verify presentation/input behaviour only, +not live appliance collection or recovery success. + +### Pool-to-physical-disk ownership in Storage details + +`storagePoolDetailPresentation.ts` must scope inferred ZFS device and UnRAID +array/cache-group membership to the owning host. An explicit disk parent equal +to the canonical pool ID or its retained resource reference remains a direct +association. Otherwise, matching canonical host parents establishes scope; +conflicting parents cannot be overridden by names, paths or group labels. +When canonical parents are unavailable, only a complete matching native +Proxmox instance/node pair establishes scope. `storageAdapters.ts` retains that +instance alongside the native node. Display names and unscoped hostnames alone +are insufficient. Unknown ownership must not present another host's SMART, +temperature, counters or topology counts as this pool's evidence. + +ZFS device matching compares full identifiers with only the optional `/dev/` +prefix normalized, not arbitrary suffixes or invented partition associations. +This is read-only presentation: no collector, canonical server identity, +metrics target, health threshold, persistence or recovery authority changes. +`storagePoolDiskOwnership.test.ts`, the mounted +`StoragePoolDetail.ownership.test.tsx` and `browser-tests/pool-ownership.cjs` +cover repeated paths/groups, direct children, scoped legacy snapshots, missing +ownership and live target replacement. Synthetic presentation proof does not +establish native appliance, installation or recovery acceptance. From 4cf935d4cc873d0618644e5248c6e36ba57ed6ab Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:44:00 +0100 Subject: [PATCH 6/6] Record a browser pass for the merged stacked disk bar model PRs 2438 and 2449 each verified their own edit to stackedDiskBarModel.ts, but the file main now holds combines both and had no receipt, so the main push run for 2449 failed range validation. This records a pass of the combined content on a mock stack built from main. --- .../62d0e3265f2fb550.json | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend-modern/browser-verification/62d0e3265f2fb550.json diff --git a/frontend-modern/browser-verification/62d0e3265f2fb550.json b/frontend-modern/browser-verification/62d0e3265f2fb550.json new file mode 100644 index 0000000000..9b8ec6cbdc --- /dev/null +++ b/frontend-modern/browser-verification/62d0e3265f2fb550.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "base_sha": "06a68dbff30c51d5e428126b65019e6b8323f12e", + "verified_at": "2026-10-03T21:44:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "159107b0ac3deb2daec780274e1d1ad48e6df3c27a1fca9e4bec1b774c16abf0" + }, + "routes": [ + "/proxmox/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 1280, + "height": 900 + }, + { + "width": 900, + "height": 900 + }, + { + "width": 390, + "height": 900 + } + ], + "states": [ + "Combined content of stackedDiskBarModel.ts on main 06a68dbff3 after #2438 (glyph-width label fit) and #2449 (fullest-disk label in vertical-bars mode) both edited it; neither PR's receipt covers the merged file, so the main push run of #2449 failed range validation", + "Proxmox Overview nodes table on a private mock stack built from main, light at 1440/1280/900/390 and dark at 1440: seven multi-disk nodes render the fullest-disk percentage (e.g. 66%) beside equal-width micro-bars with a Highest usage title, none clipped; single-disk nodes keep the inline percentage with used/total; no disk-cell text clips at any width; at 390 the column shows percentage-only bars" + ], + "interactions": [ + "Read every visible [data-stacked-disk-max-label] and its title, and swept every leaf inside [data-stacked-disk-trigger] for scrollWidth over clientWidth, per viewport and scheme", + "vitest for src/components/Workloads, src/features/proxmox, src/features/docker and src/features/platformPage on main: 142 files, 2631 tests passed" + ] +}