From 6a35c6d8e21e21e1a00bf7828b40cbd492673c3e Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:52:49 +0300 Subject: [PATCH 01/10] feat: filter resolved remediation updates --- docs/internal/session-format-sources.md | 6 +- frontend/messages/en.json | 2 + frontend/messages/fr.json | 2 + frontend/messages/ko.json | 2 + frontend/messages/zh-CN.json | 2 + frontend/messages/zh-TW.json | 2 + .../lib/components/content/MessageList.svelte | 10 ++- .../lib/components/layout/AppHeader.svelte | 39 ++++++++++ frontend/src/lib/stores/ui.svelte.ts | 20 +++++ frontend/src/lib/stores/ui.test.ts | 41 ++++++++++ .../src/lib/utils/remediation-filter.test.ts | 74 +++++++++++++++++++ frontend/src/lib/utils/remediation-filter.ts | 42 +++++++++++ internal/parser/codex.go | 31 +++++++- internal/parser/codex_parser_test.go | 48 ++++++++++++ 14 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/utils/remediation-filter.test.ts create mode 100644 frontend/src/lib/utils/remediation-filter.ts diff --git a/docs/internal/session-format-sources.md b/docs/internal/session-format-sources.md index 6fdb3aa38f..009112d48e 100644 --- a/docs/internal/session-format-sources.md +++ b/docs/internal/session-format-sources.md @@ -195,7 +195,11 @@ Grok section and remove the explicit registry exception in the coverage test. trees, so this evidence does not establish IDE, desktop, or `codex exec` activity-hint coverage. Locally observed Codex app builds can write the same schema, but that is observational evidence rather than a public - compatibility guarantee. Agentsview derives the hint path as + compatibility guarantee. Reverified 2026-08-08 against local desktop + rollouts: `event_msg` records with `payload.type="agent_message"`, + `phase="commentary"`, and `message` are ordered assistant progress updates; + Agentsview preserves them as assistant messages with + `source_subtype="commentary"`. Agentsview derives the hint path as `/../history.jsonl`; a custom sessions root without that sibling, or `HistoryPersistence::None`, degrades to ordinary watcher behavior, degraded-coverage polling when applicable, and the daily diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 53fb412492..5273417317 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -34,6 +34,8 @@ "header_transcript_filter_title": "Filter block types", "header_transcript_filter_label": "Filter block types", "header_transcript_visibility": "Block Visibility", + "header_transcript_remediation": "Remediation Updates", + "header_transcript_hide_resolved_remediation": "Hide resolved remediation updates ({count})", "header_transcript_show_all": "Show all", "header_transcript_blocks_user": "User messages", "header_transcript_blocks_assistant": "Assistant text", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 34778fe8be..a816227c15 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -34,6 +34,8 @@ "header_transcript_filter_title": "Filtrer les types de blocs", "header_transcript_filter_label": "Filtrer les types de blocs", "header_transcript_visibility": "Visibilité des blocs", + "header_transcript_remediation": "Mises à jour de correction", + "header_transcript_hide_resolved_remediation": "Masquer les mises à jour de correction résolues ({count})", "header_transcript_show_all": "Tout afficher", "header_transcript_blocks_user": "Messages utilisateur", "header_transcript_blocks_assistant": "Texte de l'assistant", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index be4d934331..3e6d913255 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -34,6 +34,8 @@ "header_transcript_filter_title": "블록 유형 필터", "header_transcript_filter_label": "블록 유형 필터", "header_transcript_visibility": "블록 표시 여부", + "header_transcript_remediation": "수정 업데이트", + "header_transcript_hide_resolved_remediation": "해결된 수정 업데이트 숨기기 ({count})", "header_transcript_show_all": "모두 표시", "header_transcript_blocks_user": "사용자 메시지", "header_transcript_blocks_assistant": "어시스턴트 텍스트", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index faa79782b7..f313f08057 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -34,6 +34,8 @@ "header_transcript_filter_title": "筛选 block 类型", "header_transcript_filter_label": "筛选 block 类型", "header_transcript_visibility": "Block 可见性", + "header_transcript_remediation": "修复更新", + "header_transcript_hide_resolved_remediation": "隐藏已解决的修复更新 ({count})", "header_transcript_show_all": "全部显示", "header_transcript_blocks_user": "用户消息", "header_transcript_blocks_assistant": "助手文本", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 739cc4bfeb..47892b7d04 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -34,6 +34,8 @@ "header_transcript_filter_title": "篩選 block 類型", "header_transcript_filter_label": "篩選 block 類型", "header_transcript_visibility": "Block 可見性", + "header_transcript_remediation": "修正更新", + "header_transcript_hide_resolved_remediation": "隱藏已解決的修正更新 ({count})", "header_transcript_show_all": "全部顯示", "header_transcript_blocks_user": "使用者訊息", "header_transcript_blocks_assistant": "助手文本", diff --git a/frontend/src/lib/components/content/MessageList.svelte b/frontend/src/lib/components/content/MessageList.svelte index 6579f82004..a5a65a087b 100644 --- a/frontend/src/lib/components/content/MessageList.svelte +++ b/frontend/src/lib/components/content/MessageList.svelte @@ -24,6 +24,7 @@ } from "../../utils/content-parser.js"; import { isSystemMessage } from "../../utils/messages.js"; import { resolveMessageLayout } from "../../utils/message-layout.js"; + import { shouldHideResolvedRemediation } from "../../utils/remediation-filter.js"; import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js"; import { sessionActivity } from "../../stores/sessionActivity.svelte.js"; import SessionFindBar from "./SessionFindBar.svelte"; @@ -49,7 +50,14 @@ let unreadLatestSeen = false; let baseMessages: Message[] = $derived.by(() => - messages.messages.filter((m) => !isSystemMessage(m)), + messages.messages.filter((m) => + !isSystemMessage(m) && + !shouldHideResolvedRemediation( + m, + sessions.activeSession?.outcome, + ui.hideResolvedRemediation, + ) + ), ); let baseDisplayItemsAsc = $derived( diff --git a/frontend/src/lib/components/layout/AppHeader.svelte b/frontend/src/lib/components/layout/AppHeader.svelte index cc6275917c..a6823e41a4 100644 --- a/frontend/src/lib/components/layout/AppHeader.svelte +++ b/frontend/src/lib/components/layout/AppHeader.svelte @@ -37,6 +37,7 @@ ALL_BLOCK_TYPES, type BlockType, } from "../../stores/ui.svelte.js"; + import { messages } from "../../stores/messages.svelte.js"; import { sessions } from "../../stores/sessions.svelte.js"; import { sync } from "../../stores/sync.svelte.js"; import { settings } from "../../stores/settings.svelte.js"; @@ -46,6 +47,7 @@ getMarkdownExportUrl, } from "../../api/client.js"; import { copyToClipboard } from "../../utils/clipboard.js"; + import { countResolvedRemediation } from "../../utils/remediation-filter.js"; import ProjectTypeahead from "./ProjectTypeahead.svelte"; import ImportModal from "../import/ImportModal.svelte"; @@ -102,6 +104,13 @@ (settings.loaded && settings.error === null)), ); + const resolvedRemediationCount = $derived( + countResolvedRemediation( + messages.messages, + sessions.activeSession?.outcome, + ), + ); + const tabs: TopBarTab[] = $derived([ { id: "sessions", label: m.nav_sessions() }, { id: "usage", label: m.nav_usage() }, @@ -435,6 +444,26 @@ {/each} + {#if resolvedRemediationCount > 0} +
+ {m.header_transcript_remediation()} +
+ + {/if} {#if ui.hasBlockFilters} {/each} - {#if resolvedRemediationCount > 0} -
- {m.header_transcript_remediation()} -
- - {/if} {#if ui.hasBlockFilters} + + + + +
+ selectFilter((next) => { sessionId = next; if (next) minSessions = "1"; }, value)} /> + selectFilter((next) => folder = next, value)} /> + selectFilter((next) => category = next, value)} /> + selectFilter((next) => tool = next, value)} /> + selectFilter((next) => source = next, value)} /> + selectFilter((next) => outcome = next, value)} /> + selectFilter((next) => severity = next, value)} /> + selectFilter((next) => confidence = next, value)} /> + selectFilter((next) => status = next, value)} /> + selectFilter((next) => recommendationType = next, value)} /> + selectFilter((next) => minOccurrences = next, value)} /> + selectFilter((next) => minSessions = next, value)} /> + selectFilter((next) => minProjects = next, value)} /> + selectFilter((next) => minWastedMs = next, value)} /> + selectFilter((next) => sort = next, value)} /> +
+ + {#if loading} +
+ {:else if error && response === null} + +
+ {m.issue_review_load_failed()}{error} + +
+
+ {:else} + {#if error}
{m.issue_review_cached_warning({ error })}
{/if} +
+ {m.issue_review_scanned_sessions({ count: response?.scanned_sessions ?? 0 })} + {m.issue_review_scanned_messages({ count: response?.scanned_messages ?? 0 })} + {m.issue_review_scanned_calls({ count: response?.scanned_tool_calls ?? 0 })} + {#if response?.duplicate_tool_calls || response?.duplicate_messages}{m.issue_review_duplicates_excluded({ count: (response?.duplicate_tool_calls ?? 0) + (response?.duplicate_messages ?? 0) })}{/if} + {#if response?.scanned_telemetry}{m.issue_review_scanned_logs({ count: response.scanned_telemetry })}{/if} + {m.issue_review_showing_findings({ shown: findings.length, total: response?.total_findings ?? findings.length })} +
+ {#if response?.telemetry_status && response.telemetry_status !== "available"} +
{m.issue_review_telemetry_unavailable()}
+ {/if} + {#if findings.length === 0} +
{m.issue_review_empty()}{m.issue_review_empty_hint()}
+ {:else} +
+ {#each findings as finding (finding.id)} + +
+
+
+

{reasonLabel(finding.reason_code)}

{finding.tool || finding.signature}
+
{severityLabel(finding.severity)}{confidenceLabel(finding.confidence)}{statusLabel(finding.status)}{actionLabel(finding.recommendation_type)}
+
+

{finding.signature}

+
+ {m.issue_review_occurrences({ count: finding.occurrences })} + {m.issue_review_chats({ count: finding.session_count })} + {m.issue_review_projects({ count: finding.project_count })} +
+ {#if finding.p95_duration_ms != null || finding.wasted_duration_ms > 0} +
+ {#if finding.p95_duration_ms != null}{m.issue_review_p95({ duration: formatDuration(finding.p95_duration_ms) })}{/if} + {m.issue_review_coverage({ value: Math.round(finding.duration_coverage * 100) })} + {#if finding.wasted_duration_ms > 0}{m.issue_review_wasted_proxy({ duration: formatDuration(finding.wasted_duration_ms) })}{/if} +
+ {/if} +

{m.issue_review_suggestion_label()} {finding.recommendation}

+ {#if finding.github_reference}{m.issue_review_open_github_issue({ reference: finding.github_reference })}{/if} + +
+
+
+ {/each} +
+ {#if response && findings.length < response.total_findings} +
+ +
+ {/if} + {/if} + {/if} + + + diff --git a/internal/db/db.go b/internal/db/db.go index 7cc949be7c..03da62917b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -586,6 +586,8 @@ type DB struct { vectorMu sync.RWMutex vectorSearcher VectorSearcher recallSearcher RecallVectorSearcher + + issueReviewCache IssueReviewCache } // Reader exposes guarded read-only query operations. It intentionally does diff --git a/internal/db/issue_review.go b/internal/db/issue_review.go new file mode 100644 index 0000000000..395f301c39 --- /dev/null +++ b/internal/db/issue_review.go @@ -0,0 +1,1839 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "go.kenn.io/agentsview/internal/parser" + "go.kenn.io/agentsview/internal/secrets" +) + +const ( + issueSnippetLimit = 1200 + + IssueReviewInputLimit = 2400 + IssueReviewResultEdgeLimit = 1200 + IssueReviewMessageScanLimit = 4000 + issueReviewCacheTTL = time.Hour +) + +// IssueReviewQuery contains detector-specific scope and result controls. +type IssueReviewQuery struct { + SessionID string + Folder string + Reason string + Tool string + Source string + Outcome string + Severity string + Confidence string + Status string + RecommendationType string + MinOccurrences int + MinSessions int + MinProjects int + MinWastedDurationMS int64 + Sort string + Refresh bool + Offset int + Limit int +} + +type IssueReviewResponse struct { + GeneratedAt string `json:"generated_at"` + ScannedSessions int `json:"scanned_sessions"` + ScannedMessages int `json:"scanned_messages"` + ScannedToolCalls int `json:"scanned_tool_calls"` + AnalyzedMessages int `json:"analyzed_messages"` + AnalyzedToolCalls int `json:"analyzed_tool_calls"` + DuplicateMessages int `json:"duplicate_messages"` + DuplicateToolCalls int `json:"duplicate_tool_calls"` + ScannedTelemetry int `json:"scanned_telemetry"` + TelemetryStatus string `json:"telemetry_status"` + TotalFindings int `json:"total_findings"` + Truncated bool `json:"truncated"` + Findings []IssueReviewFinding `json:"findings" nullable:"false"` + Facets IssueReviewFacets `json:"facets"` +} + +type IssueFacet struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` + Count int `json:"count"` +} + +type IssueReviewFacets struct { + Category []IssueFacet `json:"category" nullable:"false"` + Tool []IssueFacet `json:"tool" nullable:"false"` + Source []IssueFacet `json:"source" nullable:"false"` + Severity []IssueFacet `json:"severity" nullable:"false"` + Confidence []IssueFacet `json:"confidence" nullable:"false"` + Status []IssueFacet `json:"status" nullable:"false"` + RecommendationType []IssueFacet `json:"recommendation_type" nullable:"false"` + Session []IssueFacet `json:"session" nullable:"false"` + Folder []IssueFacet `json:"folder" nullable:"false"` + Outcome []IssueFacet `json:"outcome" nullable:"false"` +} + +type IssueReviewFinding struct { + ID string `json:"id"` + ReasonCode string `json:"reason_code"` + Tool string `json:"tool"` + Signature string `json:"signature"` + Severity string `json:"severity"` + Confidence string `json:"confidence"` + Status string `json:"status"` + RecommendationType string `json:"recommendation_type"` + Recommendation string `json:"recommendation"` + GitHubReference string `json:"github_reference,omitempty"` + Sources []string `json:"sources" nullable:"false"` + Occurrences int `json:"occurrences"` + SessionCount int `json:"session_count"` + ProjectCount int `json:"project_count"` + IncompleteSessionCount int `json:"incomplete_session_count"` + TotalDurationMS int64 `json:"total_duration_ms"` + WastedDurationMS int64 `json:"wasted_duration_ms"` + P95DurationMS *int64 `json:"p95_duration_ms,omitempty"` + DurationCoverage float64 `json:"duration_coverage"` + DurationSource string `json:"duration_source,omitempty"` + LastSeen string `json:"last_seen"` + Evidence []IssueReviewEvidence `json:"evidence" nullable:"false"` + rank int +} + +type IssueReviewEvidence struct { + SessionID string `json:"session_id"` + Project string `json:"project"` + CWD string `json:"cwd"` + Agent string `json:"agent"` + Date string `json:"date"` + Outcome string `json:"outcome"` + Source string `json:"source"` + Tool string `json:"tool"` + Excerpt string `json:"excerpt"` + MessageOrdinal *int `json:"message_ordinal,omitempty"` + CallIndex *int `json:"call_index,omitempty"` + EventStatus string `json:"event_status,omitempty"` + Recovered bool `json:"recovered"` + DurationMS *int64 `json:"duration_ms,omitempty"` +} + +// IssueReviewSession, IssueReviewMessage, and IssueReviewToolCall are the +// narrow cross-store rows consumed by the shared detector. +type IssueReviewSession struct { + ID, Name, Project, CWD, Agent, Date, Outcome string + Incomplete bool +} + +type IssueReviewMessage struct { + SessionID, Role, Content, Timestamp, SourceType, SourceSubtype, StableID string + Ordinal int + IsSystem bool +} + +type IssueReviewToolCall struct { + SessionID, Tool, Category, ToolUseID, Input, Result string + EventStatus, EventSource, Timestamp, DurationSource string + MessageOrdinal, CallIndex int + DurationMS *int64 +} + +type IssueReviewTelemetry struct { + SessionID, Target, Level, Body, Timestamp string + Tool, CallID string + DurationMS *int64 +} + +type issueReviewCacheEntry struct { + key string + expiresAt time.Time + response IssueReviewResponse +} + +// IssueReviewCache shares the short-lived base-analysis cache across stores. +type IssueReviewCache struct { + mu sync.Mutex + entry *issueReviewCacheEntry +} + +func (c *IssueReviewCache) Get(key string, q IssueReviewQuery) (IssueReviewResponse, bool) { + if q.Refresh { + return IssueReviewResponse{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + if c.entry == nil || c.entry.key != key || !time.Now().Before(c.entry.expiresAt) { + return IssueReviewResponse{}, false + } + return filterIssueReviewResponse(c.entry.response, q), true +} + +func (c *IssueReviewCache) Put(key string, response IssueReviewResponse) { + c.mu.Lock() + c.entry = &issueReviewCacheEntry{key: key, expiresAt: time.Now().Add(issueReviewCacheTTL), response: response} + c.mu.Unlock() +} + +var ( + spaceRE = regexp.MustCompile(`\s+`) + windowsPathRE = regexp.MustCompile(`(?i)[A-Z]:[\\/][^\r\n"']+`) + unixPathRE = regexp.MustCompile(`(?:^|\s)/(?:[^\s"']+/)+[^\s"']*`) + searchCommandRE = regexp.MustCompile(`(?i)^\s*(?:&\s*)?(?:"[^"]*[\\/])?(?:rg|grep)(?:\.exe)?(?:"?\s|$)`) + errorWordRE = regexp.MustCompile(`(?i)\b(error|failed|failure|fatal|exception|denied|timeout|not found|cannot|could not|crash|panic)\b`) + blockerPredicateRE = regexp.MustCompile(`(?i)\b(error|failed|failure|bug|issue|blocked|broken|crash|denied|timeout|cannot|stopped|unavailable|missing|requires|incomplete)\b|\bcould not\b|\bdid not\b`) + failureSummaryRE = regexp.MustCompile(`(?i)\b[1-9]\d*\s+(?:tests?\s+)?failed\b|\btests? failed\b|(?:^|\n)\s*(?:npm err!|fatal:|panic:|traceback \(most recent call last\):)`) + httpFailureRE = regexp.MustCompile(`(?i)\b(?:http(?: status)?|status)\s*[:=]?\s*[45]\d\d\b`) + githubIssueURLRE = regexp.MustCompile(`(?i)https?://github\.com/([a-z0-9_.-]+)/([a-z0-9_.-]+)/issues/([1-9]\d*)`) + githubIssueShortRE = regexp.MustCompile(`(?i)\b([a-z0-9_.-]+)/([a-z0-9_.-]+)#([1-9]\d*)\b`) + nestedToolRE = regexp.MustCompile("\\btools\\.([A-Za-z0-9_]+)\\s*\\(") + logFieldRE = regexp.MustCompile(`([a-z_]+)=(?:"([^"]*)"|([^\s]+))`) + credentialFieldRE = regexp.MustCompile(`(?i)["']?\b(api[_-]?key|key|token|secret|password|credential|authorization|cookie|session[_-]?key)\b["']?\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)`) + pathFieldRE = regexp.MustCompile(`(?i)\b(path|cwd|workdir|file|filename|directory|repo|repository|socket)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)`) + bearerRE = regexp.MustCompile(`(?i)\bbearer\s+[^\s,;]+`) +) + +var correctionTerms = []string{"nah", "no, that's not", "no, that is not", "i don't want", "i do not want", "wrong", "something is off", "where exactly", "you missed", "that's not what", "that is not what"} + +var blockerTerms = []string{"root cause", "failed because", "blocked by", "hit a ", "exposed a ", "exposed an ", "stopped before", "currently broken", "crashed", "failure is", "failures compare"} + +var failureMarkerTerms = []string{ + "script failed", "script error", "parsererror", "parameterbinding", "invalid context", + "npm err!", "fatal:", "panic:", "traceback (most recent call last):", + "permission denied", "access is denied", "deadline exceeded", "timed out", + "unhandled exception", "segmentation fault", `"iserror":true`, "iserror=true", +} + +// IssueReviewMessagePredicate narrows storage reads to text that the shared +// message detector can classify. All terms are fixed internal constants. +func IssueReviewMessagePredicate(roleColumn, contentColumn string) string { + likes := func(terms []string) string { + parts := make([]string, len(terms)) + for i, term := range terms { + term = strings.ReplaceAll(term, "'", "''") + parts[i] = "LOWER(" + contentColumn + ") LIKE '%" + term + "%'" + } + return "(" + strings.Join(parts, " OR ") + ")" + } + return "((" + roleColumn + " = 'user' AND (LENGTH(TRIM(" + contentColumn + ")) >= 32 OR " + likes(correctionTerms) + ")) OR (" + roleColumn + " = 'assistant' AND " + likes(blockerTerms) + ") OR LOWER(" + contentColumn + ") LIKE '%github.com/%/issues/%')" +} + +// IssueReviewTailPredicate limits expensive result-tail reads to calls whose +// structured status or bounded head already proves a failure. +func IssueReviewTailPredicate(statusColumn, resultColumn string) string { + head := "LOWER(SUBSTR(" + resultColumn + ",1," + strconv.Itoa(IssueReviewResultEdgeLimit) + "))" + likes := make([]string, len(failureMarkerTerms)) + for i, term := range failureMarkerTerms { + likes[i] = head + " LIKE '%" + strings.ReplaceAll(term, "'", "''") + "%'" + } + return "(LOWER(COALESCE(" + statusColumn + ",'')) IN ('errored','error','cancelled','canceled') OR " + strings.Join(likes, " OR ") + ")" +} + +type issuePattern struct { + reason string + terms []string +} + +var issueFailurePatterns = []issuePattern{ + {"windows_shell", []string{"parsererror", "parameterbinding", "a parameter cannot be found", "is not recognized as the name", "the term '"}}, + {"line_endings", []string{"crlf", "line ending", "newline-portable", "contains \\r\\n", "carriage return"}}, + {"missing_file", []string{"no such file or directory", "cannot find path", "path does not exist", "file not found", "could not find file", "index is incomplete"}}, + {"missing_dependency", []string{"command not found", "module not found", "cannot find module", "no module named", "missing dependency", "package not installed"}}, + {"permission_auth", []string{"permission denied", "access is denied", "unauthorized", "forbidden", "status 401", " 401 ", "requires root", "requires sudo", "authentication failed", "credential"}}, + {"rate_limit", []string{"rate limit", "too many requests", "status 429", " 429 ", "quota exceeded"}}, + {"shell_syntax", []string{"unexpected eof while looking for matching", "unexpected token", "syntax error near unexpected token", "unterminated quoted string"}}, + {"network", []string{"connection refused", "connection reset", "network is unreachable", "dns", "tls handshake", "websocket", "stream disconnect", "unexpected eof"}}, + {"timeout", []string{"timed out", "timeout", "deadline exceeded", "60m limit"}}, + {"git_github_ci", []string{"github", "gh api", "git push", "git pull", "merge conflict", "non-fast-forward", "workflow failed", "actions failed", "ci failed", "fatal: not a git"}}, + {"failed_edit", []string{"apply_patch", "patch failed", "invalid context", "failed to apply", "edit failed", "old_string was not found", "did not match"}}, + {"build_test", []string{"compilation failed", "compiler error", "build failed", "test failed", "tests failed", "assertion failed", "schema existed before restore", "psql", "migration failed", "npm err", "typecheck failed"}}, + {"tool_crash", []string{"panicked", "panic:", "segmentation fault", "stack trace", "crashed", "access violation", "unhandled exception"}}, +} + +// ClassifyIssueFailure classifies a tool result conservatively. It returns +// false for explicit success and search no-match exits. +func ClassifyIssueFailure(tool, status, input, result string) (string, bool) { + status = strings.ToLower(strings.TrimSpace(status)) + failedStatus := status == "errored" || status == "error" || status == "cancelled" || status == "canceled" + resultLower := strings.ToLower(result) + hasZeroExit, hasOneExit, hasNonZeroExit := issueExitCodes(resultLower) + if isSearchInvocation(tool, input) && hasOneExit && !hasSpecificSearchFailure(resultLower) { + return "", false + } + logicalFailure := hasLogicalFailure(input, resultLower) + markerFailure := explicitFailureMarker(result) + if !failedStatus && !hasNonZeroExit && isReadInvocation(tool, input) { + return "", false + } + if hasZeroExit && !hasNonZeroExit && !logicalFailure && !failedStatus { + return "", false + } + if !failedStatus && !hasNonZeroExit && !logicalFailure && !markerFailure { + return "", false + } + if reason, ok := classifyIssueReason(resultLower); ok { + return reason, true + } + if isEditInvocation(tool, input) { + return "failed_edit", true + } + if isGitHubInvocation(tool, input) { + return "git_github_ci", true + } + if isBuildTestInvocation(tool, input) { + return "build_test", true + } + if isShellTool(tool) { + return "command_failure", true + } + if failedStatus || hasNonZeroExit || logicalFailure || markerFailure { + return "generic_tool_failure", true + } + return "", false +} + +func classifyIssueReason(content string) (string, bool) { + content = strings.ToLower(content) + for _, pattern := range issueFailurePatterns { + for _, term := range pattern.terms { + if strings.Contains(content, term) { + return pattern.reason, true + } + } + } + return "", false +} + +func issueFailureConfidence(status, input, result string) string { + status = strings.ToLower(strings.TrimSpace(status)) + if status == "errored" || status == "error" || status == "cancelled" || status == "canceled" { + return "high" + } + _, _, hasNonZeroExit := issueExitCodes(strings.ToLower(result)) + if hasNonZeroExit { + return "high" + } + return "medium" +} + +func explicitFailureMarker(result string) bool { + result = strings.ToLower(result) + for _, marker := range failureMarkerTerms { + if strings.Contains(result, marker) { + return true + } + } + for _, line := range strings.Split(result, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "error:") || strings.HasPrefix(line, "exception:") { + return true + } + } + return false +} + +func hasLogicalFailure(input, resultLower string) bool { + if strings.Contains(resultLower, "parsererror") || strings.Contains(resultLower, "parameterbinding") || strings.Contains(resultLower, "invalid context") { + return true + } + if (strings.Contains(resultLower, "failed") || strings.Contains(resultLower, "npm err!") || strings.Contains(resultLower, "fatal:") || strings.Contains(resultLower, "panic:") || strings.Contains(resultLower, "traceback (most recent call last):")) && failureSummaryRE.MatchString(resultLower) { + return true + } + lowerInput := strings.ToLower(input) + if (!strings.Contains(resultLower, "http") && !strings.Contains(resultLower, "status")) || !httpFailureRE.MatchString(resultLower) { + return false + } + for _, term := range []string{"gh ", "github", "curl", "invoke-webrequest", "api", "http"} { + if strings.Contains(lowerInput, term) { + return true + } + } + return false +} + +func issueExitCodes(resultLower string) (hasZero, hasOne, hasNonZero bool) { + for offset := 0; ; { + relative := strings.Index(resultLower[offset:], "exit") + if relative < 0 { + return hasZero, hasOne, hasNonZero + } + start := offset + relative + len("exit") + if strings.HasPrefix(resultLower[start:], "ed") { + start += len("ed") + } + for start < len(resultLower) && resultLower[start] == ' ' { + start++ + } + if strings.HasPrefix(resultLower[start:], "with") { + start += len("with") + for start < len(resultLower) && resultLower[start] == ' ' { + start++ + } + } + if strings.HasPrefix(resultLower[start:], "code") { + start += len("code") + } + for start < len(resultLower) && (resultLower[start] == ' ' || resultLower[start] == ':' || resultLower[start] == '=') { + start++ + } + if start < len(resultLower) && resultLower[start] >= '0' && resultLower[start] <= '9' { + value := 0 + for start < len(resultLower) && resultLower[start] >= '0' && resultLower[start] <= '9' { + value = value*10 + int(resultLower[start]-'0') + start++ + } + hasZero = hasZero || value == 0 + hasOne = hasOne || value == 1 + hasNonZero = hasNonZero || value > 0 + } + offset += relative + len("exit") + } +} + +func hasSpecificSearchFailure(resultLower string) bool { + withoutWrapper := strings.ReplaceAll(resultLower, "script failed", "") + for _, term := range []string{"error", "fatal", "exception", "denied", "timeout", "not found", "cannot", "could not", "crash", "panic", "parsererror", "parameterbinding", "invalid context", "no such file"} { + if strings.Contains(withoutWrapper, term) { + return true + } + } + return false +} + +func isSearchInvocation(tool, input string) bool { + if isSearchTool(tool) { + return true + } + if !isShellTool(tool) { + return false + } + input = issueCommandInput(input) + return searchCommandRE.MatchString(input) +} + +func issueCommandInput(input string) string { + var payload struct { + Command string `json:"command"` + } + if json.Unmarshal([]byte(input), &payload) == nil && payload.Command != "" { + return payload.Command + } + return input +} + +func isEditInvocation(tool, input string) bool { + lowerTool := normalizeTool(tool) + if strings.Contains(lowerTool, "apply_patch") || strings.Contains(lowerTool, "edit") { + return true + } + lower := strings.ToLower(issueCommandInput(input)) + return strings.HasPrefix(strings.TrimSpace(lower), "apply_patch") +} + +func isGitHubInvocation(tool, input string) bool { + if !isShellTool(tool) && !strings.Contains(normalizeTool(tool), "github") { + return false + } + lower := strings.ToLower(issueCommandInput(input)) + return strings.Contains(lower, "gh ") || strings.Contains(lower, "github.com") || + strings.Contains(lower, "git push") || strings.Contains(lower, "git pull") || + strings.Contains(lower, "git fetch") || strings.Contains(lower, "git clone") +} + +func isBuildTestInvocation(tool, input string) bool { + if !isShellTool(tool) { + return false + } + lower := strings.ToLower(issueCommandInput(input)) + for _, term := range []string{" go test", "npm test", "npm run build", "npm run check", "pytest", "cargo test", "dotnet test", "psql", "migration"} { + if strings.Contains(" "+lower, term) { + return true + } + } + return false +} + +func canonicalGitHubReference(value string) string { + if strings.Contains(value, "://") { + match := githubIssueURLRE.FindStringSubmatch(value) + if len(match) == 4 { + return strings.ToLower(match[1]+"/"+match[2]) + "#" + match[3] + } + } + if strings.Contains(value, "#") { + match := githubIssueShortRE.FindStringSubmatch(value) + if len(match) == 4 { + return strings.ToLower(match[1]+"/"+match[2]) + "#" + match[3] + } + } + return "" +} + +func isShellTool(tool string) bool { + switch normalizeTool(tool) { + case "bash", "shell", "powershell", "exec", "exec_command", "shell_command", "functions.exec": + return true + default: + return false + } +} + +func isSearchTool(tool string) bool { + t := strings.ToLower(tool) + return t == "rg" || t == "grep" || strings.Contains(t, "search") +} + +func normalizeTool(tool string) string { + t := strings.ToLower(strings.TrimSpace(tool)) + switch t { + case "bash", "shell", "powershell", "exec", "exec_command", "shell_command", "functions.exec": + return t + default: + return t + } +} + +func effectiveIssueTool(tool, input string) string { + outer := normalizeTool(tool) + if outer != "exec" && outer != "functions.exec" { + return outer + } + var nested string + for _, match := range nestedToolRE.FindAllStringSubmatch(input, -1) { + candidate := normalizeTool(match[1]) + if nested == "" { + nested = candidate + } else if nested != candidate { + return outer + } + } + if nested != "" { + return nested + } + return outer +} + +func normalizeIssueText(value string) string { + value = strings.TrimSpace(value) + var out strings.Builder + out.Grow(len(value)) + pendingSpace := false + for i := 0; i < len(value); { + if isIssueSpace(value[i]) { + pendingSpace = out.Len() > 0 + i++ + continue + } + if pendingSpace { + out.WriteByte(' ') + pendingSpace = false + } + if isWindowsPathAt(value, i) { + out.WriteString("") + i += 3 + for i < len(value) && !strings.ContainsRune("\r\n\"'", rune(value[i])) { + i++ + } + continue + } + if isUnixPathAt(value, i) { + out.WriteString("") + i++ + for i < len(value) && !isIssueSpace(value[i]) && value[i] != '"' && value[i] != '\'' { + i++ + } + continue + } + if end, ok := volatileIssueToken(value, i); ok { + out.WriteByte('#') + i = end + continue + } + c := value[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out.WriteByte(c) + i++ + } + return out.String() +} + +func isIssueSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' +} + +func isIssueWord(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' +} + +func isWindowsPathAt(value string, i int) bool { + return i+2 < len(value) && (value[i] >= 'a' && value[i] <= 'z' || value[i] >= 'A' && value[i] <= 'Z') && value[i+1] == ':' && value[i+2] == '\\' && (i == 0 || !isIssueWord(value[i-1])) +} + +func isUnixPathAt(value string, i int) bool { + if value[i] != '/' || i > 0 && !isIssueSpace(value[i-1]) && value[i-1] != '"' && value[i-1] != '\'' { + return false + } + end := i + 1 + for end < len(value) && !isIssueSpace(value[end]) && value[end] != '"' && value[end] != '\'' { + end++ + } + return strings.Contains(value[i+1:end], "/") +} + +func volatileIssueToken(value string, i int) (int, bool) { + if i > 0 && isIssueWord(value[i-1]) { + return i, false + } + end := i + hasHexLetter := false + for end < len(value) { + c := value[end] + if c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c == '-' { + hasHexLetter = hasHexLetter || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' + end++ + continue + } + break + } + if end-i >= 7 && hasHexLetter && (end == len(value) || !isIssueWord(value[end])) { + return end, true + } + end = i + if value[i] >= '0' && value[i] <= '9' { + for end < len(value) && (value[end] >= '0' && value[end] <= '9' || value[end] == '.') { + end++ + } + return end, end == len(value) || !isIssueWord(value[end]) + } + return i, false +} + +func displayIssueText(value string) string { + value = redactIssueText(value) + if len(value) > 240 { + value = value[:240] + "…" + } + return value +} + +func issueExcerpt(value string) string { + value = redactIssueText(value) + if len(value) > issueSnippetLimit { + value = value[:issueSnippetLimit] + "…" + } + return value +} + +// JoinIssueReviewResult keeps bounded failure context from both ends of a +// potentially large tool result. +func JoinIssueReviewResult(head, tail string) string { + if tail == "" || head == tail { + return head + } + return head + "\n...[truncated]...\n" + tail +} + +func redactIssueText(value string) string { + value = strings.ReplaceAll(value, `\"`, `"`) + value = credentialFieldRE.ReplaceAllString(value, "$1=") + value = pathFieldRE.ReplaceAllString(value, "$1=") + value = bearerRE.ReplaceAllString(value, "Bearer ") + value = windowsPathRE.ReplaceAllString(value, "") + value = unixPathRE.ReplaceAllString(value, " ") + return secrets.Redact(strings.TrimSpace(spaceRE.ReplaceAllString(value, " "))) +} + +func findingID(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:8]) +} + +type findingAccumulator struct { + finding IssueReviewFinding + sessions map[string]bool + projects map[string]bool + incomplete map[string]bool + sources map[string]bool + durations []int64 + statsDurations []int64 + measuredCalls int + toolCalls int + totalCalls int + recovered int + unrecovered int +} + +type issueAnalyzer struct { + sessions map[string]IssueReviewSession + clusters map[string]*findingAccumulator +} + +func newIssueAnalyzer(sessions []IssueReviewSession) *issueAnalyzer { + byID := make(map[string]IssueReviewSession, len(sessions)) + for _, session := range sessions { + byID[session.ID] = session + } + return &issueAnalyzer{sessions: byID, clusters: map[string]*findingAccumulator{}} +} + +func (a *issueAnalyzer) evidence(sessionID, source, tool, excerpt, status string, ordinal, callIndex *int, duration *int64) IssueReviewEvidence { + s := a.sessions[sessionID] + return IssueReviewEvidence{SessionID: sessionID, Project: s.Project, CWD: s.CWD, Agent: s.Agent, Date: s.Date, Outcome: s.Outcome, Source: source, Tool: tool, Excerpt: issueExcerpt(excerpt), MessageOrdinal: ordinal, CallIndex: callIndex, EventStatus: status, DurationMS: duration} +} + +func (a *issueAnalyzer) add(key, reason, tool, signature, severity, confidence, recommendation string, evidence IssueReviewEvidence, duration, waste *int64, recovered bool) { + acc := a.clusters[key] + if acc == nil { + acc = &findingAccumulator{finding: IssueReviewFinding{ID: findingID(key), ReasonCode: reason, Tool: tool, Signature: displayIssueText(signature), Severity: severity, Confidence: confidence, RecommendationType: recommendation, GitHubReference: canonicalGitHubReference(signature), Evidence: []IssueReviewEvidence{}, Sources: []string{}}, sessions: map[string]bool{}, projects: map[string]bool{}, incomplete: map[string]bool{}, sources: map[string]bool{}} + a.clusters[key] = acc + } + if confidence == "high" || confidence == "medium" && acc.finding.Confidence == "low" { + acc.finding.Confidence = confidence + } + if acc.finding.GitHubReference == "" { + acc.finding.GitHubReference = canonicalGitHubReference(signature + "\n" + evidence.Excerpt) + } + acc.finding.Occurrences++ + acc.sessions[evidence.SessionID] = true + if evidence.Project != "" { + acc.projects[evidence.Project] = true + } + if a.sessions[evidence.SessionID].Incomplete { + acc.incomplete[evidence.SessionID] = true + } + if evidence.Source != "" { + acc.sources[evidence.Source] = true + } + if evidence.Date > acc.finding.LastSeen { + acc.finding.LastSeen = evidence.Date + } + if duration != nil { + acc.durations = append(acc.durations, *duration) + acc.finding.TotalDurationMS += *duration + } + if waste != nil { + acc.finding.WastedDurationMS += *waste + } + if recovered { + acc.recovered++ + } else { + acc.unrecovered++ + } + if len(acc.finding.Evidence) < 5 { + evidence.Recovered = recovered + acc.finding.Evidence = append(acc.finding.Evidence, evidence) + } +} + +func (a *issueAnalyzer) finish(totalCalls int, durationCounts map[string]int) []IssueReviewFinding { + out := make([]IssueReviewFinding, 0, len(a.clusters)) + for _, acc := range a.clusters { + f := acc.finding + f.SessionCount = len(acc.sessions) + f.ProjectCount = len(acc.projects) + f.IncompleteSessionCount = len(acc.incomplete) + for source := range acc.sources { + f.Sources = append(f.Sources, source) + } + sort.Strings(f.Sources) + f.RecommendationType = recommendationFor(f.ReasonCode, f.SessionCount, f.ProjectCount) + f.Recommendation = concreteRecommendation(f) + if f.SessionCount >= 2 { + f.Status = "recurring" + } else if acc.recovered > 0 && acc.unrecovered == 0 { + f.Status = "recovered" + } else if acc.unrecovered > 0 && f.IncompleteSessionCount > 0 { + f.Status = "open" + } else { + f.Status = "observed" + } + statsDurations := acc.statsDurations + if len(statsDurations) == 0 { + statsDurations = acc.durations + } + if len(statsDurations) > 0 { + sort.Slice(statsDurations, func(i, j int) bool { return statsDurations[i] < statsDurations[j] }) + p := int(math.Ceil(float64(len(statsDurations))*0.95)) - 1 + v := statsDurations[max(0, p)] + f.P95DurationMS = &v + numerator := len(acc.durations) + denominator := durationCounts[f.Tool] + if acc.measuredCalls > 0 { + numerator = acc.measuredCalls + } + if acc.toolCalls > 0 { + denominator = acc.toolCalls + } + if denominator == 0 { + denominator = totalCalls + } + if denominator > 0 { + f.DurationCoverage = float64(numerator) / float64(denominator) + } + } + f.rank = f.Occurrences*10 + f.SessionCount*30 + f.IncompleteSessionCount*20 + int(f.WastedDurationMS/30000) + if f.Severity == "high" { + f.rank += 40 + } else if f.Severity == "medium" { + f.rank += 20 + } + out = append(out, f) + } + sort.Slice(out, func(i, j int) bool { + if out[i].rank != out[j].rank { + return out[i].rank > out[j].rank + } + if out[i].Occurrences != out[j].Occurrences { + return out[i].Occurrences > out[j].Occurrences + } + return out[i].ID < out[j].ID + }) + return out +} + +type analyzedCall struct { + row IssueReviewToolCall + tool string + normalized string + reason string + failed bool + recovered bool +} + +type workflowAccumulator struct { + rows []analyzedCall + firstSession string + firstProject string + multiSession bool + multiProject bool +} + +func dedupeIssueMessages(rows []IssueReviewMessage) ([]IssueReviewMessage, int) { + seen := make(map[string]bool) + out := make([]IssueReviewMessage, 0, len(rows)) + duplicates := 0 + for _, row := range rows { + if row.StableID == "" { + out = append(out, row) + continue + } + key := row.Role + "|" + row.StableID + "|" + row.Content + if seen[key] { + duplicates++ + continue + } + seen[key] = true + out = append(out, row) + } + return out, duplicates +} + +func dedupeIssueCalls(rows []IssueReviewToolCall) ([]IssueReviewToolCall, int) { + indices := make(map[string]int) + out := make([]IssueReviewToolCall, 0, len(rows)) + duplicates := 0 + for _, row := range rows { + if row.ToolUseID == "" { + out = append(out, row) + continue + } + key := normalizeTool(row.Tool) + "|" + row.ToolUseID + index, ok := indices[key] + if !ok { + indices[key] = len(out) + out = append(out, row) + continue + } + duplicates++ + if len(row.Result) > len(out[index].Result) { + out[index] = row + } else if out[index].DurationMS == nil && row.DurationMS != nil { + out[index].DurationMS = row.DurationMS + out[index].DurationSource = row.DurationSource + } + } + return out, duplicates +} + +func AnalyzeIssueReview(sessions []IssueReviewSession, messages []IssueReviewMessage, calls []IssueReviewToolCall, telemetry []IssueReviewTelemetry, q IssueReviewQuery) IssueReviewResponse { + return filterIssueReviewResponse(AnalyzeIssueReviewBase(sessions, messages, calls, telemetry), q) +} + +// FilterIssueReview applies cheap result filters and pagination to a base analysis. +func FilterIssueReview(response IssueReviewResponse, q IssueReviewQuery) IssueReviewResponse { + return filterIssueReviewResponse(response, q) +} + +// AnalyzeIssueReviewBase performs the expensive shared analysis before result filters. +func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueReviewMessage, calls []IssueReviewToolCall, telemetry []IssueReviewTelemetry) IssueReviewResponse { + rawMessages, rawCalls := len(messages), len(calls) + messages, duplicateMessages := dedupeIssueMessages(messages) + calls, duplicateCalls := dedupeIssueCalls(calls) + a := newIssueAnalyzer(sessions) + bySession := make(map[string][]analyzedCall) + normalizedInputs := make(map[string]string) + durationCounts := map[string]int{} + toolCounts := map[string]int{} + for _, row := range calls { + tool := effectiveIssueTool(row.Tool, row.Input) + toolCounts[tool]++ + if row.DurationMS != nil && *row.DurationMS < 0 { + row.DurationMS = nil + row.DurationSource = "" + } + normalized := strings.TrimSpace(row.Input) + if isShellTool(tool) { + normalized = strings.TrimSpace(issueCommandInput(row.Input)) + } + reason, failed := ClassifyIssueFailure(tool, row.EventStatus, row.Input, row.Result) + bySession[row.SessionID] = append(bySession[row.SessionID], analyzedCall{row: row, tool: tool, normalized: normalized, reason: reason, failed: failed}) + if row.DurationMS != nil && *row.DurationMS >= 0 { + durationCounts[tool]++ + } + } + workflows := map[string]*workflowAccumulator{} + for sessionID, rows := range bySession { + sort.Slice(rows, func(i, j int) bool { + if rows[i].row.MessageOrdinal != rows[j].row.MessageOrdinal { + return rows[i].row.MessageOrdinal < rows[j].row.MessageOrdinal + } + return rows[i].row.CallIndex < rows[j].row.CallIndex + }) + for i := range rows { + if !rows[i].failed { + continue + } + intervening := 0 + for j := i + 1; j < len(rows); j++ { + next := rows[j] + if next.failed { + break + } + if next.tool == rows[i].tool && next.normalized == rows[i].normalized { + rows[i].recovered = true + break + } + if intervening == 3 || !isRecoveryDiagnostic(next.tool, next.row.Input) { + break + } + intervening++ + } + } + bySession[sessionID] = rows + for i, call := range rows { + tool := call.tool + ord, idx := call.row.MessageOrdinal, call.row.CallIndex + if ref := canonicalGitHubReference(call.row.Input + "\n" + call.row.Result); ref != "" && (call.failed || isGitHubInvocation(tool, call.row.Input)) { + severity := "low" + if call.failed { + severity = "medium" + } + e := a.evidence(sessionID, firstNonEmptyString(call.row.EventSource, "tool_call"), tool, ref, call.row.EventStatus, &ord, &idx, call.row.DurationMS) + a.add("github-issue|"+ref, "github_issue_reference", tool, ref, severity, "high", "rule", e, call.row.DurationMS, nil, false) + } + if call.failed { + sig := firstIssueLine(call.row.Result, call.row.Input) + key := "failure|" + call.reason + "|" + tool + "|" + canonicalGitHubReference(call.row.Input+"\n"+call.row.Result) + "|" + normalizeIssueText(sig) + e := a.evidence(sessionID, firstNonEmptyString(call.row.EventSource, "tool_result"), tool, sig, call.row.EventStatus, &ord, &idx, call.row.DurationMS) + a.add(key, call.reason, tool, sig, failureSeverity(call.reason), issueFailureConfidence(call.row.EventStatus, call.row.Input, call.row.Result), recommendationFor(call.reason, 1, 1), e, call.row.DurationMS, call.row.DurationMS, call.recovered) + if i+1 < len(rows) && rows[i+1].tool == tool && rows[i+1].normalized == call.normalized { + next := rows[i+1] + nOrd, nIdx := next.row.MessageOrdinal, next.row.CallIndex + e = a.evidence(sessionID, "tool_call", tool, next.row.Input, next.row.EventStatus, &nOrd, &nIdx, next.row.DurationMS) + a.add("retry|"+tool+"|"+call.normalized, "retry_after_failure", tool, next.row.Input, "medium", "high", "script", e, next.row.DurationMS, next.row.DurationMS, !next.failed) + } + } + if eligibleWorkflow(tool, call.row.Input) { + normalized, ok := normalizedInputs[call.row.Input] + if !ok { + normalized = normalizeIssueText(call.row.Input) + normalizedInputs[call.row.Input] = normalized + } + key := tool + "|" + normalized + acc := workflows[key] + if acc == nil { + acc = &workflowAccumulator{firstSession: call.row.SessionID, firstProject: a.sessions[call.row.SessionID].Project} + workflows[key] = acc + } else { + acc.multiSession = acc.multiSession || call.row.SessionID != acc.firstSession + acc.multiProject = acc.multiProject || a.sessions[call.row.SessionID].Project != acc.firstProject + } + acc.rows = append(acc.rows, call) + } + } + for start := 0; start < len(rows); { + end := start + 1 + for end < len(rows) && !rows[end].failed && !rows[start].failed && rows[end].tool == rows[start].tool && rows[end].normalized == rows[start].normalized { + end++ + } + threshold := 3 + if isWaitTool(rows[start].tool) { + threshold = 4 + } + if end-start >= threshold { + call := rows[start] + tool := call.tool + reason := "repeated_polling" + if isReadInvocation(tool, call.row.Input) { + reason = "repeated_read" + } + ord, idx := call.row.MessageOrdinal, call.row.CallIndex + e := a.evidence(sessionID, "tool_call", tool, call.row.Input, call.row.EventStatus, &ord, &idx, call.row.DurationMS) + for n := 0; n < end-start; n++ { + a.add(reason+"|"+tool+"|"+call.normalized, reason, tool, call.row.Input, "low", "high", "script", e, call.row.DurationMS, call.row.DurationMS, false) + } + } + start = end + } + } + for key, workflow := range workflows { + if !workflow.multiSession { + continue + } + recommendation := "script" + if workflow.multiProject { + recommendation = "skill" + } + for _, row := range workflow.rows { + ord, idx := row.row.MessageOrdinal, row.row.CallIndex + e := a.evidence(row.row.SessionID, "tool_call", row.tool, row.row.Input, row.row.EventStatus, &ord, &idx, row.row.DurationMS) + a.add("workflow|"+key, "repeated_workflow", row.tool, row.row.Input, "medium", "high", recommendation, e, row.row.DurationMS, row.row.DurationMS, false) + } + } + addSlowToolFindings(a, calls, toolCounts) + addMessageFindings(a, messages) + addTelemetryFindings(a, telemetry) + findings := a.finish(len(calls), durationCounts) + facets := issueFacets(findings, sessions) + return IssueReviewResponse{GeneratedAt: time.Now().UTC().Format(time.RFC3339), ScannedSessions: len(sessions), ScannedMessages: rawMessages, ScannedToolCalls: rawCalls, AnalyzedMessages: len(messages), AnalyzedToolCalls: len(calls), DuplicateMessages: duplicateMessages, DuplicateToolCalls: duplicateCalls, ScannedTelemetry: len(telemetry), TotalFindings: len(findings), Findings: findings, Facets: facets} +} + +func filterIssueReviewResponse(response IssueReviewResponse, q IssueReviewQuery) IssueReviewResponse { + filtered := make([]IssueReviewFinding, 0, len(response.Findings)) + for _, finding := range response.Findings { + if q.Reason != "" && finding.ReasonCode != q.Reason || q.Tool != "" && finding.Tool != q.Tool || q.Source != "" && !containsIssueString(finding.Sources, q.Source) || q.Severity != "" && finding.Severity != q.Severity || q.Confidence != "" && finding.Confidence != q.Confidence || q.Status != "" && finding.Status != q.Status || q.RecommendationType != "" && finding.RecommendationType != q.RecommendationType || finding.Occurrences < max(1, q.MinOccurrences) || finding.SessionCount < max(1, q.MinSessions) || finding.ProjectCount < q.MinProjects || finding.WastedDurationMS < q.MinWastedDurationMS { + continue + } + filtered = append(filtered, finding) + } + sortIssueFindings(filtered, q.Sort) + totalFindings := len(filtered) + limit := q.Limit + if limit <= 0 { + limit = 50 + } + if limit > 100 { + limit = 100 + } + offset := min(max(0, q.Offset), totalFindings) + end := min(offset+limit, totalFindings) + truncated := end < totalFindings + filtered = filtered[offset:end] + response.TotalFindings = totalFindings + response.Truncated = truncated + response.Findings = filtered + return response +} + +func containsIssueString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func sortIssueFindings(findings []IssueReviewFinding, mode string) { + sort.SliceStable(findings, func(i, j int) bool { + left, right := findings[i], findings[j] + switch mode { + case "frequency": + if left.Occurrences != right.Occurrences { + return left.Occurrences > right.Occurrences + } + case "recent": + if left.LastSeen != right.LastSeen { + return left.LastSeen > right.LastSeen + } + case "waste": + if left.WastedDurationMS != right.WastedDurationMS { + return left.WastedDurationMS > right.WastedDurationMS + } + case "duration": + if left.TotalDurationMS != right.TotalDurationMS { + return left.TotalDurationMS > right.TotalDurationMS + } + default: + if left.rank != right.rank { + return left.rank > right.rank + } + } + return left.ID < right.ID + }) +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func firstIssueLine(result, input string) string { + for _, source := range []string{result, issueCommandInput(input)} { + contentBlocks := strings.Contains(source, `"type"`) && strings.Contains(source, `"text"`) + if decoded := parser.DecodeContent(source); json.Valid([]byte(source)) && decoded != "" { + source = decoded + contentBlocks = false + } + source = strings.ReplaceAll(source, `\r\n`, "\n") + source = strings.ReplaceAll(source, `\r`, "\n") + source = strings.ReplaceAll(source, `\n`, "\n") + candidate := "" + errorLine := "" + for _, line := range strings.Split(source, "\n") { + line = strings.TrimSpace(line) + if contentBlocks { + line = stripIssueContentBlock(line) + } + if line == "" || isIssueWrapperLine(line) { + continue + } + if candidate == "" { + candidate = line + } + if errorLine == "" && errorWordRE.MatchString(line) { + errorLine = line + } + } + if errorLine != "" { + return errorLine + } + if candidate != "" { + return candidate + } + } + return "Tool failure" +} + +func stripIssueContentBlock(line string) string { + if start := strings.Index(line, `{"type"`); start >= 0 && start < 32 { + if text := strings.Index(line[start:], `"text":"`); text >= 0 && text < 96 { + line = line[start+text+len(`"text":"`):] + } + } + for _, suffix := range []string{`"}]`, `"},`, `"}`} { + line = strings.TrimSuffix(line, suffix) + } + return strings.TrimSpace(line) +} + +func isIssueWrapperLine(line string) bool { + lower := strings.ToLower(strings.TrimSpace(line)) + if lower == "script failed" || lower == "script error" || lower == "script error:" || lower == "script completed" || lower == "output:" || lower == "final output:" { + return true + } + for _, prefix := range []string{"wall time:", "wall time ", "process exited with code", "exit code:", "exit code ", "warning: truncated output", "total output lines:"} { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func failureSeverity(reason string) string { + switch reason { + case "permission_auth", "tool_crash", "git_github_ci", "build_test": + return "high" + case "generic_tool_failure", "github_issue_reference", "repeated_read", "repeated_polling": + return "low" + default: + return "medium" + } +} + +func recommendationFor(reason string, sessions, projects int) string { + switch reason { + case "tool_crash", "network", "rate_limit", "timeout", "app_session_error", "tool_router_error": + return "tool_fix" + case "windows_shell", "shell_syntax", "line_endings", "permission_auth", "user_correction", "github_issue_reference": + return "rule" + case "repeated_polling", "repeated_read", "retry_after_failure", "slow_tool", "command_failure": + return "script" + case "repeated_workflow": + if projects > 1 { + return "skill" + } + return "script" + case "repeated_question": + if projects > 1 { + return "skill" + } + return "rule" + default: + if sessions > 1 { + return "skill" + } + return "script" + } +} + +func concreteRecommendation(f IssueReviewFinding) string { + tool := f.Tool + if tool == "" { + tool = "this workflow" + } + switch f.ReasonCode { + case "missing_file": + return "Add a path-existence preflight and resolve the exact working directory before rerunning " + tool + "." + case "missing_dependency": + return "Add a dependency preflight for " + tool + " and print one exact install or fallback command when it is missing." + case "permission_auth": + return "Check authorization and required privileges before " + tool + "; stop before any protected write when the check fails." + case "rate_limit": + return "Honor Retry-After, add bounded exponential backoff, and cache repeated read-only requests made through " + tool + "." + case "network": + return "Add a connectivity preflight and bounded retry with the endpoint and final network error preserved for " + tool + "." + case "timeout": + return "Profile " + tool + ", split oversized work, and replace fixed polling with completion events or a measured timeout." + case "windows_shell", "shell_syntax": + return "Move complex shell logic into a checked script, validate arguments and paths, and propagate the first failing exit code." + case "line_endings": + return "Normalize line endings at the comparison boundary and keep tests portable across Windows and CI." + case "git_github_ci": + return "Run read-only git and GitHub preflights first, preserve the exact failing command, and retry only after repository state changes." + case "github_issue_reference": + return "Open " + f.GitHubReference + ", record whether it blocks the task, and link the chosen workaround or follow-up rule." + case "failed_edit": + return "Re-read the exact target range, apply one smaller context patch, and do not repeat the same edit after an unchanged failure." + case "build_test": + return "Run the narrow failing check first, fix its first stable failure, then rerun the full suite once." + case "tool_crash": + return "Capture the tool version, crash signature, and minimal safe input, then isolate a reproducible tool-level fix." + case "command_failure": + return "Preserve the first failing command and exit code, split compound shell logic, and retry only the failed step after a material change." + case "retry_after_failure": + return "Require a changed input or external state before retrying " + tool + ", then verify the intended outcome explicitly." + case "repeated_read": + return "Cache this stable read or request a narrower range; read it again only after the source changes." + case "repeated_polling": + return "Replace fixed polling with an event-driven wait or bounded backoff and an explicit stop condition." + case "slow_tool": + return "Profile " + tool + " at p95, then batch, cache, or parallelize only the measured slow stage." + case "repeated_workflow": + if f.ProjectCount > 1 { + return "Package this repeated " + tool + " workflow as a reusable skill with preflight, stop conditions, and one verification command." + } + return "Extract this repeated " + tool + " workflow into a project script with idempotent inputs and one verification command." + case "repeated_question": + if f.ProjectCount > 1 { + return "Turn this recurring request into a reusable skill with explicit inputs, scope, and one verification step." + } + return "Add a project rule or request template that fixes the expected scope, output, and verification step." + case "user_correction": + return "Add a rule that confirms scope, expected output, and exclusions before taking the corrected action." + case "reported_blocker": + return "Add a preflight for this blocker and document the smallest safe recovery path before repeating the workflow." + case "response_retry": + return "Measure response retries by cause, cap them, and surface the final provider error instead of silently looping." + case "tool_router_error": + return "Validate the tool name and arguments before routing, and preserve the rejected call shape for diagnosis." + case "hook_failure": + return "Run the hook in isolation, validate its runtime and exit code, and disable repeated unchanged retries." + case "app_session_error": + return "Capture the app session error with its task ID and lifecycle state, then verify recovery in a fresh session." + case "shell_snapshot_failure": + return "Rebuild the shell snapshot once after validating the shell path and startup profile." + default: + return "Preserve the first error from " + tool + ", change one material input before retrying, and verify the intended outcome." + } +} + +func eligibleWorkflow(tool, input string) bool { + if isWaitTool(tool) || isReadInvocation(tool, input) || len(strings.TrimSpace(input)) < 80 { + return false + } + lower := strings.ToLower(strings.TrimSpace(input)) + for _, prefix := range []string{"git status", "pwd", "get-location", "ls", "dir", "rg ", "grep ", "find ", "get-childitem", "get-content"} { + if strings.HasPrefix(lower, prefix) { + return false + } + } + return strings.ContainsAny(input, "\n;|") || len(input) >= 180 +} + +func isWaitTool(tool string) bool { + t := normalizeTool(tool) + return strings.Contains(t, "wait") || t == "sleep" || t == "await" || t == "awaitshell" +} + +func isReadInvocation(tool, input string) bool { + t := normalizeTool(tool) + for _, term := range []string{"read", "view_file", "get_file", "read_mcp_resource"} { + if t == term || strings.Contains(t, "read_file") { + return true + } + } + lower := strings.ToLower(strings.TrimSpace(issueCommandInput(input))) + for _, prefix := range []string{"get-content ", "cat ", "type ", "head ", "tail ", "sed -n "} { + if strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + +func isRecoveryDiagnostic(tool, input string) bool { + t := normalizeTool(tool) + command := "" + if isShellTool(t) { + command = strings.ToLower(strings.TrimSpace(issueCommandInput(input))) + if strings.ContainsAny(command, "\r\n;|&") { + return false + } + } + if isWaitTool(tool) || isReadInvocation(tool, input) || isSearchInvocation(tool, input) { + return true + } + if t == "status" || t == "location" || t == "list" || strings.HasPrefix(t, "get_status") || strings.HasPrefix(t, "get_location") || strings.HasPrefix(t, "list_") { + return true + } + if !isShellTool(t) { + return false + } + for _, diagnostic := range []string{"git status", "pwd", "get-location", "ls", "dir", "get-childitem"} { + if command == diagnostic || strings.HasPrefix(command, diagnostic+" ") { + return true + } + } + return false +} + +func addSlowToolFindings(a *issueAnalyzer, calls []IssueReviewToolCall, toolCounts map[string]int) { + byTool := map[string][]IssueReviewToolCall{} + for _, call := range calls { + tool := effectiveIssueTool(call.Tool, call.Input) + if call.DurationMS != nil && *call.DurationMS >= 0 && !isWaitTool(tool) { + byTool[tool] = append(byTool[tool], call) + } + } + for tool, rows := range byTool { + durations := make([]int64, len(rows)) + var maxDuration int64 + for i, row := range rows { + durations[i] = *row.DurationMS + if durations[i] > maxDuration { + maxDuration = durations[i] + } + } + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + p95 := durations[int(math.Ceil(float64(len(durations))*0.95))-1] + if !(len(rows) >= 3 && p95 >= 30000) && maxDuration < 120000 { + continue + } + severity := "medium" + if maxDuration >= 120000 { + severity = "high" + } + for _, row := range rows { + if *row.DurationMS < 30000 && *row.DurationMS < 120000 { + continue + } + waste := *row.DurationMS - 30000 + if waste < 0 { + waste = 0 + } + ord, idx := row.MessageOrdinal, row.CallIndex + e := a.evidence(row.SessionID, firstNonEmptyString(row.DurationSource, "tool_execution"), tool, row.Input, row.EventStatus, &ord, &idx, row.DurationMS) + a.add("slow|"+tool, "slow_tool", tool, tool, severity, "high", "tool_fix", e, row.DurationMS, &waste, false) + } + acc := a.clusters["slow|"+tool] + acc.finding.DurationSource = firstNonEmptyString(rows[0].DurationSource, "tool_execution") + acc.statsDurations = durations + acc.measuredCalls = len(rows) + acc.toolCalls = toolCounts[tool] + } +} + +func addMessageFindings(a *issueAnalyzer, messages []IssueReviewMessage) { + repeatedQuestions := map[string][]IssueReviewMessage{} + for _, message := range messages { + if message.IsSystem { + continue + } + content := strings.TrimSpace(message.Content) + if isHarnessEnvelope(content) { + continue + } + if ref := canonicalGitHubReference(content); ref != "" { + ord := message.Ordinal + e := a.evidence(message.SessionID, firstNonEmptyString(message.SourceType, "message"), "", ref, "", &ord, nil, nil) + a.add("github-issue|"+ref, "github_issue_reference", "", ref, "low", "high", "rule", e, nil, nil, false) + } + if message.Role == "user" { + if key, ok := repeatedQuestionKey(content); ok { + repeatedQuestions[key] = append(repeatedQuestions[key], message) + } + if !isStrongCorrection(content) { + continue + } + ord := message.Ordinal + e := a.evidence(message.SessionID, "user_message", "", content, "", &ord, nil, nil) + a.add("correction|"+normalizeIssueText(content), "user_correction", "", content, "medium", "medium", "rule", e, nil, nil, false) + continue + } + if message.Role != "assistant" || len(content) < 40 || !isAssistantBlocker(message, content) { + continue + } + reason, ok := classifyIssueReason(content) + if !ok { + reason = "reported_blocker" + } + ord := message.Ordinal + e := a.evidence(message.SessionID, "assistant_commentary", "", content, "", &ord, nil, nil) + a.add("blocker|"+reason+"|"+normalizeIssueText(content), reason, "", content, failureSeverity(reason), "medium", recommendationFor(reason, 1, 1), e, nil, nil, false) + } + for key, rows := range repeatedQuestions { + if len(rows) < 2 { + continue + } + for _, message := range rows { + ord := message.Ordinal + e := a.evidence(message.SessionID, "user_message", "", message.Content, "", &ord, nil, nil) + a.add("question|"+key, "repeated_question", "", message.Content, "low", "high", "rule", e, nil, nil, false) + } + } +} + +func repeatedQuestionKey(content string) (string, bool) { + if isHarnessEnvelope(content) { + return "", false + } + key := normalizeIssueText(content) + return key, len(key) >= 32 && len(strings.Fields(key)) >= 6 +} + +func isHarnessEnvelope(content string) bool { + lower := strings.ToLower(content) + for _, marker := range []string{ + "", "", "", "", "", + "", "", "message type: new_task", "# agents.md instructions", + "perform any necessary follow-up actions in response to the subagent completion above", + "briefly inform the user about the task result", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +func isStrongCorrection(content string) bool { + lower := strings.ToLower(strings.TrimSpace(content)) + for _, term := range correctionTerms { + if strings.HasPrefix(lower, term) || strings.Contains(lower, " "+term) { + return true + } + } + return false +} + +func isAssistantBlocker(message IssueReviewMessage, content string) bool { + lower := strings.ToLower(content) + search := lower + if message.SourceType != "event_msg" || message.SourceSubtype != "commentary" { + if len(search) > 500 { + search = search[:500] + } + } + strong, broad := false, false + for _, term := range blockerTerms { + if strings.Contains(search, term) { + if term == "hit a " || term == "exposed a " || term == "exposed an " { + broad = true + } else { + strong = true + } + } + } + if !strong && (!broad || !blockerPredicateRE.MatchString(search)) { + return false + } + if message.SourceType == "event_msg" && message.SourceSubtype == "commentary" { + return true + } + return message.SourceType == "" && len(content) <= 500 && (strings.HasPrefix(search, "root cause") || strings.HasPrefix(search, "the ") || strings.HasPrefix(search, "git")) +} + +func addTelemetryFindings(a *issueAnalyzer, telemetry []IssueReviewTelemetry) { + for _, row := range telemetry { + if row.DurationMS != nil { + continue + } + reason := telemetryReason(row.Target) + if reason == "" { + continue + } + confidence := "medium" + severity := "medium" + if strings.EqualFold(row.Level, "ERROR") { + confidence, severity = "high", "high" + } + tail := sanitizeTelemetryTail(row.Body) + if tail == "" { + continue + } + e := a.evidence(row.SessionID, "codex_log", "", tail, row.Level, nil, nil, nil) + a.add("log|"+reason+"|"+normalizeIssueText(tail), reason, "", tail, severity, confidence, recommendationFor(reason, 1, 1), e, nil, nil, false) + } +} + +func telemetryReason(target string) string { + switch target { + case "codex_core::responses_retry": + return "response_retry" + case "codex_core::tools::router": + return "tool_router_error" + case "codex_core::hook_runtime": + return "hook_failure" + case "codex_core::session::turn": + return "app_session_error" + case "codex_core::shell_snapshot": + return "shell_snapshot_failure" + default: + return "" + } +} + +func logTail(body string) string { + if i := strings.LastIndex(body, ": "); i >= 0 && i+2 < len(body) { + return body[i+2:] + } + return body +} + +func sanitizeTelemetryTail(body string) string { + tail := strings.TrimSpace(logTail(body)) + if tail == "" { + return "" + } + tail = credentialFieldRE.ReplaceAllString(tail, "$1=") + tail = pathFieldRE.ReplaceAllString(tail, "$1=") + tail = bearerRE.ReplaceAllString(tail, "Bearer ") + tail = windowsPathRE.ReplaceAllString(tail, "") + tail = unixPathRE.ReplaceAllString(tail, " ") + return issueExcerpt(secrets.Redact(tail)) +} + +func issueFacets(findings []IssueReviewFinding, sessions []IssueReviewSession) IssueReviewFacets { + maps := map[string]map[string]int{"category": {}, "tool": {}, "source": {}, "severity": {}, "confidence": {}, "status": {}, "recommendation_type": {}, "session": {}, "folder": {}, "outcome": {}} + labels := map[string]string{} + for _, finding := range findings { + for key, value := range map[string]string{"category": finding.ReasonCode, "tool": finding.Tool, "severity": finding.Severity, "confidence": finding.Confidence, "status": finding.Status, "recommendation_type": finding.RecommendationType} { + if value != "" { + maps[key][value]++ + } + } + for _, source := range finding.Sources { + maps["source"][source]++ + } + } + for _, session := range sessions { + maps["session"][session.ID]++ + label := firstNonEmptyString(strings.Join(strings.Fields(session.Name), " "), session.Project, session.ID) + if session.Date != "" { + label += " · " + session.Date + } + labels[session.ID] = label + if session.CWD != "" { + maps["folder"][session.CWD]++ + } + if session.Outcome != "" { + maps["outcome"][session.Outcome]++ + } + } + out := make(map[string][]IssueFacet, len(maps)) + for key, counts := range maps { + for value, count := range counts { + facet := IssueFacet{Value: value, Count: count} + if key == "session" { + facet.Label = labels[value] + } + out[key] = append(out[key], facet) + } + sort.Slice(out[key], func(i, j int) bool { + if out[key][i].Count != out[key][j].Count { + return out[key][i].Count > out[key][j].Count + } + left, right := out[key][i].Value, out[key][j].Value + if key == "session" { + left, right = out[key][i].Label, out[key][j].Label + } + return left < right + }) + } + return IssueReviewFacets{ + Category: out["category"], Tool: out["tool"], Source: out["source"], Severity: out["severity"], + Confidence: out["confidence"], Status: out["status"], + RecommendationType: out["recommendation_type"], Session: out["session"], Folder: out["folder"], + Outcome: out["outcome"], + } +} + +// GetAnalyticsIssueReview implements the local archive query and optional +// read-only Codex telemetry supplement. +func (db *DB) GetAnalyticsIssueReview(ctx context.Context, f AnalyticsFilter, q IssueReviewQuery) (IssueReviewResponse, error) { + key := IssueReviewCacheKey(f, q) + if cached, ok := db.issueReviewCache.Get(key, q); ok { + return cached, nil + } + sessions, err := db.issueReviewSessions(ctx, f, q) + if err != nil { + return IssueReviewResponse{}, err + } + messages, calls, err := db.issueReviewRows(ctx, sessions) + if err != nil { + return IssueReviewResponse{}, err + } + telemetry, telemetryStatus := db.issueReviewTelemetry(ctx, sessions, calls) + response := AnalyzeIssueReviewBase(sessions, messages, calls, telemetry) + response.TelemetryStatus = telemetryStatus + db.issueReviewCache.Put(key, response) + return filterIssueReviewResponse(response, q), nil +} + +// IssueReviewCacheKey identifies the expensive base-analysis scope. +func IssueReviewCacheKey(f AnalyticsFilter, q IssueReviewQuery) string { + value, _ := json.Marshal(struct { + Filter AnalyticsFilter + SessionID string + Folder string + Outcome string + }{Filter: f, SessionID: q.SessionID, Folder: q.Folder, Outcome: q.Outcome}) + return string(value) +} + +func (db *DB) issueReviewSessions(ctx context.Context, f AnalyticsFilter, q IssueReviewQuery) ([]IssueReviewSession, error) { + dateCol := "COALESCE(NULLIF(started_at, ''), created_at)" + where, args := f.buildWhere(dateCol) + if q.SessionID != "" { + where += " AND id = ?" + args = append(args, q.SessionID) + } + rows, err := db.getReader().QueryContext(ctx, `SELECT id, SUBSTR(COALESCE(NULLIF(display_name,''),NULLIF(session_name,''),NULLIF(first_message,''),NULLIF(project,''),id),1,160), project, cwd, agent, `+dateCol+`, outcome FROM sessions WHERE `+where, args...) + if err != nil { + return nil, fmt.Errorf("querying issue review sessions: %w", err) + } + defer rows.Close() + loc := f.location() + var out []IssueReviewSession + for rows.Next() { + var row IssueReviewSession + var ts string + if err := rows.Scan(&row.ID, &row.Name, &row.Project, &row.CWD, &row.Agent, &ts, &row.Outcome); err != nil { + return nil, fmt.Errorf("scanning issue review session: %w", err) + } + row.Date = localDate(ts, loc) + row.Incomplete = row.Outcome == "errored" || row.Outcome == "abandoned" + if q.Folder != "" && row.CWD != q.Folder || q.Outcome != "" && row.Outcome != q.Outcome { + continue + } + out = append(out, row) + } + return out, rows.Err() +} + +func (db *DB) issueReviewRows(ctx context.Context, sessions []IssueReviewSession) ([]IssueReviewMessage, []IssueReviewToolCall, error) { + ids := make([]string, len(sessions)) + for i, s := range sessions { + ids[i] = s.ID + } + var messages []IssueReviewMessage + var calls []IssueReviewToolCall + err := queryChunkedSize(ids, 400, func(chunk []string) error { + ph, args := inPlaceholders(chunk) + rows, err := db.getReader().QueryContext(ctx, `SELECT session_id, ordinal, role, substr(content,1,?), COALESCE(timestamp,''), is_system, source_type, source_subtype, COALESCE(NULLIF(source_uuid,''),NULLIF(claude_message_id,''),'') FROM messages WHERE session_id IN `+ph+` AND NOT is_system AND `+IssueReviewMessagePredicate("role", "content")+` ORDER BY session_id,ordinal`, append([]any{IssueReviewMessageScanLimit}, args...)...) + if err != nil { + return err + } + for rows.Next() { + var r IssueReviewMessage + if err := rows.Scan(&r.SessionID, &r.Ordinal, &r.Role, &r.Content, &r.Timestamp, &r.IsSystem, &r.SourceType, &r.SourceSubtype, &r.StableID); err != nil { + rows.Close() + return err + } + messages = append(messages, r) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + queryArgs := append([]any{}, args...) + queryArgs = append(queryArgs, IssueReviewInputLimit, IssueReviewResultEdgeLimit) + queryArgs = append(queryArgs, args...) + result := "COALESCE(es.content,tc.result_content,'')" + rows, err = db.getReader().QueryContext(ctx, `WITH events AS ( + SELECT tre.*, + ROW_NUMBER() OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index ORDER BY tre.event_index DESC,tre.id DESC) AS latest_rank, + MIN(CASE WHEN tre.source='tool_execution' AND tre.status='started' THEN tre.timestamp END) OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index) AS started, + MAX(CASE WHEN tre.source='tool_execution' AND tre.status IN ('completed','errored') THEN tre.timestamp END) OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index) AS ended + FROM tool_result_events tre WHERE tre.session_id IN `+ph+` + ), event_summary AS ( + SELECT session_id,tool_call_message_ordinal,call_index,content,status,source,started,ended FROM events WHERE latest_rank=1 + ) + SELECT tc.session_id,m.ordinal,COALESCE(tc.call_index,0),tc.tool_name,tc.category,COALESCE(tc.tool_use_id,''),substr(COALESCE(tc.input_json,''),1,?),substr(`+result+`,1,?),CASE WHEN `+IssueReviewTailPredicate("es.status", result)+` THEN substr(`+result+`,-`+strconv.Itoa(IssueReviewResultEdgeLimit)+`) ELSE '' END,COALESCE(es.status,''),COALESCE(es.source,''),COALESCE(m.timestamp,''),es.started,es.ended + FROM tool_calls tc JOIN messages m ON m.id=tc.message_id + LEFT JOIN event_summary es ON es.session_id=tc.session_id AND es.tool_call_message_ordinal=m.ordinal AND es.call_index=COALESCE(tc.call_index,0) + WHERE tc.session_id IN `+ph+` ORDER BY tc.session_id,m.ordinal,tc.call_index`, queryArgs...) + if err != nil { + return err + } + for rows.Next() { + var r IssueReviewToolCall + var resultHead, resultTail string + var started, ended sql.NullString + if err := rows.Scan(&r.SessionID, &r.MessageOrdinal, &r.CallIndex, &r.Tool, &r.Category, &r.ToolUseID, &r.Input, &resultHead, &resultTail, &r.EventStatus, &r.EventSource, &r.Timestamp, &started, &ended); err != nil { + rows.Close() + return err + } + r.Result = JoinIssueReviewResult(resultHead, resultTail) + r.DurationMS = IssueDuration(started.String, ended.String) + if r.DurationMS != nil { + r.DurationSource = "tool_execution" + } + calls = append(calls, r) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + return rows.Close() + }) + return messages, calls, err +} + +// IssueDuration returns a measured event duration when both timestamps exist. +func IssueDuration(started, ended string) *int64 { + if started == "" || ended == "" { + return nil + } + a, err := time.Parse(time.RFC3339Nano, started) + if err != nil { + return nil + } + b, err := time.Parse(time.RFC3339Nano, ended) + if err != nil || b.Before(a) { + return nil + } + v := b.Sub(a).Milliseconds() + return &v +} + +func (db *DB) issueReviewTelemetry(ctx context.Context, sessions []IssueReviewSession, calls []IssueReviewToolCall) ([]IssueReviewTelemetry, string) { + home, err := os.UserHomeDir() + if err != nil { + return nil, "unavailable" + } + return readIssueReviewTelemetry(ctx, filepath.Join(home, ".codex", "logs_2.sqlite"), sessions, calls) +} + +func readIssueReviewTelemetry(ctx context.Context, path string, sessions []IssueReviewSession, calls []IssueReviewToolCall) ([]IssueReviewTelemetry, string) { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, "missing" + } + return nil, "unavailable" + } + conn, err := sql.Open("sqlite3", makeDSN(path, true)) + if err != nil { + return nil, "unavailable" + } + defer conn.Close() + ids := make([]string, len(sessions)) + allowed := make(map[string]bool, len(sessions)) + for i, s := range sessions { + ids[i] = s.ID + allowed[s.ID] = true + } + callByID := map[string]*IssueReviewToolCall{} + for i := range calls { + if calls[i].ToolUseID != "" { + callByID[calls[i].SessionID+"|"+calls[i].ToolUseID] = &calls[i] + } + } + var out []IssueReviewTelemetry + err = queryChunkedSize(ids, 400, func(chunk []string) error { + ph, args := inPlaceholders(chunk) + rows, err := conn.QueryContext(ctx, `SELECT COALESCE(thread_id,''),target,level,COALESCE(feedback_log_body,''),ts FROM logs WHERE thread_id IN `+ph+` AND (target='codex_core::tools::parallel' OR target IN ('codex_core::responses_retry','codex_core::tools::router','codex_core::hook_runtime','codex_core::session::turn','codex_core::shell_snapshot')) AND (target='codex_core::tools::parallel' OR level IN ('WARN','ERROR')) ORDER BY ts,ts_nanos,id`, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var r IssueReviewTelemetry + var unix int64 + if err := rows.Scan(&r.SessionID, &r.Target, &r.Level, &r.Body, &unix); err != nil { + return err + } + if !allowed[r.SessionID] { + continue + } + r.Timestamp = time.Unix(unix, 0).UTC().Format(time.RFC3339) + if r.Target == "codex_core::tools::parallel" { + fields := parseLogFields(r.Body) + if !strings.Contains(r.Body, "tool call completed") { + continue + } + r.Tool, r.CallID = fields["tool_name"], fields["call_id"] + ms, err := strconv.ParseInt(fields["total_duration_ms"], 10, 64) + if err != nil || ms < 0 { + continue + } + r.DurationMS = &ms + call := callByID[r.SessionID+"|"+r.CallID] + if call == nil { + continue + } + call.DurationMS = &ms + call.DurationSource = "codex_log" + continue + } + out = append(out, r) + } + return rows.Err() + }) + if err != nil { + return nil, "unavailable" + } + return out, "available" +} + +func parseLogFields(body string) map[string]string { + out := map[string]string{} + for _, m := range logFieldRE.FindAllStringSubmatch(body, -1) { + value := m[2] + if value == "" { + value = m[3] + } + out[m[1]] = value + } + return out +} diff --git a/internal/db/issue_review_test.go b/internal/db/issue_review_test.go new file mode 100644 index 0000000000..c9e91a3198 --- /dev/null +++ b/internal/db/issue_review_test.go @@ -0,0 +1,696 @@ +package db + +import ( + "context" + "database/sql" + "math" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClassifyIssueFailure(t *testing.T) { + tests := []struct { + name, tool, status, input, result, reason string + failed bool + }{ + {"successful apply patch", "apply_patch", "completed", "apply_patch failed_edit", "Done!", "", false}, + {"successful psql", "shell_command", "success", "psql -f migration.sql", "INSERT 0 1", "", false}, + {"successful GitHub call", "shell_command", "completed", "gh api repos/example", "github response received", "", false}, + {"successful credential check", "shell_command", "succeeded", "check credential", "credential is present", "", false}, + {"successful wrapped tool discovery", "exec", "completed", "inspect tool schema", "Script completed\nOutput: timeout_ms controls the request timeout", "", false}, + {"successful documentation output", "webfetch", "completed", "fetch documentation", "Error handling, failed retries, and timeout configuration", "", false}, + {"successful read containing error", "read_file", "completed", "read source", "error: this is example source text", "", false}, + {"successful read containing test summary", "read_file", "completed", "read log", "3 tests failed, 10 passed", "", false}, + {"quoted error with exit zero", "shell_command", "", "run build", `output="error: quoted text" process exited with code 0`, "", false}, + {"completed ParserError", "shell_command", "completed", "powershell command", "ParserError: unexpected token", "windows_shell", true}, + {"completed exit code one", "shell_command", "completed", "run command", "Process exited with code 1", "command_failure", true}, + {"lowercase wrapped failure", "shell_command", "completed", "run command", "script failed\nexit code: 2\noutput:\nAccess is denied", "permission_auth", true}, + {"completed invalid context", "apply_patch", "completed", "apply_patch", "Invalid Context 42", "failed_edit", true}, + {"nonzero wins over exit zero", "shell_command", "completed", "run command", "Process exited with code 0; Process exited with code 1", "command_failure", true}, + {"plain successful output", "apply_patch", "", "apply_patch", "patch applied", "", false}, + {"failed patch", "apply_patch", "errored", "apply_patch", "invalid context", "failed_edit", true}, + {"failed psql", "shell_command", "errored", "psql -f migration.sql", "relation exists", "build_test", true}, + {"failed GitHub call", "shell_command", "error", "gh api repos/example", "request rejected", "git_github_ci", true}, + {"input words do not choose failure family", "exec", "error", "tool schema mentions timeout and network", "request rejected", "command_failure", true}, + {"bash quoting failure", "shell_command", "error", "bash script", "unexpected EOF while looking for matching `'`", "shell_syntax", true}, + {"nonzero PowerShell", "shell_command", "", "powershell command", "ParserError: process exited with code 1", "windows_shell", true}, + {"search no match", "rg", "errored", `rg "error" files`, "process exited with code 1", "", false}, + {"shell search no match", "shell_command", "completed", `{"command":"rg missing files"}`, "Script failed\nExit code: 1", "", false}, + {"shell search real error", "shell_command", "completed", `{"command":"rg missing absent-dir"}`, "absent-dir: no such file or directory\nExit code: 2", "missing_file", true}, + {"logical test failure with exit zero", "shell_command", "completed", "run tests", "3 tests failed, 10 passed\nProcess exited with code 0", "build_test", true}, + {"GitHub API failure with exit zero", "shell_command", "completed", "gh api repos/example/issues/42", "HTTP status 500\nProcess exited with code 0", "git_github_ci", true}, + {"GitHub issue failure", "shell_command", "error", "inspect https://github.com/example/project/issues/42", "request rejected", "git_github_ci", true}, + {"cancelled", "tool", "cancelled", "operation", "", "generic_tool_failure", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, failed := ClassifyIssueFailure(tt.tool, tt.status, tt.input, tt.result) + assert.Equal(t, tt.failed, failed) + assert.Equal(t, tt.reason, reason) + }) + } +} + +func TestHasLogicalFailure(t *testing.T) { + tests := []struct { + name, input, result string + want bool + }{ + {"failed test count", "run tests", "3 tests failed, 10 passed", true}, + {"npm error line", "npm test", "npm ERR! lifecycle failed", true}, + {"fatal line", "git fetch", "fatal: repository unavailable", true}, + {"HTTP failure", "request API", "HTTP status 500", true}, + {"unrelated status", "show status", "status 500", false}, + {"successful tests", "run tests", "10 tests passed", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, hasLogicalFailure(tt.input, strings.ToLower(tt.result))) + }) + } +} + +func TestCanonicalGitHubReference(t *testing.T) { + tests := []struct { + name, input, want string + }{ + {"URL", "https://github.com/Owner/Repo/issues/42", "owner/repo#42"}, + {"mixed-case URL", "HTTPS://GitHub.Com/Owner/Repo/Issues/43", "owner/repo#43"}, + {"short reference", "Owner/Repo#44", "owner/repo#44"}, + {"URL takes priority", "https://github.com/one/repo/issues/45 and two/repo#46", "one/repo#45"}, + {"unrelated", "build completed", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, canonicalGitHubReference(tt.input)) + }) + } +} + +func TestFirstIssueLineSkipsExecutionWrapper(t *testing.T) { + result := "Script failed\nWall time: 0.4 seconds\nProcess exited with code 1\nFinal output:\nParserError: unexpected token" + assert.Equal(t, "ParserError: unexpected token", firstIssueLine(result, "run command")) + blocks := `[{"type":"text","text":"Script failed\r\nWall time 0.2 seconds\r\nScript error:\r\nExit code 1\r\nFinal output:\r\nParserError: unexpected token"}]` + assert.Equal(t, "ParserError: unexpected token", firstIssueLine(blocks, "run command")) + truncatedBlocks := `[{"type":"input_text","text":"Script failed\nWall time 0.2 seconds\n"},{"type":"input_text","text":"Script error:\nExit code 1\nFinal output:\nParserError: truncated content block` + assert.Equal(t, "ParserError: truncated content block", firstIssueLine(truncatedBlocks, "run command")) + assert.Equal(t, "ParserError: unexpected token", firstIssueLine(`Script failed\r\nWall time 0.2 seconds\r\nExit code 1\r\nFinal output:\r\nParserError: unexpected token`, "run command")) + assert.Equal(t, "run command", firstIssueLine("Script failed\nExit code: 1", `{"command":"run command"}`)) + assert.Equal(t, "fatal: repository unavailable", firstIssueLine("Preparing repository checkout\nfatal: repository unavailable", "git fetch")) +} + +func TestJoinIssueReviewResultPreservesFailureTail(t *testing.T) { + result := JoinIssueReviewResult(strings.Repeat("progress ", 200), "ParserError: failure near the tail") + assert.Equal(t, "ParserError: failure near the tail", firstIssueLine(result, "run command")) +} + +func TestIssueFailureConfidencePrefersStructuredEvidence(t *testing.T) { + assert.Equal(t, "high", issueFailureConfidence("errored", "run", "request rejected")) + assert.Equal(t, "high", issueFailureConfidence("completed", "run", "Exit code: 2")) + assert.Equal(t, "medium", issueFailureConfidence("completed", "run", "ParserError: unexpected token")) +} + +func TestEffectiveIssueTool(t *testing.T) { + tests := []struct { + name, tool, input, want string + }{ + {"direct tool", "shell_command", "go test ./...", "shell_command"}, + {"single nested tool", "exec", "const r = await tools.shell_command({command: \"go test ./...\"}); text(r)", "shell_command"}, + {"repeated same nested tool", "functions.exec", "await Promise.all([tools.view_image(a), tools.view_image(b)])", "view_image"}, + {"mixed nested tools", "exec", "await Promise.all([tools.shell_command(a), tools.view_image(b)])", "exec"}, + {"tool discovery wrapper", "exec", "ALL_TOOLS.filter(x => x.name.includes('git'))", "exec"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, effectiveIssueTool(tt.tool, tt.input)) + }) + } + + response := AnalyzeIssueReview( + []IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}}, + nil, + []IssueReviewToolCall{{SessionID: "s1", Tool: "exec", Input: "await tools.apply_patch(patch)", Result: "Invalid Context 42", EventStatus: "errored"}}, + nil, + IssueReviewQuery{Limit: 100}, + ) + findings := findingsByReason(response.Findings)["failed_edit"] + require.Len(t, findings, 1) + assert.Equal(t, "apply_patch", findings[0].Tool) +} + +func TestSanitizeTelemetryTail(t *testing.T) { + raw := `router failed: error="denied" token="quoted-secret" credential=plain-secret path="C:\Users\alice\private.txt" cwd=/home/alice/private Bearer bearer-secret` + got := sanitizeTelemetryTail(raw) + for _, secret := range []string{"quoted-secret", "plain-secret", `C:\Users\alice`, "/home/alice", "bearer-secret"} { + assert.NotContains(t, got, secret) + } + assert.Contains(t, got, "token=") + assert.Contains(t, got, "credential=") + assert.Contains(t, got, "path=") + assert.Contains(t, got, "cwd=") +} + +func TestNormalizeIssueTextCollapsesVolatileValues(t *testing.T) { + left := `RUN "C:\work\alpha\build.ps1" "/home/alice/run/" 2026-08-09 123.45 915e83b` + right := `run "C:\work\beta\build.ps1" "/srv/build/run/" 2025-01-02 999.10 abcdef0` + assert.Equal(t, normalizeIssueText(left), normalizeIssueText(right)) + assert.Equal(t, `run "" "" #-#-# # #`, normalizeIssueText(left)) +} + +func TestAnalyzeIssueReviewRedactsFindingText(t *testing.T) { + secretValue := "sk-ant-api03-" + "Nc6Mp1Hj9Bg3Tf5Ds8Lr0E" + escapedValue := "N5LWA1Fcx0KoUYBsEedwj2PMOphtXgC6aRkv3DJQ" + telemetrySecret := "telemetry-secret-value" + windowsPath := `C:\Users\alice\private\build.log` + windowsSlashPath := "C:/Users/alice/private/build.log" + unixPath := "/home/alice/private/build.log" + response := AnalyzeIssueReview( + []IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}}, + nil, + []IssueReviewToolCall{{SessionID: "s1", Tool: "shell_command", Input: `{"command":"KEY=\"` + escapedValue + `\"; run ` + windowsPath + `"}`, Result: "error: token=" + secretValue + " paths=" + windowsSlashPath + " and " + unixPath, EventStatus: "errored"}}, + []IssueReviewTelemetry{{SessionID: "s1", Target: "codex_core::tools::router", Level: "ERROR", Body: "router failed token=" + telemetrySecret}}, + IssueReviewQuery{Limit: 100}, + ) + require.NotEmpty(t, response.Findings) + for _, finding := range response.Findings { + for _, private := range []string{secretValue, escapedValue, telemetrySecret, windowsPath, windowsSlashPath, unixPath} { + assert.NotContains(t, finding.Signature, private) + assert.NotContains(t, finding.Recommendation, private) + } + for _, evidence := range finding.Evidence { + for _, private := range []string{secretValue, escapedValue, telemetrySecret, windowsPath, windowsSlashPath, unixPath} { + assert.NotContains(t, evidence.Excerpt, private) + } + } + } +} + +func TestParseLogFieldsQuotedUnquotedAndMalformed(t *testing.T) { + fields := parseLogFields(`tool_name="shell command" call_id=call-1 total_duration_ms="31000" malformed="unterminated`) + assert.Equal(t, "shell command", fields["tool_name"]) + assert.Equal(t, "call-1", fields["call_id"]) + assert.Equal(t, "31000", fields["total_duration_ms"]) + assert.Equal(t, `"unterminated`, fields["malformed"]) + + fields = parseLogFields(`call_id=call-2 total_duration_ms=not-a-number`) + assert.Equal(t, "not-a-number", fields["total_duration_ms"]) +} + +func TestAnalyzeIssueReviewRecoveryAndOptimizationStatus(t *testing.T) { + sessions := []IssueReviewSession{ + {ID: "s1", Project: "alpha", Date: "2026-08-01"}, + {ID: "s2", Project: "beta", Date: "2026-08-02"}, + } + longInput := strings.Repeat("deploy verification step; ", 9) + calls := []IssueReviewToolCall{ + {SessionID: "s1", Tool: "apply_patch", Input: "replace expected block in file", Result: "invalid context", EventStatus: "errored", MessageOrdinal: 1}, + {SessionID: "s1", Tool: "apply_patch", Input: "replace expected block in file", Result: "Done!", EventStatus: "completed", MessageOrdinal: 2}, + {SessionID: "s1", Tool: "status", Input: "check", Result: "ok", EventStatus: "completed", MessageOrdinal: 3}, + {SessionID: "s1", Tool: "status", Input: "check", Result: "ok", EventStatus: "completed", MessageOrdinal: 4}, + {SessionID: "s1", Tool: "status", Input: "check", Result: "ok", EventStatus: "completed", MessageOrdinal: 5}, + {SessionID: "s1", Tool: "read_file", Input: `{"path":"notes.md"}`, Result: "ok", EventStatus: "completed", MessageOrdinal: 6}, + {SessionID: "s1", Tool: "read_file", Input: `{"path":"notes.md"}`, Result: "ok", EventStatus: "completed", MessageOrdinal: 7}, + {SessionID: "s1", Tool: "read_file", Input: `{"path":"notes.md"}`, Result: "ok", EventStatus: "completed", MessageOrdinal: 8}, + {SessionID: "s1", Tool: "shell_command", Input: longInput, Result: "ok", EventStatus: "completed", MessageOrdinal: 9}, + {SessionID: "s1", Tool: "shell_command", Input: "open missing file", Result: "file not found", EventStatus: "errored", MessageOrdinal: 10}, + {SessionID: "s1", Tool: "status", Input: "repair state", Result: "ok", EventStatus: "completed", MessageOrdinal: 11}, + {SessionID: "s1", Tool: "shell_command", Input: "open missing file", Result: "ok", EventStatus: "completed", MessageOrdinal: 12}, + {SessionID: "s2", Tool: "shell_command", Input: longInput, Result: "ok", EventStatus: "completed", MessageOrdinal: 1}, + } + response := AnalyzeIssueReview(sessions, nil, calls, nil, IssueReviewQuery{Limit: 100}) + byReason := findingsByReason(response.Findings) + require.NotEmpty(t, byReason["failed_edit"]) + assert.Equal(t, "recovered", byReason["failed_edit"][0].Status) + assert.True(t, byReason["failed_edit"][0].Evidence[0].Recovered) + require.NotEmpty(t, byReason["repeated_polling"]) + assert.Equal(t, "observed", byReason["repeated_polling"][0].Status) + assert.False(t, byReason["repeated_polling"][0].Evidence[0].Recovered) + require.NotEmpty(t, byReason["repeated_read"]) + assert.Contains(t, byReason["repeated_read"][0].Recommendation, "Cache this stable read") + require.NotEmpty(t, byReason["repeated_workflow"]) + assert.Equal(t, "recurring", byReason["repeated_workflow"][0].Status) + assert.False(t, byReason["repeated_workflow"][0].Evidence[0].Recovered) + assert.Equal(t, "skill", byReason["repeated_workflow"][0].RecommendationType) + require.NotEmpty(t, byReason["missing_file"]) + assert.Equal(t, "recovered", byReason["missing_file"][0].Status) + assert.True(t, byReason["missing_file"][0].Evidence[0].Recovered) +} + +func TestAnalyzeIssueReviewRecoveryAllowsDiagnostics(t *testing.T) { + calls := []IssueReviewToolCall{ + {SessionID: "s1", Tool: "shell_command", Input: `{"command":"open missing file","timeout_ms":1000}`, Result: "file not found", EventStatus: "errored", MessageOrdinal: 1}, + {SessionID: "s1", Tool: "read_file", Input: `{"path":"notes.md"}`, Result: "ok", EventStatus: "completed", MessageOrdinal: 2}, + {SessionID: "s1", Tool: "status", Input: "check", Result: "ok", EventStatus: "completed", MessageOrdinal: 3}, + {SessionID: "s1", Tool: "shell_command", Input: `{"timeout_ms":3000,"command":"open missing file"}`, Result: "ok", EventStatus: "completed", MessageOrdinal: 4}, + } + response := AnalyzeIssueReview([]IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}}, nil, calls, nil, IssueReviewQuery{Limit: 100}) + findings := findingsByReason(response.Findings)["missing_file"] + require.Len(t, findings, 1) + assert.Equal(t, "recovered", findings[0].Status) + assert.True(t, findings[0].Evidence[0].Recovered) +} + +func TestAnalyzeIssueReviewRecoveryStopsAtMutation(t *testing.T) { + for _, tool := range []string{"apply_patch", "write_file"} { + t.Run(tool, func(t *testing.T) { + calls := []IssueReviewToolCall{ + {SessionID: "s1", Tool: "shell_command", Input: `{"command":"open missing file"}`, Result: "file not found", EventStatus: "errored", MessageOrdinal: 1}, + {SessionID: "s1", Tool: tool, Input: "change file", Result: "ok", EventStatus: "completed", MessageOrdinal: 2}, + {SessionID: "s1", Tool: "shell_command", Input: `{"command":"open missing file"}`, Result: "ok", EventStatus: "completed", MessageOrdinal: 3}, + } + response := AnalyzeIssueReview([]IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}}, nil, calls, nil, IssueReviewQuery{Limit: 100}) + findings := findingsByReason(response.Findings)["missing_file"] + require.Len(t, findings, 1) + assert.NotEqual(t, "recovered", findings[0].Status) + assert.False(t, findings[0].Evidence[0].Recovered) + }) + } +} + +func TestIsAssistantBlockerRequiresConcreteBroadFailure(t *testing.T) { + message := IssueReviewMessage{SourceType: "event_msg", SourceSubtype: "commentary"} + assert.False(t, isAssistantBlocker(message, "The project hit a major milestone and the release remains on schedule.")) + assert.True(t, isAssistantBlocker(message, "The database dump finished, but checkpoint finalization hit a local PowerShell argument bug while reading its count manifest.")) + assert.True(t, isAssistantBlocker(message, "The drill exposed a normal isolated-container setup issue before restore.")) +} + +func TestAnalyzeIssueReviewFlagsOnlyPersistentRepeatedWaits(t *testing.T) { + tests := []struct { + name, wantReason string + count int + }{ + {name: "three waits remain normal", count: 3}, + {name: "four waits are persistent polling", count: 4, wantReason: "repeated_polling"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := make([]IssueReviewToolCall, tt.count) + for i := range calls { + calls[i] = IssueReviewToolCall{SessionID: "s1", Tool: "wait", Input: "job-1", Result: "still running", EventStatus: "completed", MessageOrdinal: i + 1} + } + response := AnalyzeIssueReview([]IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}}, nil, calls, nil, IssueReviewQuery{Limit: 100}) + findings := findingsByReason(response.Findings)["repeated_polling"] + if tt.wantReason == "" { + assert.Empty(t, findings) + return + } + require.Len(t, findings, 1) + assert.Equal(t, tt.wantReason, findings[0].ReasonCode) + assert.Equal(t, tt.count, findings[0].Occurrences) + }) + } +} + +func TestAnalyzeIssueReviewDeduplicatesImportedCopies(t *testing.T) { + sessions := []IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}, {ID: "s2", Project: "alpha", Date: "2026-08-02"}} + messages := []IssueReviewMessage{ + {SessionID: "s1", Role: "user", StableID: "message-1", Content: "Initial request"}, + {SessionID: "s2", Role: "user", StableID: "message-1", Content: "Initial request"}, + {SessionID: "s1", Role: "user", StableID: "message-2", Content: "something is off"}, + {SessionID: "s2", Role: "user", StableID: "message-2", Content: "something is off"}, + } + calls := []IssueReviewToolCall{ + {SessionID: "s1", Tool: "shell_command", ToolUseID: "call-1", Input: "run", Result: "file not found", EventStatus: "errored"}, + {SessionID: "s2", Tool: "shell_command", ToolUseID: "call-1", Input: "run", Result: "file not found", EventStatus: "errored"}, + } + + response := AnalyzeIssueReview(sessions, messages, calls, nil, IssueReviewQuery{Limit: 100}) + assert.Equal(t, 4, response.ScannedMessages) + assert.Equal(t, 2, response.AnalyzedMessages) + assert.Equal(t, 2, response.DuplicateMessages) + assert.Equal(t, 2, response.ScannedToolCalls) + assert.Equal(t, 1, response.AnalyzedToolCalls) + assert.Equal(t, 1, response.DuplicateToolCalls) + require.Len(t, findingsByReason(response.Findings)["missing_file"], 1) + assert.Equal(t, 1, findingsByReason(response.Findings)["missing_file"][0].Occurrences) +} + +func TestAnalyzeIssueReviewGroupsGitHubReferences(t *testing.T) { + sessions := []IssueReviewSession{{ID: "s1", Project: "alpha", Date: "2026-08-01"}, {ID: "s2", Project: "beta", Date: "2026-08-02"}} + calls := []IssueReviewToolCall{ + {SessionID: "s1", Tool: "shell_command", ToolUseID: "call-1", Input: "gh issue view https://github.com/Owner/Repo/issues/42", Result: "ok", EventStatus: "completed"}, + {SessionID: "s2", Tool: "shell_command", ToolUseID: "call-2", Input: "gh issue view owner/repo#42", Result: "ok", EventStatus: "completed"}, + {SessionID: "s2", Tool: "shell_command", ToolUseID: "call-3", Input: "gh issue view owner/repo#43", Result: "ok", EventStatus: "completed"}, + } + + response := AnalyzeIssueReview(sessions, nil, calls, nil, IssueReviewQuery{Limit: 100}) + findings := findingsByReason(response.Findings)["github_issue_reference"] + require.Len(t, findings, 2) + byReference := map[string]IssueReviewFinding{} + for _, finding := range findings { + byReference[finding.GitHubReference] = finding + } + assert.Equal(t, 2, byReference["owner/repo#42"].Occurrences) + assert.Equal(t, 1, byReference["owner/repo#43"].Occurrences) + assert.Contains(t, byReference["owner/repo#42"].Recommendation, "owner/repo#42") +} + +func TestAnalyzeIssueReviewScansLongCorrectionsAndCommentary(t *testing.T) { + session := IssueReviewSession{ID: "s1", Project: "alpha", Date: "2026-08-01"} + messages := []IssueReviewMessage{ + {SessionID: "s1", Role: "user", Content: "Initial request", Ordinal: 0}, + {SessionID: "s1", Role: "user", Content: strings.Repeat("context ", 200) + "something is off with that result", Ordinal: 1}, + {SessionID: "s1", Role: "assistant", Content: strings.Repeat("detail ", 200) + "root cause confirmed: the command failed because there is no such file or directory", Ordinal: 2, SourceType: "event_msg", SourceSubtype: "commentary"}, + } + + response := AnalyzeIssueReview([]IssueReviewSession{session}, messages, nil, nil, IssueReviewQuery{Limit: 100}) + byReason := findingsByReason(response.Findings) + require.NotEmpty(t, byReason["user_correction"]) + require.NotEmpty(t, byReason["missing_file"]) +} + +func TestAnalyzeIssueReviewFindsRepeatedUserRequests(t *testing.T) { + sessions := []IssueReviewSession{ + {ID: "s1", Project: "alpha", Date: "2026-08-01"}, + {ID: "s2", Project: "beta", Date: "2026-08-02"}, + {ID: "s3", Project: "beta", Date: "2026-08-03"}, + } + messages := []IssueReviewMessage{ + {SessionID: "s1", Role: "user", Content: "Please audit project 123 and suggest a reusable verification workflow", Ordinal: 0}, + {SessionID: "s2", Role: "user", Content: "Please audit project 456 and suggest a reusable verification workflow", Ordinal: 0}, + {SessionID: "s3", Role: "user", Content: "Please audit project 789 but only summarize the current test output", Ordinal: 0}, + {SessionID: "s3", Role: "user", Content: "repeated injected context that must be ignored", Ordinal: 1}, + } + + response := AnalyzeIssueReview(sessions, messages, nil, nil, IssueReviewQuery{Reason: "repeated_question", Limit: 100}) + require.Len(t, response.Findings, 1) + finding := response.Findings[0] + assert.Equal(t, 2, finding.Occurrences) + assert.Equal(t, 2, finding.SessionCount) + assert.Equal(t, 2, finding.ProjectCount) + assert.Equal(t, "high", finding.Confidence) + assert.Equal(t, "skill", finding.RecommendationType) +} + +func TestAnalyzeIssueReviewIgnoresHarnessEnvelopes(t *testing.T) { + sessions := []IssueReviewSession{{ID: "s1"}, {ID: "s2"}} + tests := []struct { + name, marker string + }{ + {name: "task notification", marker: ""}, + {name: "subagent notification", marker: ""}, + {name: "follow-up instruction", marker: "Perform any necessary follow-up actions in response to the subagent completion above"}, + {name: "brief result instruction", marker: "Briefly inform the user about the task result"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := tt.marker + " repeated orchestration request across chats" + messages := []IssueReviewMessage{ + {SessionID: "s1", Role: "user", Content: content}, + {SessionID: "s2", Role: "user", Content: content}, + } + response := AnalyzeIssueReview(sessions, messages, nil, nil, IssueReviewQuery{Reason: "repeated_question", Limit: 100}) + assert.Empty(t, response.Findings) + }) + } +} + +func TestAnalyzeIssueReviewSlowToolUsesAllMeasuredSamples(t *testing.T) { + session := IssueReviewSession{ID: "s1", Project: "alpha", Date: "2026-08-01"} + durations := []*int64{ms(10000), ms(20000), ms(31000), ms(40000), ms(130000), nil, nil, ms(-1)} + calls := make([]IssueReviewToolCall, len(durations)) + for i, duration := range durations { + calls[i] = IssueReviewToolCall{SessionID: "s1", Tool: "builder", Input: "run step " + string(rune('a'+i)), Result: "ok", EventStatus: "completed", MessageOrdinal: i + 1, DurationMS: duration} + } + response := AnalyzeIssueReview([]IssueReviewSession{session}, nil, calls, nil, IssueReviewQuery{Limit: 100}) + findings := findingsByReason(response.Findings)["slow_tool"] + require.Len(t, findings, 1) + finding := findings[0] + assert.Equal(t, 3, finding.Occurrences) + require.NotNil(t, finding.P95DurationMS) + assert.EqualValues(t, 130000, *finding.P95DurationMS) + assert.InDelta(t, 5.0/8.0, finding.DurationCoverage, 0.0001) + assert.Equal(t, "high", finding.Severity) + assert.EqualValues(t, 201000, finding.TotalDurationMS) + assert.EqualValues(t, 111000, finding.WastedDurationMS) + assert.False(t, finding.Evidence[0].Recovered) + assert.False(t, math.Signbit(finding.DurationCoverage)) +} + +func TestFilterIssueReviewResponseControlsAndPagination(t *testing.T) { + response := IssueReviewResponse{Findings: []IssueReviewFinding{ + {ID: "impact", ReasonCode: "missing_file", Tool: "shell_command", Sources: []string{"tool_result"}, Severity: "high", Confidence: "high", Status: "recurring", RecommendationType: "skill", Occurrences: 5, SessionCount: 3, ProjectCount: 2, WastedDurationMS: 900, TotalDurationMS: 900, LastSeen: "2026-08-01", rank: 500}, + {ID: "frequency", ReasonCode: "timeout", Tool: "exec", Sources: []string{"codex_log"}, Severity: "medium", Confidence: "high", Status: "observed", RecommendationType: "tool_fix", Occurrences: 10, SessionCount: 2, ProjectCount: 1, WastedDurationMS: 100, TotalDurationMS: 100, LastSeen: "2026-08-02", rank: 400}, + {ID: "recent", ReasonCode: "network", Tool: "webfetch", Sources: []string{"tool_result"}, Severity: "low", Confidence: "medium", Status: "open", RecommendationType: "rule", Occurrences: 3, SessionCount: 1, ProjectCount: 1, WastedDurationMS: 200, TotalDurationMS: 200, LastSeen: "2026-08-05", rank: 300}, + {ID: "waste", ReasonCode: "missing_dependency", Tool: "shell_command", Sources: []string{"tool_execution"}, Severity: "medium", Confidence: "medium", Status: "recovered", RecommendationType: "script", Occurrences: 2, SessionCount: 1, ProjectCount: 1, WastedDurationMS: 5000, TotalDurationMS: 500, LastSeen: "2026-08-03", rank: 200}, + {ID: "duration", ReasonCode: "build_test", Tool: "shell_command", Sources: []string{"tool_execution"}, Severity: "high", Confidence: "high", Status: "open", RecommendationType: "script", Occurrences: 2, SessionCount: 1, ProjectCount: 1, WastedDurationMS: 300, TotalDurationMS: 9000, LastSeen: "2026-08-04", rank: 100}, + }} + + filtered := filterIssueReviewResponse(response, IssueReviewQuery{ + Reason: "missing_file", Tool: "shell_command", Source: "tool_result", + Severity: "high", Confidence: "high", Status: "recurring", + RecommendationType: "skill", MinOccurrences: 5, MinSessions: 3, + MinProjects: 2, MinWastedDurationMS: 900, Limit: 100, + }) + require.Len(t, filtered.Findings, 1) + assert.Equal(t, "impact", filtered.Findings[0].ID) + + for mode, want := range map[string]string{ + "impact": "impact", "frequency": "frequency", "recent": "recent", + "waste": "waste", "duration": "duration", + } { + t.Run("sort_"+mode, func(t *testing.T) { + got := filterIssueReviewResponse(response, IssueReviewQuery{Sort: mode, Limit: 100}) + require.NotEmpty(t, got.Findings) + assert.Equal(t, want, got.Findings[0].ID) + }) + } + + page := filterIssueReviewResponse(response, IssueReviewQuery{Sort: "impact", Offset: 1, Limit: 2}) + assert.Equal(t, 5, page.TotalFindings) + assert.True(t, page.Truncated) + require.Len(t, page.Findings, 2) + assert.Equal(t, []string{"frequency", "recent"}, []string{page.Findings[0].ID, page.Findings[1].ID}) + + last := filterIssueReviewResponse(response, IssueReviewQuery{Sort: "impact", Offset: 4, Limit: 2}) + assert.False(t, last.Truncated) + require.Len(t, last.Findings, 1) + assert.Equal(t, "duration", last.Findings[0].ID) +} + +func TestGetAnalyticsIssueReviewFiltersAndEvidence(t *testing.T) { + database := testDB(t) + started := "2026-08-01T10:00:00Z" + insertSession(t, database, "s1", "alpha", func(session *Session) { + session.StartedAt = &started + session.Cwd = `C:\work\alpha` + session.Outcome = "errored" + session.MessageCount = 2 + }) + insertSession(t, database, "s2", "alpha", func(session *Session) { + session.StartedAt = &started + session.Cwd = `C:\work\alpha` + session.Outcome = "errored" + session.MessageCount = 1 + }) + insertMessages(t, database, + userMsgAt("s1", 0, "Run the Windows build", started), + Message{ + SessionID: "s1", Ordinal: 1, Role: "assistant", Content: "running", Timestamp: started, HasToolUse: true, + ToolCalls: []ToolCall{{ + SessionID: "s1", ToolName: "shell_command", ToolUseID: "call-1", InputJSON: `{"command":"bad syntax"}`, + ResultEvents: []ToolResultEvent{ + {ToolUseID: "call-1", Source: "tool_execution", Status: "started", Timestamp: "2026-08-01T10:00:00Z", EventIndex: 0}, + {ToolUseID: "call-1", Source: "tool_execution", Status: "errored", Content: "ParserError: unexpected token", Timestamp: "2026-08-01T10:00:02Z", EventIndex: 1}, + }, + }}, + }, + userMsgAt("s2", 0, "Check the same project", started), + ) + allSessions, err := database.issueReviewSessions(context.Background(), AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"}, IssueReviewQuery{}) + require.NoError(t, err) + require.Len(t, allSessions, 2) + assert.Equal(t, "unknown", allSessions[0].Outcome) + assert.Equal(t, `C:\work\alpha`, allSessions[0].CWD) + + response, err := database.GetAnalyticsIssueReview(context.Background(), AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"}, IssueReviewQuery{Folder: `C:\work\alpha`, Outcome: "unknown", Reason: "windows_shell", Limit: 10}) + require.NoError(t, err) + assert.Equal(t, 2, response.ScannedSessions) + assert.Equal(t, 1, response.ScannedToolCalls) + require.Len(t, response.Findings, 1) + finding := response.Findings[0] + assert.Equal(t, "windows_shell", finding.ReasonCode) + require.Len(t, finding.Evidence, 1) + assert.Equal(t, "s1", finding.Evidence[0].SessionID) + assert.Equal(t, `C:\work\alpha`, finding.Evidence[0].CWD) + require.NotNil(t, finding.Evidence[0].MessageOrdinal) + assert.Equal(t, 1, *finding.Evidence[0].MessageOrdinal) + require.NotNil(t, finding.Evidence[0].CallIndex) + assert.Equal(t, 0, *finding.Evidence[0].CallIndex) + require.NotNil(t, finding.Evidence[0].DurationMS) + assert.EqualValues(t, 2000, *finding.Evidence[0].DurationMS) + require.Len(t, response.Facets.Session, 2) + for _, facet := range response.Facets.Session { + assert.NotEmpty(t, facet.Value) + assert.NotEmpty(t, facet.Label) + } + + chat, err := database.GetAnalyticsIssueReview(context.Background(), AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"}, IssueReviewQuery{SessionID: "s1", Reason: "windows_shell", Limit: 10}) + require.NoError(t, err) + assert.Equal(t, 1, chat.ScannedSessions) + require.Len(t, chat.Findings, 1) + assert.Equal(t, "s1", chat.Findings[0].Evidence[0].SessionID) + + filtered, err := database.GetAnalyticsIssueReview(context.Background(), AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"}, IssueReviewQuery{Reason: "missing_file", Limit: 10}) + require.NoError(t, err) + assert.Empty(t, filtered.Findings) +} + +func TestGetAnalyticsIssueReviewCollectsRepeatedUserRequests(t *testing.T) { + database := testDB(t) + started := "2026-08-01T10:00:00Z" + for _, sessionID := range []string{"s1", "s2"} { + insertSession(t, database, sessionID, "alpha", func(session *Session) { + session.StartedAt = &started + session.MessageCount = 1 + }) + insertMessages(t, database, userMsgAt(sessionID, 0, "Can you check why this build keeps failing and create a reusable fix", started)) + } + + response, err := database.GetAnalyticsIssueReview(context.Background(), AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"}, IssueReviewQuery{Reason: "repeated_question", MinSessions: 2, Limit: 10}) + require.NoError(t, err) + require.Len(t, response.Findings, 1) + assert.Equal(t, 2, response.Findings[0].SessionCount) +} + +func TestGetAnalyticsIssueReviewDetectsFirstSelectedCorrection(t *testing.T) { + database := testDB(t) + started := "2026-08-01T10:00:00Z" + insertSession(t, database, "s1", "alpha", func(session *Session) { + session.StartedAt = &started + session.MessageCount = 2 + }) + insertMessages(t, database, + userMsgAt("s1", 0, "Run the build", started), + userMsgAt("s1", 1, "No, that is not correct; use the verified x64 compiler for this build", started), + ) + + response, err := database.GetAnalyticsIssueReview(context.Background(), AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"}, IssueReviewQuery{Reason: "user_correction", Limit: 10}) + require.NoError(t, err) + require.Len(t, response.Findings, 1) + assert.Equal(t, 1, response.Findings[0].Occurrences) + require.Len(t, response.Findings[0].Evidence, 1) + assert.Equal(t, 1, *response.Findings[0].Evidence[0].MessageOrdinal) +} + +func TestIssueReviewRowsPreservesLongFailureTail(t *testing.T) { + database := testDB(t) + started := "2026-08-01T10:00:00Z" + insertSession(t, database, "s1", "alpha", func(session *Session) { + session.StartedAt = &started + session.MessageCount = 2 + }) + failure := "Script failed\n" + strings.Repeat("progress output ", 200) + "\nParserError: stable tail failure" + success := strings.Repeat("completed output ", 200) + "\nSUCCESS_TAIL_SENTINEL" + insertMessages(t, database, + userMsgAt("s1", 0, "Run the build", started), + Message{SessionID: "s1", Ordinal: 1, Role: "assistant", Content: "running", Timestamp: started, HasToolUse: true, ToolCalls: []ToolCall{ + {SessionID: "s1", ToolName: "shell_command", ToolUseID: "call-tail", CallIndex: 0, InputJSON: `{"command":"build"}`, ResultEvents: []ToolResultEvent{{ToolUseID: "call-tail", Source: "tool_execution", Status: "completed", Content: failure, Timestamp: started}}}, + {SessionID: "s1", ToolName: "shell_command", ToolUseID: "call-success", CallIndex: 1, InputJSON: `{"command":"check"}`, ResultEvents: []ToolResultEvent{{ToolUseID: "call-success", Source: "tool_execution", Status: "completed", Content: success, Timestamp: started}}}, + }}, + ) + + _, calls, err := database.issueReviewRows(context.Background(), []IssueReviewSession{{ID: "s1"}}) + require.NoError(t, err) + require.Len(t, calls, 2) + byID := map[string]IssueReviewToolCall{calls[0].ToolUseID: calls[0], calls[1].ToolUseID: calls[1]} + assert.Contains(t, byID["call-tail"].Result, "ParserError: stable tail failure") + assert.Equal(t, "ParserError: stable tail failure", firstIssueLine(byID["call-tail"].Result, byID["call-tail"].Input)) + assert.NotContains(t, byID["call-success"].Result, "SUCCESS_TAIL_SENTINEL") +} + +func TestReadIssueReviewTelemetryReportsAvailability(t *testing.T) { + ctx := context.Background() + sessions := []IssueReviewSession{{ID: "s1"}, {ID: "s2"}} + missing := filepath.Join(t.TempDir(), "missing.sqlite") + rows, status := readIssueReviewTelemetry(ctx, missing, sessions, nil) + assert.Empty(t, rows) + assert.Equal(t, "missing", status) + + malformed := filepath.Join(t.TempDir(), "malformed.sqlite") + require.NoError(t, os.WriteFile(malformed, []byte("not sqlite"), 0o600)) + rows, status = readIssueReviewTelemetry(ctx, malformed, sessions, nil) + assert.Empty(t, rows) + assert.Equal(t, "unavailable", status) + + available := filepath.Join(t.TempDir(), "logs.sqlite") + conn, err := sql.Open("sqlite3", available) + require.NoError(t, err) + _, err = conn.Exec(`CREATE TABLE logs (thread_id TEXT,target TEXT,level TEXT,feedback_log_body TEXT,ts INTEGER,ts_nanos INTEGER,id INTEGER)`) + require.NoError(t, err) + require.NoError(t, conn.Close()) + readonly, err := sql.Open("sqlite3", makeDSN(available, true)) + require.NoError(t, err) + var count int + require.NoError(t, readonly.QueryRow(`SELECT COUNT(*) FROM logs WHERE thread_id IN (?)`, "s1").Scan(&count)) + require.NoError(t, readonly.Close()) + rows, status = readIssueReviewTelemetry(ctx, available, sessions, nil) + assert.Empty(t, rows) + assert.Equal(t, "available", status) +} + +func TestGetAnalyticsIssueReviewCacheAndForcedRefresh(t *testing.T) { + database := testDB(t) + started := "2026-08-01T10:00:00Z" + seed := func(sessionID, callID string) { + insertSession(t, database, sessionID, "alpha", func(session *Session) { + session.StartedAt = &started + session.Cwd = "C:\\work\\alpha" + session.Outcome = "errored" + session.MessageCount = 2 + }) + insertMessages(t, database, + userMsgAt(sessionID, 0, "Open the required file", started), + Message{ + SessionID: sessionID, Ordinal: 1, Role: "assistant", Content: "opening", Timestamp: started, HasToolUse: true, + ToolCalls: []ToolCall{{ + SessionID: sessionID, ToolName: "shell_command", ToolUseID: callID, InputJSON: "{\"command\":\"open missing.txt\"}", + ResultEvents: []ToolResultEvent{{ToolUseID: callID, Source: "tool_execution", Status: "errored", Content: "file not found", Timestamp: started}}, + }}, + }, + ) + } + seed("s1", "call-1") + filter := AnalyticsFilter{From: "2026-08-01", To: "2026-08-01", Timezone: "UTC"} + query := IssueReviewQuery{Reason: "missing_file", Limit: 10} + + first, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + require.Len(t, first.Findings, 1) + assert.Equal(t, 1, first.Findings[0].Occurrences) + + seed("s2", "call-2") + cached, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + require.Len(t, cached.Findings, 1) + assert.Equal(t, 1, cached.Findings[0].Occurrences) + assert.Equal(t, first.GeneratedAt, cached.GeneratedAt) + + alternate := query + alternate.Tool = "not-present" + filtered, err := database.GetAnalyticsIssueReview(context.Background(), filter, alternate) + require.NoError(t, err) + assert.Empty(t, filtered.Findings) + assert.Equal(t, first.GeneratedAt, filtered.GeneratedAt) + + query.Refresh = true + refreshed, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + require.Len(t, refreshed.Findings, 1) + assert.Equal(t, 2, refreshed.Findings[0].Occurrences) +} + +func findingsByReason(findings []IssueReviewFinding) map[string][]IssueReviewFinding { + out := map[string][]IssueReviewFinding{} + for _, finding := range findings { + out[finding.ReasonCode] = append(out[finding.ReasonCode], finding) + } + return out +} + +func ms(value int64) *int64 { return &value } diff --git a/internal/duckdb/issue_review.go b/internal/duckdb/issue_review.go new file mode 100644 index 0000000000..a35c6759a7 --- /dev/null +++ b/internal/duckdb/issue_review.go @@ -0,0 +1,143 @@ +package duckdb + +import ( + "context" + "fmt" + "strings" + + "go.kenn.io/agentsview/internal/db" +) + +// GetAnalyticsIssueReview runs the shared detector over the derived mirror. +func (s *Store) GetAnalyticsIssueReview(ctx context.Context, f db.AnalyticsFilter, q db.IssueReviewQuery) (db.IssueReviewResponse, error) { + key := db.IssueReviewCacheKey(f, q) + if cached, ok := s.issueReviewCache.Get(key, q); ok { + return cached, nil + } + sessions, err := s.issueReviewSessions(ctx, f, q) + if err != nil { + return db.IssueReviewResponse{}, err + } + messages, calls, err := s.issueReviewRows(ctx, sessions) + if err != nil { + return db.IssueReviewResponse{}, err + } + response := db.AnalyzeIssueReviewBase(sessions, messages, calls, nil) + response.TelemetryStatus = "unsupported" + s.issueReviewCache.Put(key, response) + return db.FilterIssueReview(response, q), nil +} + +func (s *Store) issueReviewSessions(ctx context.Context, f db.AnalyticsFilter, q db.IssueReviewQuery) ([]db.IssueReviewSession, error) { + where, args := duckBuildAnalyticsWhere(f, "COALESCE(s.started_at,s.created_at)", "s.", true, true) + if q.SessionID != "" { + where += " AND s.id = ?" + args = append(args, q.SessionID) + } + if q.Folder != "" { + where += " AND s.cwd = ?" + args = append(args, q.Folder) + } + if q.Outcome != "" { + where += " AND s.outcome = ?" + args = append(args, q.Outcome) + } + rows, err := s.queryContext(ctx, `SELECT s.id,substr(COALESCE(NULLIF(s.display_name,''),NULLIF(s.session_name,''),NULLIF(s.first_message,''),NULLIF(s.project,''),s.id),1,160),s.project,s.cwd,s.agent,COALESCE(s.started_at,s.created_at),s.outcome FROM sessions s WHERE `+where, args...) + if err != nil { + return nil, fmt.Errorf("querying duckdb issue review sessions: %w", err) + } + defer rows.Close() + var out []db.IssueReviewSession + for rows.Next() { + var row db.IssueReviewSession + var ts any + if err := rows.Scan(&row.ID, &row.Name, &row.Project, &row.CWD, &row.Agent, &ts, &row.Outcome); err != nil { + return nil, fmt.Errorf("scanning duckdb issue review session: %w", err) + } + row.Date = analyticsLocalDate(formatDBTime(ts), f.Timezone) + row.Incomplete = row.Outcome == "errored" || row.Outcome == "abandoned" + out = append(out, row) + } + return out, rows.Err() +} + +func (s *Store) issueReviewRows(ctx context.Context, sessions []db.IssueReviewSession) ([]db.IssueReviewMessage, []db.IssueReviewToolCall, error) { + if len(sessions) == 0 { + return nil, nil, nil + } + ids := make([]string, len(sessions)) + for i, session := range sessions { + ids[i] = session.ID + } + var messages []db.IssueReviewMessage + var calls []db.IssueReviewToolCall + const chunkSize = 400 + for start := 0; start < len(ids); start += chunkSize { + end := min(start+chunkSize, len(ids)) + args, placeholders := stringInArgs(ids[start:end]) + in := "(" + strings.Join(placeholders, ",") + ")" + rows, err := s.queryContext(ctx, `SELECT session_id,ordinal,role,substr(content,1,`+fmt.Sprint(db.IssueReviewMessageScanLimit)+`),timestamp,is_system,source_type,source_subtype,COALESCE(NULLIF(source_uuid,''),NULLIF(claude_message_id,''),'') FROM messages WHERE session_id IN `+in+` AND NOT is_system AND `+db.IssueReviewMessagePredicate("role", "content")+` ORDER BY session_id,ordinal`, args...) + if err != nil { + return nil, nil, err + } + for rows.Next() { + var row db.IssueReviewMessage + var ts any + if err := rows.Scan(&row.SessionID, &row.Ordinal, &row.Role, &row.Content, &ts, &row.IsSystem, &row.SourceType, &row.SourceSubtype, &row.StableID); err != nil { + rows.Close() + return nil, nil, err + } + row.Timestamp = formatDBTime(ts) + messages = append(messages, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, nil, err + } + rows.Close() + + queryArgs := append(append([]any{}, args...), args...) + result := "COALESCE(es.content,tc.result_content,'')" + rows, err = s.queryContext(ctx, `WITH events AS ( + SELECT tre.*, + ROW_NUMBER() OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index ORDER BY tre.event_index DESC,tre.id DESC) AS latest_rank, + MIN(CASE WHEN tre.source='tool_execution' AND tre.status='started' THEN tre.timestamp END) OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index) AS started, + MAX(CASE WHEN tre.source='tool_execution' AND tre.status IN ('completed','errored') THEN tre.timestamp END) OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index) AS ended + FROM tool_result_events tre WHERE tre.session_id IN `+in+` + ), event_summary AS ( + SELECT session_id,tool_call_message_ordinal,call_index,content,status,source,started,ended FROM events WHERE latest_rank=1 + ) + SELECT tc.session_id,m.ordinal,COALESCE(tc.call_index,0),tc.tool_name,tc.category,COALESCE(tc.tool_use_id,''),substr(COALESCE(tc.input_json,''),1,`+fmt.Sprint(db.IssueReviewInputLimit)+`),substr(`+result+`,1,`+fmt.Sprint(db.IssueReviewResultEdgeLimit)+`),CASE WHEN `+db.IssueReviewTailPredicate("es.status", result)+` THEN substr(`+result+`,-`+fmt.Sprint(db.IssueReviewResultEdgeLimit)+`) ELSE '' END,COALESCE(es.status,''),COALESCE(es.source,''),m.timestamp,es.started,es.ended + FROM tool_calls tc JOIN messages m ON m.id=tc.message_id + LEFT JOIN event_summary es ON es.session_id=tc.session_id AND es.tool_call_message_ordinal=m.ordinal AND es.call_index=COALESCE(tc.call_index,0) + WHERE tc.session_id IN `+in+` ORDER BY tc.session_id,m.ordinal,tc.call_index`, queryArgs...) + if err != nil { + return nil, nil, err + } + for rows.Next() { + var row db.IssueReviewToolCall + var resultHead, resultTail string + var messageTS, started, ended any + if err := rows.Scan(&row.SessionID, &row.MessageOrdinal, &row.CallIndex, &row.Tool, &row.Category, &row.ToolUseID, &row.Input, &resultHead, &resultTail, &row.EventStatus, &row.EventSource, &messageTS, &started, &ended); err != nil { + rows.Close() + return nil, nil, err + } + row.Result = db.JoinIssueReviewResult(resultHead, resultTail) + row.Timestamp = formatDBTime(messageTS) + startedAt, startOK := parseAnalyticsTime(formatDBTime(started)) + endedAt, endOK := parseAnalyticsTime(formatDBTime(ended)) + if startOK && endOK && !endedAt.Before(startedAt) { + value := endedAt.Sub(startedAt).Milliseconds() + row.DurationMS = &value + row.DurationSource = "tool_execution" + } + calls = append(calls, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, nil, err + } + rows.Close() + } + return messages, calls, nil +} diff --git a/internal/duckdb/issue_review_test.go b/internal/duckdb/issue_review_test.go new file mode 100644 index 0000000000..9f61e0a47c --- /dev/null +++ b/internal/duckdb/issue_review_test.go @@ -0,0 +1,48 @@ +//go:build !(windows && arm64) + +package duckdb + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestIssueReviewRowsChunksSessionsAndAggregatesEvents(t *testing.T) { + ctx := context.Background() + syncer := newInMemoryTestSync(t, newLocalDB(t), SyncOptions{}) + require.NoError(t, createSchema(ctx, syncer.DB())) + store := NewStoreFromDB(syncer.DB()) + + sessions := make([]db.IssueReviewSession, 405) + for i := range sessions { + sessions[i].ID = fmt.Sprintf("session-%03d", i) + } + last := sessions[len(sessions)-1].ID + _, err := syncer.DB().ExecContext(ctx, `INSERT INTO messages (id,session_id,ordinal,role,content,timestamp) VALUES (1,?,7,'assistant','Root cause confirmed: command failed because the dependency is missing','2026-08-09T10:00:00Z')`, last) + require.NoError(t, err) + _, err = syncer.DB().ExecContext(ctx, `INSERT INTO tool_calls (id,message_id,session_id,tool_name,category,call_index,tool_use_id,input_json,result_content) VALUES (2,1,?,'shell_command','shell',0,'call-1','{"command":"run"}','fallback'),(5,1,?,'shell_command','shell',1,'call-2','{"command":"check"}','fallback')`, last, last) + require.NoError(t, err) + failure := "Script failed\n" + strings.Repeat("progress output ", 200) + "\nParserError: stable tail failure" + success := strings.Repeat("completed output ", 200) + "\nSUCCESS_TAIL_SENTINEL" + _, err = syncer.DB().ExecContext(ctx, `INSERT INTO tool_result_events (id,session_id,tool_call_message_ordinal,call_index,source,status,content,timestamp,event_index) VALUES (3,?,7,0,'tool_execution','started','','2026-08-09T10:00:00Z',0),(4,?,7,0,'tool_execution','completed',?,'2026-08-09T10:00:02Z',1),(6,?,7,1,'tool_execution','completed',?,'2026-08-09T10:00:03Z',0)`, last, last, failure, last, success) + require.NoError(t, err) + + messages, calls, err := store.issueReviewRows(ctx, sessions) + require.NoError(t, err) + require.Len(t, messages, 1) + require.Len(t, calls, 2) + assert.Equal(t, last, messages[0].SessionID) + byID := map[string]db.IssueReviewToolCall{calls[0].ToolUseID: calls[0], calls[1].ToolUseID: calls[1]} + assert.Equal(t, "completed", byID["call-1"].EventStatus) + assert.Contains(t, byID["call-1"].Result, "ParserError: stable tail failure") + assert.NotContains(t, byID["call-2"].Result, "SUCCESS_TAIL_SENTINEL") + require.NotNil(t, byID["call-1"].DurationMS) + assert.Equal(t, int64(2000), *byID["call-1"].DurationMS) +} diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 8ff99d3466..151a32e289 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -52,11 +52,12 @@ type Store struct { // gone. retiring sync.WaitGroup - quack *quackClient - connectionKind duckDBConnectionKind - cursorMu sync.RWMutex - cursorSecret []byte - customPricing map[string]config.CustomModelRate + quack *quackClient + connectionKind duckDBConnectionKind + cursorMu sync.RWMutex + cursorSecret []byte + customPricing map[string]config.CustomModelRate + issueReviewCache db.IssueReviewCache } // NewStore opens a local DuckDB mirror file as a db.Store. The handle is diff --git a/internal/postgres/issue_review.go b/internal/postgres/issue_review.go new file mode 100644 index 0000000000..418772cbd8 --- /dev/null +++ b/internal/postgres/issue_review.go @@ -0,0 +1,136 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "go.kenn.io/agentsview/internal/db" +) + +// GetAnalyticsIssueReview mirrors the SQLite detector over PostgreSQL rows. +func (s *Store) GetAnalyticsIssueReview(ctx context.Context, f db.AnalyticsFilter, q db.IssueReviewQuery) (db.IssueReviewResponse, error) { + key := db.IssueReviewCacheKey(f, q) + if cached, ok := s.issueReviewCache.Get(key, q); ok { + return cached, nil + } + sessions, err := s.issueReviewSessions(ctx, f, q) + if err != nil { + return db.IssueReviewResponse{}, err + } + messages, calls, err := s.issueReviewRows(ctx, sessions) + if err != nil { + return db.IssueReviewResponse{}, err + } + response := db.AnalyzeIssueReviewBase(sessions, messages, calls, nil) + response.TelemetryStatus = "unsupported" + s.issueReviewCache.Put(key, response) + return db.FilterIssueReview(response, q), nil +} + +func (s *Store) issueReviewSessions(ctx context.Context, f db.AnalyticsFilter, q db.IssueReviewQuery) ([]db.IssueReviewSession, error) { + pb := ¶mBuilder{} + where := buildAnalyticsWhere(f, pgDateCol, pb) + if q.SessionID != "" { + where += " AND id = " + pb.add(q.SessionID) + } + if q.Folder != "" { + where += " AND cwd = " + pb.add(q.Folder) + } + if q.Outcome != "" { + where += " AND outcome = " + pb.add(q.Outcome) + } + rows, err := s.pg.QueryContext(ctx, `SELECT id, LEFT(COALESCE(NULLIF(display_name,''),NULLIF(session_name,''),NULLIF(first_message,''),NULLIF(project,''),id),160), project, cwd, agent, `+pgDateCol+`, outcome FROM sessions WHERE `+where, pb.args...) + if err != nil { + return nil, fmt.Errorf("querying issue review sessions: %w", err) + } + defer rows.Close() + loc := analyticsLocation(f) + var out []db.IssueReviewSession + for rows.Next() { + var row db.IssueReviewSession + var ts *time.Time + if err := rows.Scan(&row.ID, &row.Name, &row.Project, &row.CWD, &row.Agent, &ts, &row.Outcome); err != nil { + return nil, fmt.Errorf("scanning issue review session: %w", err) + } + row.Date = localDate(scanDateCol(ts), loc) + row.Incomplete = row.Outcome == "errored" || row.Outcome == "abandoned" + out = append(out, row) + } + return out, rows.Err() +} + +func (s *Store) issueReviewRows(ctx context.Context, sessions []db.IssueReviewSession) ([]db.IssueReviewMessage, []db.IssueReviewToolCall, error) { + ids := make([]string, len(sessions)) + for i, session := range sessions { + ids[i] = session.ID + } + var messages []db.IssueReviewMessage + var calls []db.IssueReviewToolCall + err := pgQueryChunked(ids, func(chunk []string) error { + pb := ¶mBuilder{} + in := pgInPlaceholders(chunk, pb) + limit := pb.add(db.IssueReviewMessageScanLimit) + rows, err := s.pg.QueryContext(ctx, `SELECT session_id, ordinal, role, LEFT(content, `+limit+`), COALESCE(to_char(timestamp AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"'),''), is_system, source_type, source_subtype, COALESCE(NULLIF(source_uuid,''),NULLIF(claude_message_id,''),'') FROM messages WHERE session_id IN `+in+` AND NOT is_system AND `+db.IssueReviewMessagePredicate("role", "content")+` ORDER BY session_id,ordinal`, pb.args...) + if err != nil { + return err + } + for rows.Next() { + var row db.IssueReviewMessage + if err := rows.Scan(&row.SessionID, &row.Ordinal, &row.Role, &row.Content, &row.Timestamp, &row.IsSystem, &row.SourceType, &row.SourceSubtype, &row.StableID); err != nil { + rows.Close() + return err + } + messages = append(messages, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + pb = ¶mBuilder{} + in = pgInPlaceholders(chunk, pb) + inputLimit := pb.add(db.IssueReviewInputLimit) + resultLimit := pb.add(db.IssueReviewResultEdgeLimit) + result := "COALESCE(es.content,tc.result_content,'')" + rows, err = s.pg.QueryContext(ctx, `WITH events AS ( + SELECT tre.*, + ROW_NUMBER() OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index ORDER BY tre.event_index DESC,tre.id DESC) AS latest_rank, + MIN(CASE WHEN tre.source='tool_execution' AND tre.status='started' THEN tre.timestamp END) OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index) AS started, + MAX(CASE WHEN tre.source='tool_execution' AND tre.status IN ('completed','errored') THEN tre.timestamp END) OVER (PARTITION BY tre.session_id,tre.tool_call_message_ordinal,tre.call_index) AS ended + FROM tool_result_events tre WHERE tre.session_id IN `+in+` + ), event_summary AS ( + SELECT session_id,tool_call_message_ordinal,call_index,content,status,source,started,ended FROM events WHERE latest_rank=1 + ) + SELECT tc.session_id,tc.message_ordinal,tc.call_index,tc.tool_name,tc.category,tc.tool_use_id,LEFT(COALESCE(tc.input_json,''),`+inputLimit+`),LEFT(`+result+`,`+resultLimit+`),CASE WHEN `+db.IssueReviewTailPredicate("es.status", result)+` THEN RIGHT(`+result+`,`+fmt.Sprint(db.IssueReviewResultEdgeLimit)+`) ELSE '' END,COALESCE(es.status,''),COALESCE(es.source,''),COALESCE(to_char(m.timestamp AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"'),''),es.started,es.ended + FROM tool_calls tc LEFT JOIN messages m ON m.session_id=tc.session_id AND m.ordinal=tc.message_ordinal + LEFT JOIN event_summary es ON es.session_id=tc.session_id AND es.tool_call_message_ordinal=tc.message_ordinal AND es.call_index=tc.call_index + WHERE tc.session_id IN `+in+` ORDER BY tc.session_id,tc.message_ordinal,tc.call_index`, pb.args...) + if err != nil { + return err + } + for rows.Next() { + var row db.IssueReviewToolCall + var resultHead, resultTail string + var started, ended *time.Time + if err := rows.Scan(&row.SessionID, &row.MessageOrdinal, &row.CallIndex, &row.Tool, &row.Category, &row.ToolUseID, &row.Input, &resultHead, &resultTail, &row.EventStatus, &row.EventSource, &row.Timestamp, &started, &ended); err != nil { + rows.Close() + return err + } + row.Result = db.JoinIssueReviewResult(resultHead, resultTail) + if started != nil && ended != nil && !ended.Before(*started) { + value := ended.Sub(*started).Milliseconds() + row.DurationMS = &value + row.DurationSource = "tool_execution" + } + calls = append(calls, row) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + return rows.Close() + }) + return messages, calls, err +} diff --git a/internal/postgres/issue_review_pgtest_test.go b/internal/postgres/issue_review_pgtest_test.go new file mode 100644 index 0000000000..de2585a9b6 --- /dev/null +++ b/internal/postgres/issue_review_pgtest_test.go @@ -0,0 +1,59 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +const issueReviewSchema = "agentsview_issue_review_test" + +func setupIssueReviewStore(t *testing.T) *Store { + t.Helper() + pgURL := testPGURL(t) + pg, err := Open(pgURL, issueReviewSchema, true) + require.NoError(t, err) + _, err = pg.Exec(`DROP SCHEMA IF EXISTS ` + issueReviewSchema + ` CASCADE`) + require.NoError(t, err) + require.NoError(t, EnsureSchema(context.Background(), pg, issueReviewSchema)) + require.NoError(t, pg.Close()) + store, err := NewStore(pgURL, issueReviewSchema, true) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + return store +} + +func TestIssueReviewRowsConditionallyLoadsResultTail(t *testing.T) { + store := setupIssueReviewStore(t) + const sessionID = "issue-review-tail" + failure := "Script failed\n" + strings.Repeat("progress output ", 200) + "\nParserError: stable tail failure" + success := strings.Repeat("completed output ", 200) + "\nSUCCESS_TAIL_SENTINEL" + _, err := store.DB().Exec(` + INSERT INTO sessions (id,machine,project,agent,first_message,started_at,message_count,user_message_count) + VALUES ($1,'test-machine','test-project','codex','Run the build','2026-08-09T10:00:00Z'::timestamptz,1,0); + INSERT INTO messages (session_id,ordinal,role,content,timestamp,content_length) + VALUES ($1,1,'assistant','running','2026-08-09T10:00:00Z'::timestamptz,7); + INSERT INTO tool_calls (session_id,message_ordinal,call_index,tool_name,category,tool_use_id,input_json,result_content) + VALUES ($1,1,0,'shell_command','shell','call-1','{"command":"run"}','fallback'), + ($1,1,1,'shell_command','shell','call-2','{"command":"check"}','fallback'); + INSERT INTO tool_result_events (session_id,tool_call_message_ordinal,call_index,tool_use_id,source,status,content,timestamp,event_index) + VALUES ($1,1,0,'call-1','tool_execution','completed',$2,'2026-08-09T10:00:02Z'::timestamptz,0), + ($1,1,1,'call-2','tool_execution','completed',$3,'2026-08-09T10:00:03Z'::timestamptz,0)`, + sessionID, failure, success, + ) + require.NoError(t, err) + + _, calls, err := store.issueReviewRows(context.Background(), []db.IssueReviewSession{{ID: sessionID}}) + require.NoError(t, err) + require.Len(t, calls, 2) + byID := map[string]db.IssueReviewToolCall{calls[0].ToolUseID: calls[0], calls[1].ToolUseID: calls[1]} + assert.Contains(t, byID["call-1"].Result, "ParserError: stable tail failure") + assert.NotContains(t, byID["call-2"].Result, "SUCCESS_TAIL_SENTINEL") +} diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index a89ce9e0b8..b0f8a63926 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -40,6 +40,7 @@ type Store struct { vectorMu sync.RWMutex vectorSearcher db.VectorSearcher semanticUnavailableReason string + issueReviewCache db.IssueReviewCache } // pgSessionCols is the column list for standard PG session queries. diff --git a/internal/server/huma_routes_analytics.go b/internal/server/huma_routes_analytics.go index b6c1778f0d..9597aceac1 100644 --- a/internal/server/huma_routes_analytics.go +++ b/internal/server/huma_routes_analytics.go @@ -25,6 +25,7 @@ func (s *Server) registerAnalyticsRoutes() { get(s, group, "/top-sessions", "Get top sessions", s.humaAnalyticsTopSessions) get(s, group, "/signals", "Get signal analytics", s.humaAnalyticsSignals) get(s, group, "/signal-sessions", "Get signal session examples", s.humaAnalyticsSignalSessions) + get(s, group, "/issue-review", "Get proactive issue review", s.humaAnalyticsIssueReview) } type analyticsGranularity string @@ -78,6 +79,33 @@ type analyticsSignalSessionsInput struct { Limit int `query:"limit" minimum:"0" maximum:"20" default:"10" doc:"Maximum number of session examples"` } +type analyticsIssueReviewInput struct { + AnalyticsFilterInput + SessionID string `query:"session_id" doc:"Exact chat session ID"` + Folder string `query:"folder" doc:"Exact session working directory"` + Category string `query:"category" doc:"Issue reason code"` + Reason string `query:"reason" doc:"Issue reason code alias"` + Tool string `query:"tool" doc:"Normalized tool name"` + Source string `query:"source" doc:"Evidence source"` + Outcome string `query:"outcome" doc:"Session outcome"` + Severity string `query:"severity" enum:"high,medium,low" doc:"Finding severity"` + Confidence string `query:"confidence" enum:"high,medium,low" doc:"Finding confidence"` + Status string `query:"status" enum:"open,recovered,recurring,observed" doc:"Finding status"` + RecommendationType string `query:"recommendation_type" enum:"skill,script,rule,tool_fix" doc:"Suggested action type"` + MinOccurrences int `query:"min_occurrences" minimum:"1" default:"1" doc:"Minimum repeated occurrences"` + MinSessions int `query:"min_sessions" minimum:"1" default:"1" doc:"Minimum distinct chats"` + MinProjects int `query:"min_projects" minimum:"0" default:"0" doc:"Minimum distinct projects"` + MinWastedMS int64 `query:"min_wasted_ms" minimum:"0" default:"0" doc:"Minimum estimated wasted duration in milliseconds"` + Sort string `query:"sort" enum:"impact,frequency,recent,waste,duration" default:"impact" doc:"Finding sort order"` + Refresh bool `query:"refresh" default:"false" doc:"Bypass the short analysis cache"` + Offset int `query:"offset" minimum:"0" default:"0" doc:"Findings to skip after filtering and sorting"` + Limit int `query:"limit" minimum:"1" maximum:"100" default:"50" doc:"Maximum findings"` +} + +type analyticsIssueReviewStore interface { + GetAnalyticsIssueReview(context.Context, db.AnalyticsFilter, db.IssueReviewQuery) (db.IssueReviewResponse, error) +} + func analyticsFilterFromInput(in AnalyticsFilterInput) (db.AnalyticsFilter, error) { tz := in.Timezone if tz == "" { @@ -301,3 +329,45 @@ func (s *Server) humaAnalyticsSignalSessions( } return &jsonOutput[db.SignalSessionsResponse]{Body: result}, nil } + +func (s *Server) humaAnalyticsIssueReview( + ctx context.Context, + in *analyticsIssueReviewInput, +) (*jsonOutput[db.IssueReviewResponse], error) { + f, err := analyticsFilterFromInput(in.AnalyticsFilterInput) + if err != nil { + return nil, err + } + store, ok := s.db.(analyticsIssueReviewStore) + if !ok { + return nil, apiError(http.StatusNotImplemented, "issue review is not supported by this store") + } + reason := in.Category + if reason == "" { + reason = in.Reason + } + result, err := store.GetAnalyticsIssueReview(ctx, f, db.IssueReviewQuery{ + SessionID: in.SessionID, + Folder: in.Folder, + Reason: reason, + Tool: in.Tool, + Source: in.Source, + Outcome: in.Outcome, + Severity: in.Severity, + Confidence: in.Confidence, + Status: in.Status, + RecommendationType: in.RecommendationType, + MinOccurrences: in.MinOccurrences, + MinSessions: in.MinSessions, + MinProjects: in.MinProjects, + MinWastedDurationMS: in.MinWastedMS, + Sort: in.Sort, + Refresh: in.Refresh, + Offset: in.Offset, + Limit: in.Limit, + }) + if err != nil { + return nil, internalError("analytics issue review error", err) + } + return &jsonOutput[db.IssueReviewResponse]{Body: result}, nil +} From 4c3c108acf2be030ce8852286eb81815d4d612a5 Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:46:34 +0300 Subject: [PATCH 04/10] perf: reduce issue review allocations --- internal/db/issue_review.go | 106 ++++++++++++++++++------------- internal/db/issue_review_test.go | 1 + 2 files changed, 63 insertions(+), 44 deletions(-) diff --git a/internal/db/issue_review.go b/internal/db/issue_review.go index 395f301c39..c4683bfc15 100644 --- a/internal/db/issue_review.go +++ b/internal/db/issue_review.go @@ -265,16 +265,24 @@ var issueFailurePatterns = []issuePattern{ // ClassifyIssueFailure classifies a tool result conservatively. It returns // false for explicit success and search no-match exits. func ClassifyIssueFailure(tool, status, input, result string) (string, bool) { + command := input + if isShellTool(tool) { + command = issueCommandInput(input) + } + return classifyIssueFailure(tool, status, command, result) +} + +func classifyIssueFailure(tool, status, command, result string) (string, bool) { status = strings.ToLower(strings.TrimSpace(status)) failedStatus := status == "errored" || status == "error" || status == "cancelled" || status == "canceled" resultLower := strings.ToLower(result) hasZeroExit, hasOneExit, hasNonZeroExit := issueExitCodes(resultLower) - if isSearchInvocation(tool, input) && hasOneExit && !hasSpecificSearchFailure(resultLower) { + if isSearchInvocation(tool, command) && hasOneExit && !hasSpecificSearchFailure(resultLower) { return "", false } - logicalFailure := hasLogicalFailure(input, resultLower) + logicalFailure := hasLogicalFailure(command, resultLower) markerFailure := explicitFailureMarker(result) - if !failedStatus && !hasNonZeroExit && isReadInvocation(tool, input) { + if !failedStatus && !hasNonZeroExit && isReadInvocation(tool, command) { return "", false } if hasZeroExit && !hasNonZeroExit && !logicalFailure && !failedStatus { @@ -286,13 +294,13 @@ func ClassifyIssueFailure(tool, status, input, result string) (string, bool) { if reason, ok := classifyIssueReason(resultLower); ok { return reason, true } - if isEditInvocation(tool, input) { + if isEditInvocation(tool, command) { return "failed_edit", true } - if isGitHubInvocation(tool, input) { + if isGitHubInvocation(tool, command) { return "git_github_ci", true } - if isBuildTestInvocation(tool, input) { + if isBuildTestInvocation(tool, command) { return "build_test", true } if isShellTool(tool) { @@ -412,15 +420,14 @@ func hasSpecificSearchFailure(resultLower string) bool { return false } -func isSearchInvocation(tool, input string) bool { +func isSearchInvocation(tool, command string) bool { if isSearchTool(tool) { return true } if !isShellTool(tool) { return false } - input = issueCommandInput(input) - return searchCommandRE.MatchString(input) + return searchCommandRE.MatchString(command) } func issueCommandInput(input string) string { @@ -433,30 +440,30 @@ func issueCommandInput(input string) string { return input } -func isEditInvocation(tool, input string) bool { +func isEditInvocation(tool, command string) bool { lowerTool := normalizeTool(tool) if strings.Contains(lowerTool, "apply_patch") || strings.Contains(lowerTool, "edit") { return true } - lower := strings.ToLower(issueCommandInput(input)) + lower := strings.ToLower(command) return strings.HasPrefix(strings.TrimSpace(lower), "apply_patch") } -func isGitHubInvocation(tool, input string) bool { +func isGitHubInvocation(tool, command string) bool { if !isShellTool(tool) && !strings.Contains(normalizeTool(tool), "github") { return false } - lower := strings.ToLower(issueCommandInput(input)) + lower := strings.ToLower(command) return strings.Contains(lower, "gh ") || strings.Contains(lower, "github.com") || strings.Contains(lower, "git push") || strings.Contains(lower, "git pull") || strings.Contains(lower, "git fetch") || strings.Contains(lower, "git clone") } -func isBuildTestInvocation(tool, input string) bool { +func isBuildTestInvocation(tool, command string) bool { if !isShellTool(tool) { return false } - lower := strings.ToLower(issueCommandInput(input)) + lower := strings.ToLower(command) for _, term := range []string{" go test", "npm test", "npm run build", "npm run check", "pytest", "cargo test", "dotnet test", "psql", "migration"} { if strings.Contains(" "+lower, term) { return true @@ -466,16 +473,25 @@ func isBuildTestInvocation(tool, input string) bool { } func canonicalGitHubReference(value string) string { - if strings.Contains(value, "://") { - match := githubIssueURLRE.FindStringSubmatch(value) - if len(match) == 4 { - return strings.ToLower(match[1]+"/"+match[2]) + "#" + match[3] + return canonicalGitHubReferenceParts(value, "") +} + +func canonicalGitHubReferenceParts(first, second string) string { + values := [...]string{first, second} + for _, value := range values { + if strings.Contains(value, "://") { + match := githubIssueURLRE.FindStringSubmatch(value) + if len(match) == 4 { + return strings.ToLower(match[1]+"/"+match[2]) + "#" + match[3] + } } } - if strings.Contains(value, "#") { - match := githubIssueShortRE.FindStringSubmatch(value) - if len(match) == 4 { - return strings.ToLower(match[1]+"/"+match[2]) + "#" + match[3] + for _, value := range values { + if strings.Contains(value, "#") { + match := githubIssueShortRE.FindStringSubmatch(value) + if len(match) == 4 { + return strings.ToLower(match[1]+"/"+match[2]) + "#" + match[3] + } } } return "" @@ -808,6 +824,7 @@ func (a *issueAnalyzer) finish(totalCalls int, durationCounts map[string]int) [] type analyzedCall struct { row IssueReviewToolCall tool string + command string normalized string reason string failed bool @@ -890,17 +907,18 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie toolCounts := map[string]int{} for _, row := range calls { tool := effectiveIssueTool(row.Tool, row.Input) + command := row.Input + if isShellTool(tool) { + command = issueCommandInput(row.Input) + } toolCounts[tool]++ if row.DurationMS != nil && *row.DurationMS < 0 { row.DurationMS = nil row.DurationSource = "" } - normalized := strings.TrimSpace(row.Input) - if isShellTool(tool) { - normalized = strings.TrimSpace(issueCommandInput(row.Input)) - } - reason, failed := ClassifyIssueFailure(tool, row.EventStatus, row.Input, row.Result) - bySession[row.SessionID] = append(bySession[row.SessionID], analyzedCall{row: row, tool: tool, normalized: normalized, reason: reason, failed: failed}) + normalized := strings.TrimSpace(command) + reason, failed := classifyIssueFailure(tool, row.EventStatus, command, row.Result) + bySession[row.SessionID] = append(bySession[row.SessionID], analyzedCall{row: row, tool: tool, command: command, normalized: normalized, reason: reason, failed: failed}) if row.DurationMS != nil && *row.DurationMS >= 0 { durationCounts[tool]++ } @@ -927,7 +945,7 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie rows[i].recovered = true break } - if intervening == 3 || !isRecoveryDiagnostic(next.tool, next.row.Input) { + if intervening == 3 || !isRecoveryDiagnostic(next.tool, next.command) { break } intervening++ @@ -937,17 +955,18 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie for i, call := range rows { tool := call.tool ord, idx := call.row.MessageOrdinal, call.row.CallIndex - if ref := canonicalGitHubReference(call.row.Input + "\n" + call.row.Result); ref != "" && (call.failed || isGitHubInvocation(tool, call.row.Input)) { + githubRef := canonicalGitHubReferenceParts(call.row.Input, call.row.Result) + if githubRef != "" && (call.failed || isGitHubInvocation(tool, call.command)) { severity := "low" if call.failed { severity = "medium" } - e := a.evidence(sessionID, firstNonEmptyString(call.row.EventSource, "tool_call"), tool, ref, call.row.EventStatus, &ord, &idx, call.row.DurationMS) - a.add("github-issue|"+ref, "github_issue_reference", tool, ref, severity, "high", "rule", e, call.row.DurationMS, nil, false) + e := a.evidence(sessionID, firstNonEmptyString(call.row.EventSource, "tool_call"), tool, githubRef, call.row.EventStatus, &ord, &idx, call.row.DurationMS) + a.add("github-issue|"+githubRef, "github_issue_reference", tool, githubRef, severity, "high", "rule", e, call.row.DurationMS, nil, false) } if call.failed { sig := firstIssueLine(call.row.Result, call.row.Input) - key := "failure|" + call.reason + "|" + tool + "|" + canonicalGitHubReference(call.row.Input+"\n"+call.row.Result) + "|" + normalizeIssueText(sig) + key := "failure|" + call.reason + "|" + tool + "|" + githubRef + "|" + normalizeIssueText(sig) e := a.evidence(sessionID, firstNonEmptyString(call.row.EventSource, "tool_result"), tool, sig, call.row.EventStatus, &ord, &idx, call.row.DurationMS) a.add(key, call.reason, tool, sig, failureSeverity(call.reason), issueFailureConfidence(call.row.EventStatus, call.row.Input, call.row.Result), recommendationFor(call.reason, 1, 1), e, call.row.DurationMS, call.row.DurationMS, call.recovered) if i+1 < len(rows) && rows[i+1].tool == tool && rows[i+1].normalized == call.normalized { @@ -957,7 +976,7 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie a.add("retry|"+tool+"|"+call.normalized, "retry_after_failure", tool, next.row.Input, "medium", "high", "script", e, next.row.DurationMS, next.row.DurationMS, !next.failed) } } - if eligibleWorkflow(tool, call.row.Input) { + if eligibleWorkflow(tool, call.row.Input, call.command) { normalized, ok := normalizedInputs[call.row.Input] if !ok { normalized = normalizeIssueText(call.row.Input) @@ -988,7 +1007,7 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie call := rows[start] tool := call.tool reason := "repeated_polling" - if isReadInvocation(tool, call.row.Input) { + if isReadInvocation(tool, call.command) { reason = "repeated_read" } ord, idx := call.row.MessageOrdinal, call.row.CallIndex @@ -1266,8 +1285,8 @@ func concreteRecommendation(f IssueReviewFinding) string { } } -func eligibleWorkflow(tool, input string) bool { - if isWaitTool(tool) || isReadInvocation(tool, input) || len(strings.TrimSpace(input)) < 80 { +func eligibleWorkflow(tool, input, command string) bool { + if isWaitTool(tool) || isReadInvocation(tool, command) || len(strings.TrimSpace(input)) < 80 { return false } lower := strings.ToLower(strings.TrimSpace(input)) @@ -1284,14 +1303,14 @@ func isWaitTool(tool string) bool { return strings.Contains(t, "wait") || t == "sleep" || t == "await" || t == "awaitshell" } -func isReadInvocation(tool, input string) bool { +func isReadInvocation(tool, command string) bool { t := normalizeTool(tool) for _, term := range []string{"read", "view_file", "get_file", "read_mcp_resource"} { if t == term || strings.Contains(t, "read_file") { return true } } - lower := strings.ToLower(strings.TrimSpace(issueCommandInput(input))) + lower := strings.ToLower(strings.TrimSpace(command)) for _, prefix := range []string{"get-content ", "cat ", "type ", "head ", "tail ", "sed -n "} { if strings.HasPrefix(lower, prefix) { return true @@ -1300,16 +1319,15 @@ func isReadInvocation(tool, input string) bool { return false } -func isRecoveryDiagnostic(tool, input string) bool { +func isRecoveryDiagnostic(tool, command string) bool { t := normalizeTool(tool) - command := "" if isShellTool(t) { - command = strings.ToLower(strings.TrimSpace(issueCommandInput(input))) + command = strings.ToLower(strings.TrimSpace(command)) if strings.ContainsAny(command, "\r\n;|&") { return false } } - if isWaitTool(tool) || isReadInvocation(tool, input) || isSearchInvocation(tool, input) { + if isWaitTool(tool) || isReadInvocation(tool, command) || isSearchInvocation(tool, command) { return true } if t == "status" || t == "location" || t == "list" || strings.HasPrefix(t, "get_status") || strings.HasPrefix(t, "get_location") || strings.HasPrefix(t, "list_") { diff --git a/internal/db/issue_review_test.go b/internal/db/issue_review_test.go index c9e91a3198..18848c7c70 100644 --- a/internal/db/issue_review_test.go +++ b/internal/db/issue_review_test.go @@ -90,6 +90,7 @@ func TestCanonicalGitHubReference(t *testing.T) { assert.Equal(t, tt.want, canonicalGitHubReference(tt.input)) }) } + assert.Equal(t, "url/repo#47", canonicalGitHubReferenceParts("Short/Repo#46", "https://github.com/URL/Repo/issues/47")) } func TestFirstIssueLineSkipsExecutionWrapper(t *testing.T) { From 28eff630d84bf9c582eadd6b9b854fcfa1f2b99c Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:21:04 +0300 Subject: [PATCH 05/10] perf: reduce issue review hot-path allocations --- internal/db/issue_review.go | 66 ++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/internal/db/issue_review.go b/internal/db/issue_review.go index c4683bfc15..fcadd8e808 100644 --- a/internal/db/issue_review.go +++ b/internal/db/issue_review.go @@ -17,6 +17,8 @@ import ( "sync" "time" + "github.com/tidwall/gjson" + "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/secrets" ) @@ -281,7 +283,7 @@ func classifyIssueFailure(tool, status, command, result string) (string, bool) { return "", false } logicalFailure := hasLogicalFailure(command, resultLower) - markerFailure := explicitFailureMarker(result) + markerFailure := !logicalFailure && explicitFailureMarker(resultLower) if !failedStatus && !hasNonZeroExit && isReadInvocation(tool, command) { return "", false } @@ -337,7 +339,6 @@ func issueFailureConfidence(status, input, result string) string { } func explicitFailureMarker(result string) bool { - result = strings.ToLower(result) for _, marker := range failureMarkerTerms { if strings.Contains(result, marker) { return true @@ -359,10 +360,10 @@ func hasLogicalFailure(input, resultLower string) bool { if (strings.Contains(resultLower, "failed") || strings.Contains(resultLower, "npm err!") || strings.Contains(resultLower, "fatal:") || strings.Contains(resultLower, "panic:") || strings.Contains(resultLower, "traceback (most recent call last):")) && failureSummaryRE.MatchString(resultLower) { return true } - lowerInput := strings.ToLower(input) if (!strings.Contains(resultLower, "http") && !strings.Contains(resultLower, "status")) || !httpFailureRE.MatchString(resultLower) { return false } + lowerInput := strings.ToLower(input) for _, term := range []string{"gh ", "github", "curl", "invoke-webrequest", "api", "http"} { if strings.Contains(lowerInput, term) { return true @@ -431,11 +432,8 @@ func isSearchInvocation(tool, command string) bool { } func issueCommandInput(input string) string { - var payload struct { - Command string `json:"command"` - } - if json.Unmarshal([]byte(input), &payload) == nil && payload.Command != "" { - return payload.Command + if result := gjson.Get(input, "command"); result.Type == gjson.String && result.Str != "" && gjson.Valid(input) { + return result.Str } return input } @@ -523,7 +521,7 @@ func normalizeTool(tool string) string { func effectiveIssueTool(tool, input string) string { outer := normalizeTool(tool) - if outer != "exec" && outer != "functions.exec" { + if (outer != "exec" && outer != "functions.exec") || !strings.Contains(input, "tools.") { return outer } var nested string @@ -832,7 +830,7 @@ type analyzedCall struct { } type workflowAccumulator struct { - rows []analyzedCall + rows []*analyzedCall firstSession string firstProject string multiSession bool @@ -901,12 +899,21 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie messages, duplicateMessages := dedupeIssueMessages(messages) calls, duplicateCalls := dedupeIssueCalls(calls) a := newIssueAnalyzer(sessions) - bySession := make(map[string][]analyzedCall) + sessionCallCounts := make(map[string]int) + for _, row := range calls { + sessionCallCounts[row.SessionID]++ + } + bySession := make(map[string][]analyzedCall, len(sessionCallCounts)) + for sessionID, count := range sessionCallCounts { + bySession[sessionID] = make([]analyzedCall, 0, count) + } normalizedInputs := make(map[string]string) durationCounts := map[string]int{} toolCounts := map[string]int{} + effectiveTools := make([]string, 0, len(calls)) for _, row := range calls { tool := effectiveIssueTool(row.Tool, row.Input) + effectiveTools = append(effectiveTools, tool) command := row.Input if isShellTool(tool) { command = issueCommandInput(row.Input) @@ -923,7 +930,8 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie durationCounts[tool]++ } } - workflows := map[string]*workflowAccumulator{} + type workflowKey struct{ tool, normalized string } + workflows := map[workflowKey]*workflowAccumulator{} for sessionID, rows := range bySession { sort.Slice(rows, func(i, j int) bool { if rows[i].row.MessageOrdinal != rows[j].row.MessageOrdinal { @@ -952,7 +960,8 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie } } bySession[sessionID] = rows - for i, call := range rows { + for i := range rows { + call := &rows[i] tool := call.tool ord, idx := call.row.MessageOrdinal, call.row.CallIndex githubRef := canonicalGitHubReferenceParts(call.row.Input, call.row.Result) @@ -982,7 +991,7 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie normalized = normalizeIssueText(call.row.Input) normalizedInputs[call.row.Input] = normalized } - key := tool + "|" + normalized + key := workflowKey{tool: tool, normalized: normalized} acc := workflows[key] if acc == nil { acc = &workflowAccumulator{firstSession: call.row.SessionID, firstProject: a.sessions[call.row.SessionID].Project} @@ -1027,13 +1036,14 @@ func AnalyzeIssueReviewBase(sessions []IssueReviewSession, messages []IssueRevie if workflow.multiProject { recommendation = "skill" } + findingKey := "workflow|" + key.tool + "|" + key.normalized for _, row := range workflow.rows { ord, idx := row.row.MessageOrdinal, row.row.CallIndex e := a.evidence(row.row.SessionID, "tool_call", row.tool, row.row.Input, row.row.EventStatus, &ord, &idx, row.row.DurationMS) - a.add("workflow|"+key, "repeated_workflow", row.tool, row.row.Input, "medium", "high", recommendation, e, row.row.DurationMS, row.row.DurationMS, false) + a.add(findingKey, "repeated_workflow", row.tool, row.row.Input, "medium", "high", recommendation, e, row.row.DurationMS, row.row.DurationMS, false) } } - addSlowToolFindings(a, calls, toolCounts) + addSlowToolFindings(a, calls, effectiveTools, toolCounts) addMessageFindings(a, messages) addTelemetryFindings(a, telemetry) findings := a.finish(len(calls), durationCounts) @@ -1118,7 +1128,7 @@ func firstNonEmptyString(values ...string) string { func firstIssueLine(result, input string) string { for _, source := range []string{result, issueCommandInput(input)} { contentBlocks := strings.Contains(source, `"type"`) && strings.Contains(source, `"text"`) - if decoded := parser.DecodeContent(source); json.Valid([]byte(source)) && decoded != "" { + if decoded := parser.DecodeContent(source); gjson.Valid(source) && decoded != "" { source = decoded contentBlocks = false } @@ -1289,9 +1299,9 @@ func eligibleWorkflow(tool, input, command string) bool { if isWaitTool(tool) || isReadInvocation(tool, command) || len(strings.TrimSpace(input)) < 80 { return false } - lower := strings.ToLower(strings.TrimSpace(input)) + input = strings.TrimSpace(input) for _, prefix := range []string{"git status", "pwd", "get-location", "ls", "dir", "rg ", "grep ", "find ", "get-childitem", "get-content"} { - if strings.HasPrefix(lower, prefix) { + if hasFoldPrefix(input, prefix) { return false } } @@ -1310,9 +1320,9 @@ func isReadInvocation(tool, command string) bool { return true } } - lower := strings.ToLower(strings.TrimSpace(command)) + command = strings.TrimSpace(command) for _, prefix := range []string{"get-content ", "cat ", "type ", "head ", "tail ", "sed -n "} { - if strings.HasPrefix(lower, prefix) { + if hasFoldPrefix(command, prefix) { return true } } @@ -1322,7 +1332,7 @@ func isReadInvocation(tool, command string) bool { func isRecoveryDiagnostic(tool, command string) bool { t := normalizeTool(tool) if isShellTool(t) { - command = strings.ToLower(strings.TrimSpace(command)) + command = strings.TrimSpace(command) if strings.ContainsAny(command, "\r\n;|&") { return false } @@ -1337,17 +1347,21 @@ func isRecoveryDiagnostic(tool, command string) bool { return false } for _, diagnostic := range []string{"git status", "pwd", "get-location", "ls", "dir", "get-childitem"} { - if command == diagnostic || strings.HasPrefix(command, diagnostic+" ") { + if strings.EqualFold(command, diagnostic) || hasFoldPrefix(command, diagnostic+" ") { return true } } return false } -func addSlowToolFindings(a *issueAnalyzer, calls []IssueReviewToolCall, toolCounts map[string]int) { +func hasFoldPrefix(value, prefix string) bool { + return len(value) >= len(prefix) && strings.EqualFold(value[:len(prefix)], prefix) +} + +func addSlowToolFindings(a *issueAnalyzer, calls []IssueReviewToolCall, effectiveTools []string, toolCounts map[string]int) { byTool := map[string][]IssueReviewToolCall{} - for _, call := range calls { - tool := effectiveIssueTool(call.Tool, call.Input) + for i, call := range calls { + tool := effectiveTools[i] if call.DurationMS != nil && *call.DurationMS >= 0 && !isWaitTool(tool) { byTool[tool] = append(byTool[tool], call) } From cb0dc9a4ac86e378d6d07175cdf5907220624760 Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:56:30 +0300 Subject: [PATCH 06/10] docs: close out issue review release --- docs/issue-review-handover.md | 137 ++++++++++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 14 deletions(-) diff --git a/docs/issue-review-handover.md b/docs/issue-review-handover.md index b1d8f279b0..beb6b135ec 100644 --- a/docs/issue-review-handover.md +++ b/docs/issue-review-handover.md @@ -2,12 +2,12 @@ Status date: 2026-08-10 -Release decision: **READY FOR LOCAL SQLITE DESKTOP DEPLOYMENT AFTER THE -FEATURE COMMIT**. The production-scale default-timeout benchmark, SQLite, -DuckDB, server, and frontend gates pass. PostgreSQL integration execution is -the only unresolved backend-parity gate because this host has no dedicated -PostgreSQL test database and cannot start the Docker test service while -firmware virtualization is disabled. +Release decision: **LOCAL SQLITE DESKTOP RELEASE DEPLOYED AND ACCEPTED** at +`28eff630d84bf9c582eadd6b9b854fcfa1f2b99c`. The installed binary, API, +browser workflow, rollback artifact, and daily read-only task are verified. +PostgreSQL integration execution remains the only unresolved backend-parity +gate because this host has no dedicated PostgreSQL test database and cannot +start the Docker test service while firmware virtualization is disabled. Base revision: `915e83b91da3c553a8735f89309fd4b055189f65` @@ -44,9 +44,9 @@ Automatic remediation remains out of scope. | Hourly and on-demand refresh | Ready | One-hour cache and forced-refresh coverage passes | | Performance | Ready | Two forced requests pass the unchanged 30-second timeout | | Finding privacy | Ready | Full benchmark pagination has zero path or credential leaks | -| Feature commit | Pending | Freeze only intended files; preserve unrelated untracked content | -| Installed desktop release | Pending | Build and deploy only from the clean exact feature commit | -| Daily scheduled report | Pending | Create only after installed browser acceptance passes | +| Feature commit | Ready | Feature `252cef0`; optimized release `28eff63` | +| Installed desktop release | Ready | Exact artifact installed, hashed, healthy, and browser-verified | +| Daily scheduled report | Active | `daily-agentsview-issue-review`, daily 09:00 local Kyiv time | ## Current implementation @@ -247,9 +247,118 @@ The benchmark closed two late issues: Changing the production write timeout was not required. +## Final release closeout + +### Frozen revision and validation + +The release code is frozen in three focused commits: + +- `252cef0` — proactive Issue Review; +- `4c3c108` — allocation reduction; +- `28eff63` — hot-path allocation reduction. + +`go fmt ./...`, focused SQLite and DuckDB Issue Review tests, +`go vet -tags fts5 ./...`, the private-data scrub, `git diff --check`, the +isolated performance/parity gate, and the exact frontend build pass. The +broader `go test -tags fts5 ./internal/db ./internal/duckdb -count=1` exceeded +the 184-second harness timeout without emitting a failure; the affected +focused tests pass, and the full backend suites passed before the final +localized optimization. PostgreSQL `pgtest` remains blocked as described +above. + +GitNexus is current at exact revision `28eff63`: 47,865 nodes, 278,060 edges, +2,390 clusters, and 300 flows. Graphify remains unavailable for this +repository. Analyzer-generated `AGENTS.md` and `CLAUDE.md` churn was not kept. + +On the same isolated production-scale database, `4c3c108` took 24.488 seconds +for 1,135 findings. `28eff63` took 17.763 and 17.868 seconds for the same 1,135 +findings; a cached request took 0.064 seconds. Telemetry was `available`. + +### Exact artifact, deployment, and rollback + +The clean detached release worktree is +`%LOCALAPPDATA%\Temp\agentsview-issue-review-28eff63-release`. + +| Evidence | Result | +| --- | --- | +| Version | `v0.40.1-5-g28eff63` | +| Compiler | `x86_64-w64-mingw32` | +| Build time | `2026-08-09T22:32:15Z` | +| SHA-256 | `09515D3A0E3517D07C0F96A00627F1467A4AFC2D01BC7280494D79E694C7D16F` | +| Installed path | `%LOCALAPPDATA%\Programs\AgentsView\agentsview.exe` | +| Installed daemon | PID 64192 on `127.0.0.1:8080` after retry acceptance | +| Root UI | HTTP 200 | + +The installed hash and version match the release artifact. The prior +`4c3c108` binary is backed up at +`%LOCALAPPDATA%\Programs\AgentsView\backups\20260810-013422-28eff63-predeploy\agentsview.exe` +with SHA-256 +`3FC516FEA37080344997808D7CFF1D92C058C34D9F93CC02AA566C41BD8B2D2D`. + +### Installed API and browser acceptance + +The live archive changed during acceptance, increasing the result from 1,150 +to 1,152 findings. The installed API returned: + +| Check | Result | +| --- | --- | +| Cold forced request | HTTP 200, 29.488 seconds | +| Warm forced request | HTTP 200, 28.215 seconds | +| Cached pagination | 12 pages in 0.487 seconds | +| Findings | 1,152 | +| Scanned tool calls | 88,040 | +| Analyzed tool calls | 79,111 | +| Duplicate imported calls | 8,929 | +| Telemetry | `available` | +| Duplicate finding IDs | 0 | +| Absolute path leaks | 0 | +| Credential leaks | 0 | + +Installed-browser acceptance passed for primary navigation, every Issue Review +filter group, a high-severity filter, **Clear filters**, **Load more findings** +(100 to 200), **Refresh now**, keyboard selection in a filter, and evidence +navigation to the exact session and message query. Browser diagnostics were +empty before the deliberate retry test. The screenshot is +`%LOCALAPPDATA%\Temp\agentsview-issue-review-28eff63.png`; the repeatable API +acceptance script is +`%LOCALAPPDATA%\Temp\agentsview-issue-review-acceptance.ps1`. + +Retry acceptance stopped and verified the initial PID 8804, rendered the +first-load **Retry** state, started the same installed artifact as PID 64192, +and recovered through **Retry** to 1,153 findings and 88,195 scanned tool calls. +The only new browser diagnostics were the expected fetch warnings during the +deliberate outage; there were no errors after recovery. The daemon remains +running and healthy. + +The in-app acceptance harness has a fixed 1280×720 viewport and exposes no +viewport override. Narrow-layout CSS was source-reviewed and the full +frontend test/build gates pass, but an installed sub-720-pixel manual resize +was not reproducible in this harness. This is the one remaining installed-UI +evidence limitation. + +### Daily read-only task + +`daily-agentsview-issue-review` is active as a heartbeat in the dedicated +Issue Review task. It runs daily at 09:00 local Kyiv time and uses the previous +completed `Europe/Kiev` day. Its prompt permits one forced GET followed by +cached GET pagination and forbids repair, restart, import, sync, repository +changes, and worktree creation. It pauses after three consecutive +unreachable-daemon runs and requests operator review. + +The host local zone is Windows `FLE Standard Time` for Kyiv. The verified +`Europe/Kiev` offset is UTC+02:00 in winter and UTC+03:00 in summer, so the +local-wall-clock schedule remains 09:00 through daylight-saving changes. + +The exact prompt was tested before scheduling against 2026-08-09 with label +`issue-review:2026-08-09:Europe/Kiev:human-excluding-one-shot`. It returned 120 +unique findings across two pages: 13 recurring and 107 observed; 23 high, 86 +medium, and 11 low severity; telemetry `available`; six sessions, 30 messages, +and 2,630 tool calls. This is the comparison baseline for the first scheduled +run. + ## Release gates -### Gate 5: freeze, graph review, and commit +### Gate 5: freeze, graph review, and commit — complete 1. Stage regenerated API output and all intended Issue Review files. 2. Preserve unrelated untracked `.claude/skills/gitnexus/` and `build/`. @@ -263,7 +372,7 @@ Changing the production write timeout was not required. Graphify has no graph for this repository. GitNexus is the release graph authority; a missing Graphify artifact is not review evidence. -### Gate 6: exact-commit Windows deployment +### Gate 6: exact-commit Windows deployment — complete 1. Create a detached clean worktree at the committed revision. 2. Verify the worktree is clean and `HEAD` equals the release revision. @@ -287,11 +396,11 @@ Rollback: 3. Restart and verify its version, root UI, and API health. 4. Keep the failed artifact and sanitized logs for diagnosis. -### Gate 7: installed browser acceptance +### Gate 7: installed browser acceptance — desktop complete; narrow resize limited Verify in the installed desktop UI: -- **Issue Review** is visible on **Insights**; +- **Issue Review** is visible in primary navigation; - global timeframe and project filters change the scan scope; - chat and folder selectors enforce exact evidence containment; - category, tool, source, outcome, severity, confidence, status, @@ -343,7 +452,7 @@ Operational acceptance: Official OpenAI documentation requires the computer and desktop app to remain running for scheduled tasks that need local files or localhost services. See -[Scheduled tasks](https://learn.chatgpt.com/docs/automations). +[Scheduled tasks](https://developers.openai.com/codex/app/automations). ## Definition of done From f84c643e52ffaf313710ea6e5c04d9e1f092b009 Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:00:32 +0300 Subject: [PATCH 07/10] docs: clarify issue review graph snapshot --- docs/issue-review-handover.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/issue-review-handover.md b/docs/issue-review-handover.md index beb6b135ec..26c50f248c 100644 --- a/docs/issue-review-handover.md +++ b/docs/issue-review-handover.md @@ -266,9 +266,10 @@ focused tests pass, and the full backend suites passed before the final localized optimization. PostgreSQL `pgtest` remains blocked as described above. -GitNexus is current at exact revision `28eff63`: 47,865 nodes, 278,060 edges, -2,390 clusters, and 300 flows. Graphify remains unavailable for this -repository. Analyzer-generated `AGENTS.md` and `CLAUDE.md` churn was not kept. +At the release-code freeze, GitNexus was current at exact revision `28eff63`: +47,865 nodes, 278,060 edges, 2,390 clusters, and 300 flows. Graphify remains +unavailable for this repository. Analyzer-generated `AGENTS.md` and +`CLAUDE.md` churn was not kept. On the same isolated production-scale database, `4c3c108` took 24.488 seconds for 1,135 findings. `28eff63` took 17.763 and 17.868 seconds for the same 1,135 From 9c3c4a38486793e1949095fe46ab3f6403729e3c Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:21:45 +0300 Subject: [PATCH 08/10] test(postgres): close issue review parity gate --- .../run-postgres-integration-tests/SKILL.md | 41 +++++++++++++++++ .../agents/openai.yaml | 4 ++ docs/issue-review-handover.md | 45 +++++++++---------- internal/postgres/issue_review_pgtest_test.go | 16 ++++--- 4 files changed, 76 insertions(+), 30 deletions(-) create mode 100644 .claude/skills/run-postgres-integration-tests/SKILL.md create mode 100644 .claude/skills/run-postgres-integration-tests/agents/openai.yaml diff --git a/.claude/skills/run-postgres-integration-tests/SKILL.md b/.claude/skills/run-postgres-integration-tests/SKILL.md new file mode 100644 index 0000000000..861812b362 --- /dev/null +++ b/.claude/skills/run-postgres-integration-tests/SKILL.md @@ -0,0 +1,41 @@ +--- +name: run-postgres-integration-tests +description: Run AgentsView PostgreSQL integration and backend-parity tests against a dedicated disposable local database. Use for pgtest failures, PostgreSQL storage changes, or release gates that require TEST_PG_URL. +--- + +# Run PostgreSQL integration tests + +1. Read `docs/agents/testing.md`, `docs/agents/storage.md`, and + `docs/agents/build.md`. +2. Never use production, shared, or persistent archive databases. The tests + drop and recreate test schemas. +3. Create a unique cluster below `$env:TEMP`, bind it only to `127.0.0.1`, and + use a free non-default port. Initialize it with PostgreSQL 17 `initdb`, UTF-8, + locale `C`, user `postgres`, and local trust authentication. +4. Start with `pg_ctl -w`, create a dedicated `agentsview_test` database, and + verify the database and server version with `psql`. +5. Set `TEST_PG_URL` only in the test process. Set `CGO_ENABLED=1` and verify + the compiler target is `x86_64-w64-mingw32` before running Go. +6. Run the smallest gate first: + + ```powershell + go test -tags 'fts5,pgtest' ./internal/postgres/... -run '^TestIssueReviewRowsConditionallyLoadsResultTail$' -v -count=1 + ``` + +7. Run the full canonical gate only after the focused test passes: + + ```powershell + go test -tags 'fts5,pgtest' ./internal/postgres/... -json -count=1 + ``` + + For large output, retain only failed test events and nearby output in the + conversation. Keep the unfiltered JSON outside Git if exact diagnosis is + needed. +8. Do not repeat a failure unchanged. Identify the failing test, then rerun + that test with `-run '^ExactTestName$'` before another full suite. +9. Stop the exact scratch server with `pg_ctl -w stop -t 360`. A full suite can + leave a large checkpoint that legitimately exceeds 30 seconds; while the + log shows checkpoint progress, wait instead of killing the process. Remove + the cluster only after resolving the absolute path and proving it is a child + named `agentsview-pgtest-*` below `$env:TEMP`. Preserve logs on failure until + the cause is recorded. diff --git a/.claude/skills/run-postgres-integration-tests/agents/openai.yaml b/.claude/skills/run-postgres-integration-tests/agents/openai.yaml new file mode 100644 index 0000000000..187e74f53f --- /dev/null +++ b/.claude/skills/run-postgres-integration-tests/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "PostgreSQL Integration Tests" + short_description: "Run isolated PostgreSQL parity tests safely" + default_prompt: "Use $run-postgres-integration-tests to run the PostgreSQL integration suite against a disposable local database." diff --git a/docs/issue-review-handover.md b/docs/issue-review-handover.md index 26c50f248c..7e5a32a2b8 100644 --- a/docs/issue-review-handover.md +++ b/docs/issue-review-handover.md @@ -5,9 +5,8 @@ Status date: 2026-08-10 Release decision: **LOCAL SQLITE DESKTOP RELEASE DEPLOYED AND ACCEPTED** at `28eff630d84bf9c582eadd6b9b854fcfa1f2b99c`. The installed binary, API, browser workflow, rollback artifact, and daily read-only task are verified. -PostgreSQL integration execution remains the only unresolved backend-parity -gate because this host has no dedicated PostgreSQL test database and cannot -start the Docker test service while firmware virtualization is disabled. +PostgreSQL integration now passes against a dedicated disposable local +PostgreSQL 17 database; no backend-parity gate remains. Base revision: `915e83b91da3c553a8735f89309fd4b055189f65` @@ -38,7 +37,7 @@ Automatic remediation remains out of scope. | Shared detector and recommendations | Ready | Focused and full SQLite suites pass | | SQLite read path | Ready | Conditional tail, cache, pagination, and redaction coverage passes | | DuckDB read path | Ready | Focused and full suites pass | -| PostgreSQL read path | Compile-verified | Dedicated `pgtest` execution is blocked by host capabilities | +| PostgreSQL read path | Ready | Focused contract and full 682-test `pgtest` suite pass | | Filters, sorting, pagination, and evidence links | Ready | 2,278 frontend tests and production build pass | | Telemetry supplement | Ready | Status remains explicit; benchmark reports `available` with zero scoped rows | | Hourly and on-demand refresh | Ready | One-hour cache and forced-refresh coverage passes | @@ -141,9 +140,9 @@ classified. ### B4: backend parity -SQLite and DuckDB execution coverage passes. PostgreSQL query construction and -server compilation pass. `internal/postgres/issue_review_pgtest_test.go` covers -the same failure-tail and successful-no-tail contract but has not executed on a +Resolved. SQLite and DuckDB execution coverage passes. PostgreSQL query +construction, server compilation, the focused result-tail contract, and the +full `pgtest` package pass against a dedicated disposable PostgreSQL 17 database. ### B5: release state @@ -152,18 +151,17 @@ Implementation and validation are complete. The final freeze must include only Issue Review files and this handover. Preserve unrelated untracked `.claude/skills/gitnexus/` and `build/` content. -## Remaining PostgreSQL gate +## PostgreSQL gate closeout The canonical integration test requires a dedicated database because it drops -and recreates its test schema. This host cannot currently supply one: +and recreates test schemas. A disposable PostgreSQL 17 cluster was initialized +below `%TEMP%`, bound only to loopback on a non-default port, and removed after +the run. No production, shared, or persistent archive database was used. -- Docker Desktop cannot start its Linux engine because WSL2 reports firmware - virtualization disabled; -- no local PostgreSQL service or listener exists; -- no test container was created. - -Do not point `pgtest` at production or a shared database. When a dedicated test -database becomes available, run: +The first focused run exposed an invalid test fixture: pgx rejects multiple +parameterized SQL commands in one prepared execution. Splitting the fixture +setup into separate executions fixed the test without changing production +code. The focused contract and full canonical suite then passed: ```powershell $env:TEST_PG_URL = '' @@ -171,8 +169,8 @@ $env:CGO_ENABLED = '1' go test -tags 'fts5,pgtest' ./internal/postgres/... -v -count=1 ``` -The PostgreSQL gate remains unresolved until that command executes -successfully. +Use `.claude/skills/run-postgres-integration-tests/SKILL.md` for the safe local +workflow and cleanup guards. ## Validation evidence @@ -187,7 +185,8 @@ successfully. | PostgreSQL/server compile | Pass, packages 1.474 and 0.593 seconds | | Full SQLite suite | Pass, package 93.901 seconds | | Full DuckDB suite | Pass, package 202.524 seconds | -| PostgreSQL `pgtest` execution | Not run; dedicated database unavailable | +| Focused PostgreSQL Issue Review | Pass, package 0.514 seconds | +| Full PostgreSQL `pgtest` suite | Pass, 682 tests, package 167.702 seconds | ### Final frontend checks @@ -263,8 +262,8 @@ isolated performance/parity gate, and the exact frontend build pass. The broader `go test -tags fts5 ./internal/db ./internal/duckdb -count=1` exceeded the 184-second harness timeout without emitting a failure; the affected focused tests pass, and the full backend suites passed before the final -localized optimization. PostgreSQL `pgtest` remains blocked as described -above. +localized optimization. PostgreSQL `pgtest` later passed in full during the +backend-parity closeout described above. At the release-code freeze, GitNexus was current at exact revision `28eff63`: 47,865 nodes, 278,060 edges, 2,390 clusters, and 300 flows. Graphify remains @@ -462,8 +461,7 @@ Implementation is complete when: - B1-B5 are resolved; - SQLite, DuckDB, server, frontend, build, and default-timeout benchmark gates pass on the frozen diff; -- PostgreSQL integration is either executed or remains explicitly blocked from - the local SQLite release; +- PostgreSQL integration passes against a dedicated test database; - an exact-commit binary is installed, hashed, healthy, and browser-verified; - rollback evidence exists; - the daily read-only task is created after installed acceptance. @@ -473,7 +471,6 @@ comparison run are reviewed. ## Post-release backlog -- execute PostgreSQL `pgtest` against a dedicated database; - named saved views and multiple filter presets; - acknowledge, suppress, and expiry rules for accepted findings; - persisted “new since last review” trend snapshots; diff --git a/internal/postgres/issue_review_pgtest_test.go b/internal/postgres/issue_review_pgtest_test.go index de2585a9b6..3cc8066a76 100644 --- a/internal/postgres/issue_review_pgtest_test.go +++ b/internal/postgres/issue_review_pgtest_test.go @@ -37,17 +37,21 @@ func TestIssueReviewRowsConditionallyLoadsResultTail(t *testing.T) { success := strings.Repeat("completed output ", 200) + "\nSUCCESS_TAIL_SENTINEL" _, err := store.DB().Exec(` INSERT INTO sessions (id,machine,project,agent,first_message,started_at,message_count,user_message_count) - VALUES ($1,'test-machine','test-project','codex','Run the build','2026-08-09T10:00:00Z'::timestamptz,1,0); + VALUES ($1,'test-machine','test-project','codex','Run the build','2026-08-09T10:00:00Z'::timestamptz,1,0)`, sessionID) + require.NoError(t, err) + _, err = store.DB().Exec(` INSERT INTO messages (session_id,ordinal,role,content,timestamp,content_length) - VALUES ($1,1,'assistant','running','2026-08-09T10:00:00Z'::timestamptz,7); + VALUES ($1,1,'assistant','running','2026-08-09T10:00:00Z'::timestamptz,7)`, sessionID) + require.NoError(t, err) + _, err = store.DB().Exec(` INSERT INTO tool_calls (session_id,message_ordinal,call_index,tool_name,category,tool_use_id,input_json,result_content) VALUES ($1,1,0,'shell_command','shell','call-1','{"command":"run"}','fallback'), - ($1,1,1,'shell_command','shell','call-2','{"command":"check"}','fallback'); + ($1,1,1,'shell_command','shell','call-2','{"command":"check"}','fallback')`, sessionID) + require.NoError(t, err) + _, err = store.DB().Exec(` INSERT INTO tool_result_events (session_id,tool_call_message_ordinal,call_index,tool_use_id,source,status,content,timestamp,event_index) VALUES ($1,1,0,'call-1','tool_execution','completed',$2,'2026-08-09T10:00:02Z'::timestamptz,0), - ($1,1,1,'call-2','tool_execution','completed',$3,'2026-08-09T10:00:03Z'::timestamptz,0)`, - sessionID, failure, success, - ) + ($1,1,1,'call-2','tool_execution','completed',$3,'2026-08-09T10:00:03Z'::timestamptz,0)`, sessionID, failure, success) require.NoError(t, err) _, calls, err := store.issueReviewRows(context.Background(), []db.IssueReviewSession{{ID: sessionID}}) From 1a45e5e49984ac4c106e3032f775b3bfc59df01b Mon Sep 17 00:00:00 2001 From: xboxmasters <31378632+xboxmasters@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:49:58 +0300 Subject: [PATCH 09/10] feat(insights): add saved issue review views --- docs/insights.md | 6 + docs/issue-review-handover.md | 12 +- frontend/messages/en.json | 7 + frontend/messages/fr.json | 7 + frontend/messages/ko.json | 7 + frontend/messages/zh-CN.json | 7 + frontend/messages/zh-TW.json | 7 + .../insights/IssueReviewPanel.svelte | 136 +++++++++-- .../insights/IssueReviewPanel.test.ts | 218 ++++++++++++++++++ 9 files changed, 383 insertions(+), 24 deletions(-) create mode 100644 frontend/src/lib/components/insights/IssueReviewPanel.test.ts diff --git a/docs/insights.md b/docs/insights.md index efbc605e2e..2886f7efe0 100644 --- a/docs/insights.md +++ b/docs/insights.md @@ -44,6 +44,12 @@ filters. Each finding keeps at most five redacted evidence excerpts and links to the exact message ordinal when one exists. Results are returned in pages of 100; **Load more findings** continues through the full filtered result set. +You can name and save up to 50 complete Issue Review filter sets. Selecting a +saved view restores its filters and refreshes the results. Saving the same name +updates that view, and deleting it removes only the preset. Saved views stay in +the current browser profile; they are not stored in the archive or synced +between devices. + The panel refreshes when its filters or the global scope changes, after a debounced data-sync event, every hour while open, and on manual retry. Background refreshes use the one-hour analysis cache so frequent sync events diff --git a/docs/issue-review-handover.md b/docs/issue-review-handover.md index 7e5a32a2b8..70fc48045c 100644 --- a/docs/issue-review-handover.md +++ b/docs/issue-review-handover.md @@ -194,11 +194,11 @@ workflow and cleanup guards. | --- | --- | | `npm run i18n:compile` | Pass | | `npm run generate:api` | Pass with x64 CGO toolchain available to the subprocess | -| Locale parity | Pass; five catalogues, 1,610 keys each | +| Locale parity | Pass; five catalogues, 1,617 keys each | | `npm run check` | Pass; zero errors and eight known CSS warnings | -| `npm test` | Pass; 148 files and 2,278 tests | +| `npm test` | Pass; 149 files and 2,282 tests | | `npm run build` | Pass; one known large-chunk warning | -| project-local `vp check` | Exact documented baseline: 486 files, exit 1 | +| project-local `vp check` | Exact documented baseline: 487 files, exit 1 | Do not run `vp check --fix`; it would create an unrelated repository-wide rewrite. The final staged diff still requires `git diff --check` and the @@ -471,7 +471,11 @@ comparison run are reviewed. ## Post-release backlog -- named saved views and multiple filter presets; +Named saved views and multiple filter presets are complete. They are +browser-local, capped at 50, and covered by component behavior tests. + +Remaining: + - acknowledge, suppress, and expiry rules for accepted findings; - persisted “new since last review” trend snapshots; - per-tool slow thresholds and project-specific rule packs; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 922e0535b4..18e9b7db2b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2042,6 +2042,13 @@ "issue_review_title": "Recurring issues and improvement opportunities", "issue_review_description": "Ranked evidence from chats, tool results, and optional local Codex telemetry. Filters are evaluated on the server.", "issue_review_filters": "Issue review filters", + "issue_review_saved_view": "Saved view", + "issue_review_no_saved_view": "No saved view", + "issue_review_no_saved_views": "No saved views", + "issue_review_view_name": "View name", + "issue_review_view_name_placeholder": "Name this view", + "issue_review_save_view": "Save view", + "issue_review_delete_view": "Delete view", "issue_review_chat": "Chat", "issue_review_all_chats": "All chats", "issue_review_folder": "Folder", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index fcde07fa41..9d2733f4ff 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -2041,6 +2041,13 @@ "issue_review_title": "Problèmes récurrents et pistes d'amélioration", "issue_review_description": "Éléments classés issus des discussions, résultats d'outils et de la télémétrie locale Codex facultative. Les filtres sont évalués sur le serveur.", "issue_review_filters": "Filtres de revue des problèmes", + "issue_review_saved_view": "Vue enregistrée", + "issue_review_no_saved_view": "Aucune vue enregistrée", + "issue_review_no_saved_views": "Aucune vue enregistrée", + "issue_review_view_name": "Nom de la vue", + "issue_review_view_name_placeholder": "Nommer cette vue", + "issue_review_save_view": "Enregistrer la vue", + "issue_review_delete_view": "Supprimer la vue", "issue_review_chat": "Discussion", "issue_review_all_chats": "Toutes les discussions", "issue_review_folder": "Dossier", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index 05188ec5bc..aeb418424f 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -2004,6 +2004,13 @@ "issue_review_title": "반복 문제 및 개선 기회", "issue_review_description": "채팅, 도구 결과 및 선택적 로컬 Codex 원격 분석의 순위별 증거입니다. 필터는 서버에서 평가됩니다.", "issue_review_filters": "문제 검토 필터", + "issue_review_saved_view": "저장된 보기", + "issue_review_no_saved_view": "선택된 저장 보기가 없음", + "issue_review_no_saved_views": "저장된 보기가 없음", + "issue_review_view_name": "보기 이름", + "issue_review_view_name_placeholder": "보기 이름 지정", + "issue_review_save_view": "보기 저장", + "issue_review_delete_view": "보기 삭제", "issue_review_chat": "채팅", "issue_review_all_chats": "모든 채팅", "issue_review_folder": "폴더", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index d28e72c43e..666edf1d71 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -2004,6 +2004,13 @@ "issue_review_title": "重复问题和改进机会", "issue_review_description": "基于聊天、工具结果和可选的本地 Codex 遥测数据的排序证据。筛选条件在服务器端评估。", "issue_review_filters": "问题审查筛选条件", + "issue_review_saved_view": "已保存视图", + "issue_review_no_saved_view": "未选择已保存视图", + "issue_review_no_saved_views": "没有已保存视图", + "issue_review_view_name": "视图名称", + "issue_review_view_name_placeholder": "为此视图命名", + "issue_review_save_view": "保存视图", + "issue_review_delete_view": "删除视图", "issue_review_chat": "聊天", "issue_review_all_chats": "所有聊天", "issue_review_folder": "文件夹", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 4641acaccc..52858b541d 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -2004,6 +2004,13 @@ "issue_review_title": "重複問題與改善機會", "issue_review_description": "根據聊天、工具結果和可選的本機 Codex 遙測資料排序的證據。篩選條件在伺服器端評估。", "issue_review_filters": "問題審查篩選條件", + "issue_review_saved_view": "已儲存檢視", + "issue_review_no_saved_view": "未選擇已儲存檢視", + "issue_review_no_saved_views": "沒有已儲存檢視", + "issue_review_view_name": "檢視名稱", + "issue_review_view_name_placeholder": "為此檢視命名", + "issue_review_save_view": "儲存檢視", + "issue_review_delete_view": "刪除檢視", "issue_review_chat": "聊天", "issue_review_all_chats": "所有聊天", "issue_review_folder": "資料夾", diff --git a/frontend/src/lib/components/insights/IssueReviewPanel.svelte b/frontend/src/lib/components/insights/IssueReviewPanel.svelte index 396593116e..0792dbf10c 100644 --- a/frontend/src/lib/components/insights/IssueReviewPanel.svelte +++ b/frontend/src/lib/components/insights/IssueReviewPanel.svelte @@ -1,6 +1,6 @@