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/insights.md b/docs/insights.md index b2ff192c73..1b43ebc415 100644 --- a/docs/insights.md +++ b/docs/insights.md @@ -16,6 +16,72 @@ live under the **More** dropdown as of 0.21.0, which leaves ![Insights page](/assets/generated/screenshots/insights.png) +## Proactive Issue Review + +The top of the Insights page continuously ranks recurring problems and +automation opportunities across the selected chats. It is deterministic and +server-backed; generating an AI insight is not required. The review detects: + +- failed commands, edits, builds, tests, migrations, Git/GitHub operations, + missing files or dependencies, permissions, network errors, timeouts, and + tool crashes; +- a successful retry after a failed identical call, persistent repeated waits + or polling, and the + same substantial workflow repeated across chats or projects; +- slow non-wait tools, exact-normalized user requests repeated across chats, + explicit user corrections, and assistant-reported blockers; +- allowlisted Codex response, tool-router, hook, session, and PowerShell + snapshot failures when local telemetry is available. + +When an `exec` wrapper calls only one nested tool, Issue Review attributes the +finding and duration to that nested tool. Mixed-tool wrappers remain attributed +to `exec` because the outer result cannot identify one responsible tool. + +The global date, project, machine, agent, termination, automation, and +one-shot filters apply first. The panel adds exact chat, folder, category, tool, +outcome, severity, confidence, status, suggested-action, and minimum-occurrence +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 +cannot trigger repeated full-archive scans; **Refresh now** bypasses the cache. +If a refresh fails, the last successful result remains visible with a warning. +Acknowledgement and suppression decisions are loaded separately on every +request, so they take effect immediately without invalidating or mutating the +cached detector result. Acknowledgements reopen when a finding appears on a +later date. Suppression can last 1, 7, or 30 days, or remain permanent; hidden +findings stay available through the review-state filter. + +Durations are measured only when start and completion events can be paired. +For slow tools, occurrences count calls at least 30 seconds long, while p95 is +calculated from every measured sample for that tool. Coverage is measured +samples divided by all scoped calls for the tool. The “excess” duration is a +triage proxy above 30 seconds, not a claim that all of that time was wasted. +Wait/sleep tools and negative or malformed durations are excluded. + +Large tool results retain bounded context from both the beginning and end, so a +stable compiler, test, or command error near the tail remains classifiable. + +The optional Codex supplement reads `~/.codex/logs_2.sqlite` in read-only mode +only for scoped session IDs and exact tool-call IDs. This supplement is +available only with the local SQLite store; PostgreSQL and DuckDB use timing +events already mirrored into their own stores. Telemetry and chat/tool +excerpts are redacted before the API returns them. The panel shows a non-blocking +warning when local telemetry is missing or unavailable; chat and tool-result +analysis remains active. + +See [Proactive Issue Review handover](issue-review-handover.md) for the detector +architecture, validation matrix, deployment gates, and follow-up roadmap. + ## Insight Types There are three generation modes, selected from the dropdown at 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/docs/issue-review-handover.md b/docs/issue-review-handover.md new file mode 100644 index 0000000000..ffbb7ba0d3 --- /dev/null +++ b/docs/issue-review-handover.md @@ -0,0 +1,497 @@ +# Proactive Issue Review handover + +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 now passes against a dedicated disposable local +PostgreSQL 17 database; no backend-parity gate remains. + +Base revision: `915e83b91da3c553a8735f89309fd4b055189f65` + +## Objective + +Ship a local-first, cross-chat review that finds recurring work, confusion, +blockers, failures, slow tools, and automation opportunities across a selected +timeframe, chat, project, and working folder. Every finding keeps redacted +evidence and recommends one concrete skill, script, rule, or tool improvement. + +The finished workflow provides: + +- deterministic review across matching chats; +- date, project, folder, and exact-chat scope; +- filtering, sorting, stable pagination, and evidence navigation; +- hourly refresh while the panel is open; +- cache-bypassing review on demand; +- explicit coverage when optional telemetry is missing or unsupported; +- read-only reporting with no automatic changes to chats, code, skills, + GitHub, or daemon state. + +Automatic remediation remains out of scope. + +## Executive status + +| Surface | State | Evidence or remaining gate | +| --- | --- | --- | +| 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 | 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 | +| 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 | 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 + +### Data flow + +1. Global analytics filters resolve matching sessions. +2. SQLite, PostgreSQL, or DuckDB loads bounded message and tool-call rows. +3. The shared Go analyzer deduplicates imported copies, classifies failures, + groups recurring signatures, measures durations, and attaches evidence. +4. Local SQLite optionally supplements exact scoped calls with read-only Codex + telemetry from `%USERPROFILE%\.codex\logs_2.sqlite`. +5. Persisted acknowledgement and suppression state is overlaid on a fresh copy + of the one-hour cached base analysis for every request. +6. Cheap filters, sorting, and pagination apply after that overlay. +7. The Svelte panel renders findings and links evidence to the exact chat and + message ordinal. + +Codex JSONL-derived archive rows remain the conversation and tool-result +authority. `logs_2.sqlite` supplements timing and runtime failures; it is not a +complete chat archive. + +### Detection coverage + +- command, edit, build, test, migration, Git, GitHub, and CI failures; +- missing files and dependencies, permissions, network failures, rate limits, + timeouts, shell syntax, Windows PowerShell, and line-ending failures; +- crashes, structured tool failures, and conservative successful recovery; +- persistent polling, repeated reads, and substantial workflows repeated + across chats or projects; +- exact-normalized user requests repeated across chats; +- explicit user corrections and assistant-reported blockers; +- slow non-wait tools, measured p95, duration coverage, and excess-duration + triage proxy; +- referenced GitHub issues and allowlisted Codex router, hook, response, + session, and shell-snapshot failures. + +### Controls + +Global controls apply date, project, machine, agent, termination, one-shot, and +automation scope. Issue Review adds: + +- chat and working folder; +- category, tool, evidence source, and session outcome; +- severity, confidence, lifecycle status, and recommendation type; +- active, acknowledged, or suppressed review state; +- minimum occurrences, chats, projects, and excess duration; +- impact, frequency, recency, waste, and duration sorting; +- stable pages of 100 findings and explicit **Load more**; +- persisted local filter state, **Clear filters**, and **Refresh now**. +- **Acknowledge**, 1/7/30-day or permanent **Suppress**, and **Reopen** actions. + +Detector lifecycle status remains derived. User review state is stored in a +separate SQLite or PostgreSQL table and copied during full SQLite rebuilds. +Acknowledgement applies only through the accepted `last_seen` date; a later +occurrence reactivates the finding. Timed suppression expires against UTC. +Suppressed findings are hidden by default but remain queryable. DuckDB is +read-only and reports findings as active without an in-place mirror migration. + +### Reliability and privacy boundaries + +- Explicit event status and non-zero exit codes outrank keyword inference. +- Search exit code 1 is no match unless a concrete error exists. +- Successful read-only diagnostics may bridge a failed call to an identical + successful retry. Writes, edits, builds, failures, compound commands, and + unrelated operations close recovery. +- Every store loads a bounded result head. It loads the bounded tail only when + structured status or the head proves a likely failure. +- Content-block JSON and escaped CRLF/LF are decoded before signature + selection. Wrapper and progress lines are removed. +- Imported message and tool-call copies are deduplicated by stable identity. +- Finding signatures, recommendations, evidence, and telemetry tails redact + credentials, bearer values, and absolute Windows or Unix paths. +- Telemetry reports `available`, `missing`, `unavailable`, or `unsupported`. +- Base analysis is cached for one hour. Manual refresh bypasses the cache. + +## Resolved release blockers + +### B1: unconditional result-tail extraction + +Resolved in SQLite, PostgreSQL, and DuckDB. Obsolete length and tail parameters +were removed. Failure-tail and successful-no-tail fixtures pass in SQLite and +DuckDB; the equivalent PostgreSQL test compiles behind `pgtest`. + +### B2: orchestration messages counted as repeated requests + +Resolved. The detector rejects these harness envelopes before classification: + +- ``; +- ``; +- `Perform any necessary follow-up actions in response to the subagent + completion above`; +- `Briefly inform the user about the task result`. + +The production-scale benchmark has zero banned harness matches and 598 genuine +repeated-request findings. + +### B3: user-correction turn counting + +Resolved. Harness envelopes are rejected first, the selected-message +`userCount` heuristic is removed, and the first selected strong correction is +classified. + +### B4: backend parity + +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 + +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. + +## PostgreSQL gate closeout + +The canonical integration test requires a dedicated database because it drops +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. + +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 = '' +$env:CGO_ENABLED = '1' +go test -tags 'fts5,pgtest' ./internal/postgres/... -v -count=1 +``` + +Use `.claude/skills/run-postgres-integration-tests/SKILL.md` for the safe local +workflow and cleanup guards. + +## Validation evidence + +### Final code and storage checks + +| Check | Result | +| --- | --- | +| `go fmt ./...` | Pass | +| `go vet ./...` | Pass | +| Focused SQLite Issue Review | Pass, package 0.758 seconds | +| Focused DuckDB Issue Review | Pass, package 0.951 seconds | +| 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 | +| Focused PostgreSQL Issue Review | Pass, package 0.514 seconds | +| Full PostgreSQL `pgtest` suite | Pass, 682 tests, package 167.702 seconds | + +### Final frontend checks + +| Check | Result | +| --- | --- | +| `npm run i18n:compile` | Pass | +| `npm run generate:api` | Pass with x64 CGO toolchain available to the subprocess | +| Locale parity | Pass; five catalogues, 1,617 keys each | +| `npm run check` | Pass; zero errors and eight known CSS warnings | +| `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: 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 +private-data scrub. + +## Branch-21 archive benchmark + +The final benchmark used an isolated dirty-worktree executable, copied session +data, copied SQLite archive, isolated data directory, port 8091, hidden pprof, +and the unchanged 30-second write timeout. + +Benchmark binary SHA-256: +`9044AC3CFC68D6D883403A503E2B3B53690D3764C746565AA9D01707EF7FE931`. +This is benchmark evidence only, not the release artifact. + +| Metric | Result | +| --- | --- | +| Forced request 1 | HTTP 200, 19.729398 seconds | +| Forced request 2 | HTTP 200, 19.320250 seconds | +| Cached request | HTTP 200, 4.861 milliseconds | +| Total findings | 3,688 | +| Scanned messages | 6,426 | +| Scanned tool calls | 75,949 | +| Repeated requests | 598 | +| Recurring findings | 926 | +| Open findings | 160 | +| Recovered findings | 12 | +| Observed findings | 2,590 | +| Duplicate IDs across every page | 0 | +| Banned harness matches | 0 | +| Credential leaks in finding text | 0 | +| Absolute path leaks in finding text | 0 | +| Exact-chat containment | 31 of 31 evidence rows matched | +| Exact-folder containment | 31 of 31 evidence rows matched | +| Telemetry | `available`; zero matching scoped rows | +| Longest signature | 241 characters | + +The benchmark closed two late issues: + +1. Regex-heavy GitHub and logical-failure checks now use cheap syntax or + keyword prefilters before regular expressions. Both forced requests remain + below 20 seconds. +2. The shared finding sanitizer now redacts absolute Windows paths with either + slash style and absolute Unix paths, including paths embedded in markdown. + +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` 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 +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 — complete + +1. Stage regenerated API output and all intended Issue Review files. +2. Preserve unrelated untracked `.claude/skills/gitnexus/` and `build/`. +3. Run `git diff --check`, the private-data scrub, and inspect the complete + staged diff. +4. Refresh GitNexus against the frozen diff and run change-impact detection. +5. Resolve every blocking finding and repeat affected checks. +6. Create one focused conventional commit. Do not amend, push, merge, or create + a branch. + +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 — complete + +1. Create a detached clean worktree at the committed revision. +2. Verify the worktree is clean and `HEAD` equals the release revision. +3. Verify the compiler reports `x86_64-w64-mingw32`. +4. Build the embedded frontend and release binary with CGO and `fts5`. +5. Record revision, version, architecture, build time, and SHA-256. +6. Resolve `%LOCALAPPDATA%\Programs\AgentsView\agentsview.exe`; verify any + daemon PID belongs to that executable. +7. Stop the daemon and prove its PID exited before replacement. +8. Copy the installed executable to a timestamped backup directory. +9. Install the exact-commit binary and compare artifact and deployed hashes. +10. Restart and verify daemon version, `127.0.0.1:8080`, root UI, and + `/api/v1/analytics/issue-review`. +11. Stop the isolated 8091 benchmark server only after re-verifying its PID and + executable path. + +Rollback: + +1. Stop and verify the new daemon exited. +2. Restore the timestamped prior executable. +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 — desktop complete; narrow resize limited + +Verify in the installed desktop UI: + +- **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, + recommendation, thresholds, and sort controls work; +- **Clear filters**, **Load more**, retry, and **Refresh now** work; +- **Refresh now** forces analysis and background refresh remains cached; +- evidence links open the correct chat and message; +- API output contains no unredacted credential-shaped value or absolute path in + finding text; +- narrow and desktop layouts remain keyboard accessible. + +Record screenshots, API status, installed version, and hash. A successful +process start alone is not deployment acceptance. + +## Daily scheduled report plan + +Create this only after Gate 7 passes. Use a scheduled task attached to a +dedicated Issue Review operations chat so reports remain comparable. + +Schedule: + +- daily at 09:00 in `Europe/Kiev`; +- previous completed local day; +- one forced API request, then cached pagination for the same scope; +- idempotency label `issue-review::Europe/Kiev:`; +- report in the task and **Scheduled** inbox; +- read-only permissions and no worktree changes. + +Each run reports: + +- open, recurring, recovered, and total finding counts; +- severity and confidence split; +- total excess duration and slowest recurring tools; +- new or materially changed top patterns when prior context is available; +- grouped skill, script, rule, and tool-fix recommendations; +- telemetry status and scanned count; +- direct evidence links where available; +- daemon or API unavailability without attempting repair. + +Operational acceptance: + +- test the exact prompt manually before scheduling; +- verify timezone and daylight-saving behavior; +- review the first scheduled run and one subsequent comparison run; +- keep scope bounded and do not scan unrelated local folders; +- say when comparison context is unavailable; +- pause after three consecutive unreachable-daemon runs and request operator + review instead of attempting repair. + +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://developers.openai.com/codex/app/automations). + +## Definition of done + +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 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. + +Operational follow-up remains open until the first daily run and one subsequent +comparison run are reviewed. + +## Post-release backlog + +Named saved views, multiple filter presets, and accepted-finding review state +are complete. Saved views are browser-local and capped at 50. Review decisions +persist in SQLite or PostgreSQL with acknowledgement and suppression expiry. + +Remaining: + +- persisted “new since last review” trend snapshots; +- per-tool slow thresholds and project-specific rule packs; +- conservative near-duplicate request clustering beyond exact normalization; +- JSON or CSV export of filtered findings and evidence; +- optional read-only GitHub status enrichment for referenced issues. + +Any semantic or near-duplicate detector must ship with a labeled evaluation +set, a false-positive budget, and a deterministic fallback. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 53fb412492..086bbd0b13 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -21,7 +21,7 @@ "nav_trends": "Trends", "nav_recall": "Recall", "nav_pinned": "Pinned", - "nav_insights": "Insights", + "nav_insights": "Issue Review", "nav_trash": "Trash", "nav_search_sessions": "Search sessions...", "nav_search_sessions_shortcut": "Search sessions ({shortcut})", @@ -1463,17 +1463,17 @@ "insights_page_filter_scopes": "Filter scopes...", "insights_page_filter_by_scope": "Filter insights by session scope", "insights_page_no_matching_scopes": "No matching scopes", - "insights_page_refresh": "Refresh insights", + "insights_page_refresh": "Refresh issue review", "insights_page_rule_based": "Rule-based", - "insights_page_next_actions": "Next actions", - "insights_page_deterministic_recommendations": "Deterministic Recommendations", - "insights_page_no_rule_actions": "No rule-based actions are firing.", - "insights_page_patterns_clear": "Patterns are clear or unavailable for the current filters.", - "insights_page_scored_facts": "Scored facts", - "insights_page_quality_patterns": "Quality Patterns", - "insights_page_deterministic_counts": "Deterministic counts from persisted session signals for {range}.", - "insights_page_insights_help_intro": "Deterministic sections are computed from session data, while generated insights are separate context text.", - "insights_page_insights_help_docs": "Read Insights docs", + "insights_page_next_actions": "Prioritized improvements", + "insights_page_deterministic_recommendations": "What to fix next", + "insights_page_no_rule_actions": "No recurring issues need action.", + "insights_page_patterns_clear": "No issue patterns match the current filters.", + "insights_page_scored_facts": "Across filtered chats", + "insights_page_quality_patterns": "Top issue drivers", + "insights_page_deterministic_counts": "Aggregated from persisted chat signals for {range}.", + "insights_page_insights_help_intro": "This review ranks recurring patterns across the filtered chats; generated insights remain separate context text.", + "insights_page_insights_help_docs": "Read issue review docs", "insights_page_could_not_load": "Could not load deterministic insights.", "insights_page_retry": "Retry", "insights_page_no_scored_data": "No scored quality data for this range.", @@ -2037,5 +2037,160 @@ "appearance_high_contrast": "High contrast", "appearance_on": "On", "appearance_off": "Off", - "appearance_text_size": "Text size" + "appearance_text_size": "Text size", + "issue_review_proactive": "Proactive detection", + "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", + "issue_review_category": "Category", + "issue_review_tool": "Tool", + "issue_review_source": "Evidence source", + "issue_review_outcome": "Outcome", + "issue_review_severity": "Severity", + "issue_review_confidence": "Confidence", + "issue_review_status": "Status", + "issue_review_action": "Suggested action", + "issue_review_min_occurrences": "Minimum occurrences", + "issue_review_min_chats": "Minimum chats", + "issue_review_min_projects": "Minimum projects", + "issue_review_min_wasted": "Minimum excess time", + "issue_review_sort": "Sort by", + "issue_review_all_folders": "All folders", + "issue_review_all_categories": "All categories", + "issue_review_all_tools": "All tools", + "issue_review_all_sources": "All evidence sources", + "issue_review_all_outcomes": "All outcomes", + "issue_review_all_severities": "All severities", + "issue_review_all_confidences": "All confidence levels", + "issue_review_all_statuses": "All statuses", + "issue_review_all_review_states": "Active and acknowledged", + "issue_review_review_state": "Review state", + "issue_review_state_active": "Active", + "issue_review_state_acknowledged": "Acknowledged", + "issue_review_state_suppressed": "Suppressed", + "issue_review_acknowledge": "Acknowledge", + "issue_review_suppress_for": "Suppress for", + "issue_review_suppress_one_day": "1 day", + "issue_review_suppress_seven_days": "7 days", + "issue_review_suppress_thirty_days": "30 days", + "issue_review_suppress_permanently": "Permanently", + "issue_review_suppress": "Suppress", + "issue_review_reopen": "Reopen", + "issue_review_updating": "Updating finding", + "issue_review_update_failed": "Could not update finding state. {error}", + "issue_review_all_actions": "All actions", + "issue_review_no_matches": "No matching options", + "issue_review_min_occurrences_value": "At least {count}", + "issue_review_min_chats_value": "At least {count} chats", + "issue_review_any_projects": "Any project count", + "issue_review_min_projects_value": "At least {count} projects", + "issue_review_any_wasted_time": "Any excess time", + "issue_review_min_wasted_value": "At least {duration}", + "issue_review_sort_impact": "Highest impact", + "issue_review_sort_frequency": "Most frequent", + "issue_review_sort_recent": "Most recent", + "issue_review_sort_waste": "Most excess time", + "issue_review_sort_duration": "Most total tool time", + "issue_review_loading": "Detecting issues", + "issue_review_refreshing": "Refreshing detected issues", + "issue_review_refresh_now": "Refresh now", + "issue_review_clear_filters": "Clear issue filters", + "issue_review_load_failed": "Could not load detected issues", + "issue_review_cached_warning": "Refresh failed; showing cached findings. {error}", + "issue_review_retry": "Retry", + "issue_review_empty": "No matching issues detected", + "issue_review_empty_hint": "Widen the time range or clear one of the filters.", + "issue_review_global_project": "Global", + "issue_review_severity_high": "High", + "issue_review_severity_medium": "Medium", + "issue_review_severity_low": "Low", + "issue_review_confidence_high": "High confidence", + "issue_review_confidence_medium": "Medium confidence", + "issue_review_confidence_low": "Low confidence", + "issue_review_status_open": "Open", + "issue_review_status_recovered": "Recovered", + "issue_review_status_recurring": "Recurring", + "issue_review_status_observed": "Observed", + "issue_review_action_skill": "Create skill", + "issue_review_action_script": "Create script", + "issue_review_action_rule": "Enforce rule", + "issue_review_action_tool_fix": "Fix tool", + "issue_review_suggestion": "Suggested next step: {action}", + "issue_review_suggestion_label": "Suggested next step:", + "issue_review_open_github_issue": "Open GitHub issue {reference}", + "issue_review_p95": "p95 {duration}", + "issue_review_coverage": "{value}% measured coverage", + "issue_review_wasted_proxy": "{duration} measured excess over 30s", + "issue_review_duration_minutes": "{value} min", + "issue_review_duration_seconds": "{value} sec", + "issue_review_reason_missing_file": "Missing file or path", + "issue_review_reason_missing_dependency": "Missing dependency", + "issue_review_reason_permission_auth": "Permission or authentication failure", + "issue_review_reason_rate_limit": "Rate limit", + "issue_review_reason_network": "Network failure", + "issue_review_reason_timeout": "Timeout", + "issue_review_reason_windows_shell": "Windows shell failure", + "issue_review_reason_line_endings": "Line-ending mismatch", + "issue_review_reason_git_github_ci": "Git, GitHub, or CI failure", + "issue_review_reason_github_issue_reference": "Referenced GitHub issue", + "issue_review_reason_build_test": "Build, test, or migration failure", + "issue_review_reason_failed_edit": "Failed edit or patch", + "issue_review_reason_tool_crash": "Tool crash", + "issue_review_reason_generic_tool_failure": "Tool failure", + "issue_review_reason_command_failure": "Command or nested tool failure", + "issue_review_reason_retry_after_failure": "Repeated call after failure", + "issue_review_reason_repeated_polling": "Repeated polling", + "issue_review_reason_repeated_read": "Repeated stable read", + "issue_review_reason_shell_syntax": "Shell syntax or quoting failure", + "issue_review_reason_slow_tool": "Slow tool", + "issue_review_reason_repeated_workflow": "Repeated workflow", + "issue_review_reason_repeated_question": "Repeated user request", + "issue_review_reason_user_correction": "User correction or confusion", + "issue_review_reason_reported_blocker": "Assistant-reported blocker", + "issue_review_reason_response_retry": "App response retry", + "issue_review_reason_tool_router_error": "App tool routing error", + "issue_review_reason_hook_failure": "Hook failure", + "issue_review_reason_app_session_error": "App session error", + "issue_review_reason_shell_snapshot_failure": "PowerShell snapshot failure", + "issue_review_source_tool_result": "Tool result", + "issue_review_source_tool_execution": "Tool execution", + "issue_review_source_tool_call": "Tool call", + "issue_review_source_user_message": "User message", + "issue_review_source_assistant_commentary": "Assistant commentary", + "issue_review_source_codex_log": "Codex log", + "issue_review_source_event": "Session event", + "issue_review_source_message": "Chat message", + "issue_review_scanned_messages": "{count} candidate messages", + "issue_review_duplicates_excluded": "{count} imported copies excluded", + "issue_review_showing_findings": "Showing {shown} of {total} findings", + "issue_review_load_more": "Load more findings", + "issue_review_occurrences": [ + {"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} occurrence","countPlural=other":"{count} occurrences"}} + ], + "issue_review_chats": [ + {"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} chat","countPlural=other":"{count} chats"}} + ], + "issue_review_projects": [ + {"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} project","countPlural=other":"{count} projects"}} + ], + "issue_review_scanned_sessions": [ + {"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"Scanned {count} chat","countPlural=other":"Scanned {count} chats"}} + ], + "issue_review_scanned_calls": [ + {"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} tool call","countPlural=other":"{count} tool calls"}} + ], + "issue_review_telemetry_unavailable": "Codex tool telemetry is unavailable; chat and tool-result analysis is still active.", + "issue_review_scanned_logs": [ + {"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} local log signal","countPlural=other":"{count} local log signals"}} + ] } diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 34778fe8be..e3bb20d977 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -21,7 +21,7 @@ "nav_trends": "Tendances", "nav_recall": "Rappel", "nav_pinned": "Épinglés", - "nav_insights": "Analyses", + "nav_insights": "Revue des problèmes", "nav_trash": "Corbeille", "nav_search_sessions": "Rechercher des sessions...", "nav_search_sessions_shortcut": "Rechercher des sessions ({shortcut})", @@ -1462,17 +1462,17 @@ "insights_page_filter_scopes": "Filtrer les portées...", "insights_page_filter_by_scope": "Filtrer les analyses par portée de session", "insights_page_no_matching_scopes": "Aucune portée correspondante", - "insights_page_refresh": "Actualiser les analyses", + "insights_page_refresh": "Actualiser la revue des problèmes", "insights_page_rule_based": "Basé sur des règles", - "insights_page_next_actions": "Actions à mener", - "insights_page_deterministic_recommendations": "Recommandations déterministes", - "insights_page_no_rule_actions": "Aucune action basée sur des règles ne se déclenche.", - "insights_page_patterns_clear": "Les motifs sont sains ou indisponibles pour les filtres actuels.", - "insights_page_scored_facts": "Faits notés", - "insights_page_quality_patterns": "Motifs de qualité", - "insights_page_deterministic_counts": "Décomptes déterministes issus des signaux de session persistés pour {range}.", - "insights_page_insights_help_intro": "Les sections déterministes sont calculées à partir des données de session, tandis que les analyses générées sont un texte de contexte distinct.", - "insights_page_insights_help_docs": "Lire la documentation des analyses", + "insights_page_next_actions": "Améliorations prioritaires", + "insights_page_deterministic_recommendations": "Que corriger ensuite", + "insights_page_no_rule_actions": "Aucun problème récurrent ne nécessite d'action.", + "insights_page_patterns_clear": "Aucun schéma de problème ne correspond aux filtres actuels.", + "insights_page_scored_facts": "Sur les conversations filtrées", + "insights_page_quality_patterns": "Principaux facteurs de problème", + "insights_page_deterministic_counts": "Agrégation des signaux de conversation persistés pour {range}.", + "insights_page_insights_help_intro": "Cette revue classe les schémas récurrents dans les conversations filtrées ; les analyses générées restent un texte de contexte distinct.", + "insights_page_insights_help_docs": "Lire la documentation de la revue", "insights_page_could_not_load": "Impossible de charger les analyses déterministes.", "insights_page_retry": "Réessayer", "insights_page_no_scored_data": "Aucune donnée de qualité notée pour cette plage.", @@ -2036,5 +2036,148 @@ "appearance_high_contrast": "Contraste élevé", "appearance_on": "Activé", "appearance_off": "Désactivé", - "appearance_text_size": "Taille du texte" + "appearance_text_size": "Taille du texte", + "issue_review_proactive": "Détection proactive", + "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", + "issue_review_category": "Catégorie", + "issue_review_tool": "Outil", + "issue_review_source": "Source des éléments", + "issue_review_outcome": "Résultat", + "issue_review_severity": "Gravité", + "issue_review_confidence": "Confiance", + "issue_review_status": "État", + "issue_review_action": "Action suggérée", + "issue_review_min_occurrences": "Occurrences minimales", + "issue_review_min_chats": "Discussions minimales", + "issue_review_min_projects": "Projets minimaux", + "issue_review_min_wasted": "Temps perdu minimal", + "issue_review_sort": "Trier par", + "issue_review_all_folders": "Tous les dossiers", + "issue_review_all_categories": "Toutes les catégories", + "issue_review_all_tools": "Tous les outils", + "issue_review_all_sources": "Toutes les sources", + "issue_review_all_outcomes": "Tous les résultats", + "issue_review_all_severities": "Tous les niveaux de gravité", + "issue_review_all_confidences": "Tous les niveaux de confiance", + "issue_review_all_statuses": "Tous les états", + "issue_review_all_review_states": "Actifs et reconnus", + "issue_review_review_state": "État de revue", + "issue_review_state_active": "Actif", + "issue_review_state_acknowledged": "Reconnu", + "issue_review_state_suppressed": "Masqué", + "issue_review_acknowledge": "Reconnaître", + "issue_review_suppress_for": "Masquer pendant", + "issue_review_suppress_one_day": "1 jour", + "issue_review_suppress_seven_days": "7 jours", + "issue_review_suppress_thirty_days": "30 jours", + "issue_review_suppress_permanently": "Définitivement", + "issue_review_suppress": "Masquer", + "issue_review_reopen": "Rouvrir", + "issue_review_updating": "Mise à jour du constat", + "issue_review_update_failed": "Impossible de mettre à jour l’état du constat. {error}", + "issue_review_all_actions": "Toutes les actions", + "issue_review_no_matches": "Aucune option correspondante", + "issue_review_min_occurrences_value": "Au moins {count}", + "issue_review_min_chats_value": "Au moins {count} discussions", + "issue_review_any_projects": "Nombre de projets quelconque", + "issue_review_min_projects_value": "Au moins {count} projets", + "issue_review_any_wasted_time": "Temps perdu quelconque", + "issue_review_min_wasted_value": "Au moins {duration}", + "issue_review_sort_impact": "Impact le plus élevé", + "issue_review_sort_frequency": "Les plus fréquents", + "issue_review_sort_recent": "Les plus récents", + "issue_review_sort_waste": "Le plus de temps perdu", + "issue_review_sort_duration": "Temps total d'outil le plus élevé", + "issue_review_loading": "Détection des problèmes", + "issue_review_refreshing": "Actualisation des problèmes détectés", + "issue_review_refresh_now": "Actualiser maintenant", + "issue_review_clear_filters": "Effacer les filtres de problèmes", + "issue_review_load_failed": "Impossible de charger les problèmes détectés", + "issue_review_cached_warning": "Échec de l'actualisation ; affichage des résultats en cache. {error}", + "issue_review_retry": "Réessayer", + "issue_review_empty": "Aucun problème correspondant détecté", + "issue_review_empty_hint": "Élargissez la période ou effacez l'un des filtres.", + "issue_review_global_project": "Global", + "issue_review_severity_high": "Élevée", + "issue_review_severity_medium": "Moyenne", + "issue_review_severity_low": "Faible", + "issue_review_confidence_high": "Confiance élevée", + "issue_review_confidence_medium": "Confiance moyenne", + "issue_review_confidence_low": "Confiance faible", + "issue_review_status_open": "Ouvert", + "issue_review_status_recovered": "Rétabli", + "issue_review_status_recurring": "Récurrent", + "issue_review_status_observed": "Observé", + "issue_review_action_skill": "Créer une compétence", + "issue_review_action_script": "Créer un script", + "issue_review_action_rule": "Appliquer une règle", + "issue_review_action_tool_fix": "Corriger l'outil", + "issue_review_suggestion": "Prochaine étape suggérée : {action}", + "issue_review_suggestion_label": "Prochaine étape suggérée :", + "issue_review_open_github_issue": "Ouvrir l'issue GitHub {reference}", + "issue_review_p95": "p95 {duration}", + "issue_review_coverage": "{value}% de couverture mesurée", + "issue_review_wasted_proxy": "{duration} d'excédent mesuré au-delà de 30 s", + "issue_review_duration_minutes": "{value} min", + "issue_review_duration_seconds": "{value} s", + "issue_review_reason_missing_file": "Fichier ou chemin introuvable", + "issue_review_reason_missing_dependency": "Dépendance manquante", + "issue_review_reason_permission_auth": "Échec d'autorisation ou d'authentification", + "issue_review_reason_rate_limit": "Limite de débit", + "issue_review_reason_network": "Échec réseau", + "issue_review_reason_timeout": "Délai d'attente dépassé", + "issue_review_reason_windows_shell": "Échec de l'interpréteur Windows", + "issue_review_reason_line_endings": "Incohérence de fin de ligne", + "issue_review_reason_git_github_ci": "Échec Git, GitHub ou CI", + "issue_review_reason_github_issue_reference": "Issue GitHub référencée", + "issue_review_reason_build_test": "Échec de compilation, test ou migration", + "issue_review_reason_failed_edit": "Modification ou correctif échoué", + "issue_review_reason_tool_crash": "Plantage de l'outil", + "issue_review_reason_generic_tool_failure": "Échec de l'outil", + "issue_review_reason_command_failure": "Échec de commande ou d'outil imbriqué", + "issue_review_reason_retry_after_failure": "Appel répété après un échec", + "issue_review_reason_repeated_polling": "Interrogation répétée", + "issue_review_reason_repeated_read": "Lecture stable répétée", + "issue_review_reason_shell_syntax": "Erreur de syntaxe ou de guillemets de l'interpréteur", + "issue_review_reason_slow_tool": "Outil lent", + "issue_review_reason_repeated_workflow": "Flux de travail répété", + "issue_review_reason_repeated_question": "Demande utilisateur répétée", + "issue_review_reason_user_correction": "Correction ou confusion de l'utilisateur", + "issue_review_reason_reported_blocker": "Blocage signalé par l'assistant", + "issue_review_reason_response_retry": "Nouvel essai de réponse de l'application", + "issue_review_reason_tool_router_error": "Erreur de routage d'outil de l'application", + "issue_review_reason_hook_failure": "Échec de hook", + "issue_review_reason_app_session_error": "Erreur de session d'application", + "issue_review_reason_shell_snapshot_failure": "Échec de capture PowerShell", + "issue_review_source_tool_result": "Résultat d'outil", + "issue_review_source_tool_execution": "Exécution d'outil", + "issue_review_source_tool_call": "Appel d'outil", + "issue_review_source_user_message": "Message utilisateur", + "issue_review_source_assistant_commentary": "Commentaire de l'assistant", + "issue_review_source_codex_log": "Journal Codex", + "issue_review_source_event": "Événement de session", + "issue_review_source_message": "Message de discussion", + "issue_review_scanned_messages": "{count} messages candidats", + "issue_review_duplicates_excluded": "{count} copies importées exclues", + "issue_review_showing_findings": "Affichage de {shown} résultats sur {total}", + "issue_review_load_more": "Afficher plus de résultats", + "issue_review_occurrences": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} occurrence","countPlural=other":"{count} occurrences"}}], + "issue_review_chats": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} discussion","countPlural=other":"{count} discussions"}}], + "issue_review_projects": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} projet","countPlural=other":"{count} projets"}}], + "issue_review_scanned_sessions": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} discussion analysée","countPlural=other":"{count} discussions analysées"}}], + "issue_review_scanned_calls": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} appel d'outil","countPlural=other":"{count} appels d'outil"}}], + "issue_review_telemetry_unavailable": "La télémétrie des outils Codex est indisponible ; l'analyse des conversations et des résultats d'outils reste active.", + "issue_review_scanned_logs": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} signal de journal local","countPlural=other":"{count} signaux de journal local"}}] } diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index be4d934331..ea533972ab 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -21,7 +21,7 @@ "nav_trends": "트렌드", "nav_recall": "리콜", "nav_pinned": "고정됨", - "nav_insights": "인사이트", + "nav_insights": "문제 검토", "nav_trash": "휴지통", "nav_search_sessions": "세션 검색...", "nav_search_sessions_shortcut": "세션 검색 ({shortcut})", @@ -1431,17 +1431,17 @@ "insights_page_filter_scopes": "범위 필터링...", "insights_page_filter_by_scope": "세션 범위별로 인사이트 필터링", "insights_page_no_matching_scopes": "일치하는 범위 없음", - "insights_page_refresh": "인사이트 새로고침", + "insights_page_refresh": "문제 검토 새로 고침", "insights_page_rule_based": "규칙 기반", - "insights_page_next_actions": "다음 작업", - "insights_page_deterministic_recommendations": "결정론적 권장 사항", - "insights_page_no_rule_actions": "실행 중인 규칙 기반 작업이 없습니다.", - "insights_page_patterns_clear": "현재 필터에 대한 패턴이 없거나 양호합니다.", - "insights_page_scored_facts": "채점 기반", - "insights_page_quality_patterns": "품질 패턴", - "insights_page_deterministic_counts": "{range} 기간의 저장된 세션 신호에서 산출한 결정론적 집계입니다.", - "insights_page_insights_help_intro": "결정론적 섹션은 세션 데이터에서 계산되며, 생성된 인사이트는 별도의 컨텍스트 텍스트입니다.", - "insights_page_insights_help_docs": "인사이트 문서 읽기", + "insights_page_next_actions": "우선순위 개선 사항", + "insights_page_deterministic_recommendations": "다음에 수정할 항목", + "insights_page_no_rule_actions": "조치가 필요한 반복 문제는 없습니다.", + "insights_page_patterns_clear": "현재 필터에 해당하는 문제 패턴이 없습니다.", + "insights_page_scored_facts": "필터링된 채팅 전체", + "insights_page_quality_patterns": "주요 문제 원인", + "insights_page_deterministic_counts": "{range}의 저장된 채팅 신호를 집계합니다.", + "insights_page_insights_help_intro": "이 검토는 필터링된 채팅의 반복 패턴을 순위화하며, 생성된 인사이트는 별도 컨텍스트 텍스트입니다.", + "insights_page_insights_help_docs": "문제 검토 문서 읽기", "insights_page_could_not_load": "결정론적 인사이트를 불러올 수 없습니다.", "insights_page_retry": "재시도", "insights_page_no_scored_data": "이 기간에는 채점된 품질 데이터가 없습니다.", @@ -1999,5 +1999,148 @@ "appearance_high_contrast": "고대비", "appearance_on": "켜짐", "appearance_off": "꺼짐", - "appearance_text_size": "텍스트 크기" + "appearance_text_size": "텍스트 크기", + "issue_review_proactive": "사전 감지", + "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": "폴더", + "issue_review_category": "카테고리", + "issue_review_tool": "도구", + "issue_review_source": "증거 출처", + "issue_review_outcome": "결과", + "issue_review_severity": "심각도", + "issue_review_confidence": "신뢰도", + "issue_review_status": "상태", + "issue_review_action": "권장 작업", + "issue_review_min_occurrences": "최소 발생 횟수", + "issue_review_min_chats": "최소 채팅 수", + "issue_review_min_projects": "최소 프로젝트 수", + "issue_review_min_wasted": "최소 초과 시간", + "issue_review_sort": "정렬 기준", + "issue_review_all_folders": "모든 폴더", + "issue_review_all_categories": "모든 카테고리", + "issue_review_all_tools": "모든 도구", + "issue_review_all_sources": "모든 증거 출처", + "issue_review_all_outcomes": "모든 결과", + "issue_review_all_severities": "모든 심각도", + "issue_review_all_confidences": "모든 신뢰도", + "issue_review_all_statuses": "모든 상태", + "issue_review_all_review_states": "활성 및 확인됨", + "issue_review_review_state": "검토 상태", + "issue_review_state_active": "활성", + "issue_review_state_acknowledged": "확인됨", + "issue_review_state_suppressed": "숨김", + "issue_review_acknowledge": "확인", + "issue_review_suppress_for": "숨김 기간", + "issue_review_suppress_one_day": "1일", + "issue_review_suppress_seven_days": "7일", + "issue_review_suppress_thirty_days": "30일", + "issue_review_suppress_permanently": "영구", + "issue_review_suppress": "숨기기", + "issue_review_reopen": "다시 열기", + "issue_review_updating": "발견 항목 업데이트 중", + "issue_review_update_failed": "발견 상태를 업데이트하지 못했습니다. {error}", + "issue_review_all_actions": "모든 작업", + "issue_review_no_matches": "일치하는 옵션 없음", + "issue_review_min_occurrences_value": "최소 {count}회", + "issue_review_min_chats_value": "최소 {count}개 채팅", + "issue_review_any_projects": "프로젝트 수 제한 없음", + "issue_review_min_projects_value": "최소 {count}개 프로젝트", + "issue_review_any_wasted_time": "초과 시간 제한 없음", + "issue_review_min_wasted_value": "최소 {duration}", + "issue_review_sort_impact": "영향도 높은 순", + "issue_review_sort_frequency": "빈도 높은 순", + "issue_review_sort_recent": "최신순", + "issue_review_sort_waste": "초과 시간이 많은 순", + "issue_review_sort_duration": "총 도구 시간이 긴 순", + "issue_review_loading": "문제 감지 중", + "issue_review_refreshing": "감지된 문제 새로 고치는 중", + "issue_review_refresh_now": "지금 새로 고침", + "issue_review_clear_filters": "문제 필터 지우기", + "issue_review_load_failed": "감지된 문제를 불러올 수 없습니다", + "issue_review_cached_warning": "새로 고침에 실패했습니다. 캐시된 결과를 표시합니다. {error}", + "issue_review_retry": "다시 시도", + "issue_review_empty": "일치하는 문제가 감지되지 않았습니다", + "issue_review_empty_hint": "기간을 넓히거나 필터 하나를 지우세요.", + "issue_review_global_project": "전체", + "issue_review_severity_high": "높음", + "issue_review_severity_medium": "보통", + "issue_review_severity_low": "낮음", + "issue_review_confidence_high": "높은 신뢰도", + "issue_review_confidence_medium": "보통 신뢰도", + "issue_review_confidence_low": "낮은 신뢰도", + "issue_review_status_open": "열림", + "issue_review_status_recovered": "복구됨", + "issue_review_status_recurring": "반복됨", + "issue_review_status_observed": "관찰됨", + "issue_review_action_skill": "스킬 만들기", + "issue_review_action_script": "스크립트 만들기", + "issue_review_action_rule": "규칙 적용", + "issue_review_action_tool_fix": "도구 수정", + "issue_review_suggestion": "권장 다음 단계: {action}", + "issue_review_suggestion_label": "권장 다음 단계:", + "issue_review_open_github_issue": "GitHub 이슈 {reference} 열기", + "issue_review_p95": "p95 {duration}", + "issue_review_coverage": "측정된 적용 범위 {value}%", + "issue_review_wasted_proxy": "30초 초과 측정 시간 {duration}", + "issue_review_duration_minutes": "{value}분", + "issue_review_duration_seconds": "{value}초", + "issue_review_reason_missing_file": "파일 또는 경로 없음", + "issue_review_reason_missing_dependency": "종속성 누락", + "issue_review_reason_permission_auth": "권한 또는 인증 실패", + "issue_review_reason_rate_limit": "요청 한도", + "issue_review_reason_network": "네트워크 실패", + "issue_review_reason_timeout": "시간 초과", + "issue_review_reason_windows_shell": "Windows 셸 실패", + "issue_review_reason_line_endings": "줄바꿈 형식 불일치", + "issue_review_reason_git_github_ci": "Git, GitHub 또는 CI 실패", + "issue_review_reason_github_issue_reference": "참조된 GitHub 이슈", + "issue_review_reason_build_test": "빌드, 테스트 또는 마이그레이션 실패", + "issue_review_reason_failed_edit": "편집 또는 패치 실패", + "issue_review_reason_tool_crash": "도구 충돌", + "issue_review_reason_generic_tool_failure": "도구 실패", + "issue_review_reason_command_failure": "명령 또는 중첩 도구 실패", + "issue_review_reason_retry_after_failure": "실패 후 반복 호출", + "issue_review_reason_repeated_polling": "반복 폴링", + "issue_review_reason_repeated_read": "반복된 동일 읽기", + "issue_review_reason_shell_syntax": "셸 구문 또는 인용 오류", + "issue_review_reason_slow_tool": "느린 도구", + "issue_review_reason_repeated_workflow": "반복 워크플로", + "issue_review_reason_repeated_question": "반복된 사용자 요청", + "issue_review_reason_user_correction": "사용자 정정 또는 혼란", + "issue_review_reason_reported_blocker": "도우미가 보고한 차단 요인", + "issue_review_reason_response_retry": "앱 응답 재시도", + "issue_review_reason_tool_router_error": "앱 도구 라우팅 오류", + "issue_review_reason_hook_failure": "훅 실패", + "issue_review_reason_app_session_error": "앱 세션 오류", + "issue_review_reason_shell_snapshot_failure": "PowerShell 스냅샷 실패", + "issue_review_source_tool_result": "도구 결과", + "issue_review_source_tool_execution": "도구 실행", + "issue_review_source_tool_call": "도구 호출", + "issue_review_source_user_message": "사용자 메시지", + "issue_review_source_assistant_commentary": "도우미 해설", + "issue_review_source_codex_log": "Codex 로그", + "issue_review_source_event": "세션 이벤트", + "issue_review_source_message": "채팅 메시지", + "issue_review_scanned_messages": "후보 메시지 {count}개", + "issue_review_duplicates_excluded": "가져온 중복 사본 {count}개 제외", + "issue_review_showing_findings": "결과 {total}개 중 {shown}개 표시", + "issue_review_load_more": "결과 더 보기", + "issue_review_occurrences": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count}회 발생","countPlural=other":"{count}회 발생"}}], + "issue_review_chats": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"채팅 {count}개","countPlural=other":"채팅 {count}개"}}], + "issue_review_projects": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"프로젝트 {count}개","countPlural=other":"프로젝트 {count}개"}}], + "issue_review_scanned_sessions": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"채팅 {count}개 분석됨","countPlural=other":"채팅 {count}개 분석됨"}}], + "issue_review_scanned_calls": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"도구 호출 {count}회","countPlural=other":"도구 호출 {count}회"}}], + "issue_review_telemetry_unavailable": "Codex 도구 원격 측정을 사용할 수 없습니다. 채팅 및 도구 결과 분석은 계속 활성화됩니다.", + "issue_review_scanned_logs": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"로컬 로그 신호 {count}개","countPlural=other":"로컬 로그 신호 {count}개"}}] } diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index faa79782b7..fa0db9e1a6 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -21,7 +21,7 @@ "nav_trends": "趋势", "nav_recall": "召回", "nav_pinned": "已固定", - "nav_insights": "洞察", + "nav_insights": "问题审查", "nav_trash": "回收站", "nav_search_sessions": "搜索会话...", "nav_search_sessions_shortcut": "搜索会话 ({shortcut})", @@ -1429,17 +1429,17 @@ "insights_page_filter_scopes": "筛选范围...", "insights_page_filter_by_scope": "按会话范围筛选洞察", "insights_page_no_matching_scopes": "无匹配范围", - "insights_page_refresh": "刷新洞察", + "insights_page_refresh": "刷新问题审查", "insights_page_rule_based": "基于规则", - "insights_page_next_actions": "下一步行动", - "insights_page_deterministic_recommendations": "确定性建议", - "insights_page_no_rule_actions": "当前没有触发任何基于规则的行动。", - "insights_page_patterns_clear": "当前筛选条件下模式正常或不可用。", - "insights_page_scored_facts": "评分事实", - "insights_page_quality_patterns": "质量模式", - "insights_page_deterministic_counts": "基于 {range} 持久化会话信号的确定性统计。", - "insights_page_insights_help_intro": "确定性部分由会话数据计算得出,生成洞察是分开展示的说明文本。", - "insights_page_insights_help_docs": "查看 Insights 文档", + "insights_page_next_actions": "优先改进项", + "insights_page_deterministic_recommendations": "下一步修复内容", + "insights_page_no_rule_actions": "没有需要处理的重复问题。", + "insights_page_patterns_clear": "当前筛选条件下没有问题模式。", + "insights_page_scored_facts": "跨筛选聊天", + "insights_page_quality_patterns": "主要问题驱动因素", + "insights_page_deterministic_counts": "基于 {range} 的持久化聊天信号汇总。", + "insights_page_insights_help_intro": "此审查对筛选聊天中的重复模式进行排序;生成的洞察仍是单独的上下文文本。", + "insights_page_insights_help_docs": "阅读问题审查文档", "insights_page_could_not_load": "无法加载确定性洞察。", "insights_page_retry": "重试", "insights_page_no_scored_data": "此范围内无评分质量数据。", @@ -1999,5 +1999,148 @@ "appearance_high_contrast": "高对比度", "appearance_on": "开", "appearance_off": "关", - "appearance_text_size": "文字大小" + "appearance_text_size": "文字大小", + "issue_review_proactive": "主动检测", + "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": "文件夹", + "issue_review_category": "类别", + "issue_review_tool": "工具", + "issue_review_source": "证据来源", + "issue_review_outcome": "结果", + "issue_review_severity": "严重程度", + "issue_review_confidence": "置信度", + "issue_review_status": "状态", + "issue_review_action": "建议操作", + "issue_review_min_occurrences": "最少出现次数", + "issue_review_min_chats": "最少聊天数", + "issue_review_min_projects": "最少项目数", + "issue_review_min_wasted": "最少额外耗时", + "issue_review_sort": "排序方式", + "issue_review_all_folders": "所有文件夹", + "issue_review_all_categories": "所有类别", + "issue_review_all_tools": "所有工具", + "issue_review_all_sources": "所有证据来源", + "issue_review_all_outcomes": "所有结果", + "issue_review_all_severities": "所有严重程度", + "issue_review_all_confidences": "所有置信度级别", + "issue_review_all_statuses": "所有状态", + "issue_review_all_review_states": "活跃和已确认", + "issue_review_review_state": "审阅状态", + "issue_review_state_active": "活跃", + "issue_review_state_acknowledged": "已确认", + "issue_review_state_suppressed": "已抑制", + "issue_review_acknowledge": "确认", + "issue_review_suppress_for": "抑制时长", + "issue_review_suppress_one_day": "1 天", + "issue_review_suppress_seven_days": "7 天", + "issue_review_suppress_thirty_days": "30 天", + "issue_review_suppress_permanently": "永久", + "issue_review_suppress": "抑制", + "issue_review_reopen": "重新打开", + "issue_review_updating": "正在更新发现", + "issue_review_update_failed": "无法更新发现状态。{error}", + "issue_review_all_actions": "所有建议操作", + "issue_review_no_matches": "没有匹配的选项", + "issue_review_min_occurrences_value": "至少 {count} 次", + "issue_review_min_chats_value": "至少 {count} 个聊天", + "issue_review_any_projects": "不限项目数", + "issue_review_min_projects_value": "至少 {count} 个项目", + "issue_review_any_wasted_time": "不限额外耗时", + "issue_review_min_wasted_value": "至少 {duration}", + "issue_review_sort_impact": "影响最大", + "issue_review_sort_frequency": "最常发生", + "issue_review_sort_recent": "最新发生", + "issue_review_sort_waste": "额外耗时最多", + "issue_review_sort_duration": "工具总耗时最长", + "issue_review_loading": "正在检测问题", + "issue_review_refreshing": "正在刷新检测到的问题", + "issue_review_refresh_now": "立即刷新", + "issue_review_clear_filters": "清除问题筛选条件", + "issue_review_load_failed": "无法加载检测到的问题", + "issue_review_cached_warning": "刷新失败;正在显示缓存的发现。{error}", + "issue_review_retry": "重试", + "issue_review_empty": "未检测到匹配的问题", + "issue_review_empty_hint": "扩大时间范围或清除其中一个筛选条件。", + "issue_review_global_project": "全局", + "issue_review_severity_high": "高", + "issue_review_severity_medium": "中", + "issue_review_severity_low": "低", + "issue_review_confidence_high": "高置信度", + "issue_review_confidence_medium": "中等置信度", + "issue_review_confidence_low": "低置信度", + "issue_review_status_open": "未解决", + "issue_review_status_recovered": "已恢复", + "issue_review_status_recurring": "反复出现", + "issue_review_status_observed": "已观察到", + "issue_review_action_skill": "创建技能", + "issue_review_action_script": "创建脚本", + "issue_review_action_rule": "强制执行规则", + "issue_review_action_tool_fix": "修复工具", + "issue_review_suggestion": "建议的下一步:{action}", + "issue_review_suggestion_label": "建议的下一步:", + "issue_review_open_github_issue": "打开 GitHub 问题 {reference}", + "issue_review_p95": "p95 {duration}", + "issue_review_coverage": "已测量覆盖率 {value}%", + "issue_review_wasted_proxy": "测得超出 30 秒的额外耗时 {duration}", + "issue_review_duration_minutes": "{value} 分钟", + "issue_review_duration_seconds": "{value} 秒", + "issue_review_reason_missing_file": "缺少文件或路径", + "issue_review_reason_missing_dependency": "缺少依赖项", + "issue_review_reason_permission_auth": "权限或身份验证失败", + "issue_review_reason_rate_limit": "速率限制", + "issue_review_reason_network": "网络失败", + "issue_review_reason_timeout": "超时", + "issue_review_reason_windows_shell": "Windows shell 失败", + "issue_review_reason_line_endings": "行尾符不匹配", + "issue_review_reason_git_github_ci": "Git、GitHub 或 CI 失败", + "issue_review_reason_github_issue_reference": "引用的 GitHub 问题", + "issue_review_reason_build_test": "构建、测试或迁移失败", + "issue_review_reason_failed_edit": "编辑或补丁失败", + "issue_review_reason_tool_crash": "工具崩溃", + "issue_review_reason_generic_tool_failure": "工具失败", + "issue_review_reason_command_failure": "命令或嵌套工具失败", + "issue_review_reason_retry_after_failure": "失败后的重复调用", + "issue_review_reason_repeated_polling": "重复轮询", + "issue_review_reason_repeated_read": "重复读取未变内容", + "issue_review_reason_shell_syntax": "Shell 语法或引号失败", + "issue_review_reason_slow_tool": "工具缓慢", + "issue_review_reason_repeated_workflow": "重复工作流", + "issue_review_reason_repeated_question": "重复的用户请求", + "issue_review_reason_user_correction": "用户纠正或困惑", + "issue_review_reason_reported_blocker": "助手报告的阻塞项", + "issue_review_reason_response_retry": "应用响应重试", + "issue_review_reason_tool_router_error": "应用工具路由错误", + "issue_review_reason_hook_failure": "钩子失败", + "issue_review_reason_app_session_error": "应用会话错误", + "issue_review_reason_shell_snapshot_failure": "PowerShell 快照失败", + "issue_review_source_tool_result": "工具结果", + "issue_review_source_tool_execution": "工具执行", + "issue_review_source_tool_call": "工具调用", + "issue_review_source_user_message": "用户消息", + "issue_review_source_assistant_commentary": "助手评注", + "issue_review_source_codex_log": "Codex 日志", + "issue_review_source_event": "会话事件", + "issue_review_source_message": "聊天消息", + "issue_review_scanned_messages": "{count} 条候选消息", + "issue_review_duplicates_excluded": "已排除 {count} 个导入副本", + "issue_review_showing_findings": "显示 {shown} / {total} 个发现", + "issue_review_load_more": "加载更多结果", + "issue_review_occurrences": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 次出现","countPlural=other":"{count} 次出现"}}], + "issue_review_chats": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 个聊天","countPlural=other":"{count} 个聊天"}}], + "issue_review_projects": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 个项目","countPlural=other":"{count} 个项目"}}], + "issue_review_scanned_sessions": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"已扫描 {count} 个聊天","countPlural=other":"已扫描 {count} 个聊天"}}], + "issue_review_scanned_calls": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 次工具调用","countPlural=other":"{count} 次工具调用"}}], + "issue_review_telemetry_unavailable": "Codex 工具遥测不可用;聊天和工具结果分析仍在运行。", + "issue_review_scanned_logs": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 个本地日志信号","countPlural=other":"{count} 个本地日志信号"}}] } diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index 739cc4bfeb..106664ca92 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -21,7 +21,7 @@ "nav_trends": "趨勢", "nav_recall": "召回", "nav_pinned": "已釘選", - "nav_insights": "洞察", + "nav_insights": "問題檢視", "nav_trash": "垃圾桶", "nav_search_sessions": "搜尋對話...", "nav_search_sessions_shortcut": "搜尋對話 ({shortcut})", @@ -1429,17 +1429,17 @@ "insights_page_filter_scopes": "篩選範圍...", "insights_page_filter_by_scope": "按對話範圍篩選洞察", "insights_page_no_matching_scopes": "無匹配範圍", - "insights_page_refresh": "重整洞察", + "insights_page_refresh": "重新整理問題檢視", "insights_page_rule_based": "基於規則", - "insights_page_next_actions": "下一步行動", - "insights_page_deterministic_recommendations": "確定性建議", - "insights_page_no_rule_actions": "目前沒有觸發任何基於規則的行動。", - "insights_page_patterns_clear": "目前篩選條件下模式正常或不可用。", - "insights_page_scored_facts": "評分事實", - "insights_page_quality_patterns": "品質模式", - "insights_page_deterministic_counts": "基於 {range} 持久化對話信號的確定性統計。", - "insights_page_insights_help_intro": "確定性部分由對話資料計算得出,生成洞察是分開展示的說明文本。", - "insights_page_insights_help_docs": "查看 Insights 文件", + "insights_page_next_actions": "優先改善項目", + "insights_page_deterministic_recommendations": "下一步修正項目", + "insights_page_no_rule_actions": "沒有需要處理的重複問題。", + "insights_page_patterns_clear": "目前篩選條件下沒有問題模式。", + "insights_page_scored_facts": "跨篩選聊天", + "insights_page_quality_patterns": "主要問題驅動因素", + "insights_page_deterministic_counts": "根據 {range} 的持久化聊天信號彙總。", + "insights_page_insights_help_intro": "此檢視會為篩選聊天中的重複模式排序;產生的洞察仍是獨立的內容文字。", + "insights_page_insights_help_docs": "閱讀問題檢視文件", "insights_page_could_not_load": "無法載入確定性洞察。", "insights_page_retry": "重試", "insights_page_no_scored_data": "此範圍內無評分品質資料。", @@ -1999,5 +1999,148 @@ "appearance_high_contrast": "高對比", "appearance_on": "開", "appearance_off": "關", - "appearance_text_size": "文字大小" + "appearance_text_size": "文字大小", + "issue_review_proactive": "主動偵測", + "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": "資料夾", + "issue_review_category": "類別", + "issue_review_tool": "工具", + "issue_review_source": "證據來源", + "issue_review_outcome": "結果", + "issue_review_severity": "嚴重程度", + "issue_review_confidence": "信心水準", + "issue_review_status": "狀態", + "issue_review_action": "建議操作", + "issue_review_min_occurrences": "最少出現次數", + "issue_review_min_chats": "最少聊天數", + "issue_review_min_projects": "最少專案數", + "issue_review_min_wasted": "最少額外耗時", + "issue_review_sort": "排序方式", + "issue_review_all_folders": "所有資料夾", + "issue_review_all_categories": "所有類別", + "issue_review_all_tools": "所有工具", + "issue_review_all_sources": "所有證據來源", + "issue_review_all_outcomes": "所有結果", + "issue_review_all_severities": "所有嚴重程度", + "issue_review_all_confidences": "所有信心水準", + "issue_review_all_statuses": "所有狀態", + "issue_review_all_review_states": "作用中和已確認", + "issue_review_review_state": "審閱狀態", + "issue_review_state_active": "作用中", + "issue_review_state_acknowledged": "已確認", + "issue_review_state_suppressed": "已抑制", + "issue_review_acknowledge": "確認", + "issue_review_suppress_for": "抑制時間", + "issue_review_suppress_one_day": "1 天", + "issue_review_suppress_seven_days": "7 天", + "issue_review_suppress_thirty_days": "30 天", + "issue_review_suppress_permanently": "永久", + "issue_review_suppress": "抑制", + "issue_review_reopen": "重新開啟", + "issue_review_updating": "正在更新發現", + "issue_review_update_failed": "無法更新發現狀態。{error}", + "issue_review_all_actions": "所有建議操作", + "issue_review_no_matches": "沒有相符的選項", + "issue_review_min_occurrences_value": "至少 {count} 次", + "issue_review_min_chats_value": "至少 {count} 個聊天", + "issue_review_any_projects": "不限專案數", + "issue_review_min_projects_value": "至少 {count} 個專案", + "issue_review_any_wasted_time": "不限額外耗時", + "issue_review_min_wasted_value": "至少 {duration}", + "issue_review_sort_impact": "影響最大", + "issue_review_sort_frequency": "最常發生", + "issue_review_sort_recent": "最新發生", + "issue_review_sort_waste": "額外耗時最多", + "issue_review_sort_duration": "工具總耗時最長", + "issue_review_loading": "正在偵測問題", + "issue_review_refreshing": "正在重新整理偵測到的問題", + "issue_review_refresh_now": "立即重新整理", + "issue_review_clear_filters": "清除問題篩選條件", + "issue_review_load_failed": "無法載入偵測到的問題", + "issue_review_cached_warning": "重新整理失敗;正在顯示快取的發現。{error}", + "issue_review_retry": "重試", + "issue_review_empty": "未偵測到相符的問題", + "issue_review_empty_hint": "擴大時間範圍或清除其中一個篩選條件。", + "issue_review_global_project": "全域", + "issue_review_severity_high": "高", + "issue_review_severity_medium": "中", + "issue_review_severity_low": "低", + "issue_review_confidence_high": "高信心", + "issue_review_confidence_medium": "中等信心", + "issue_review_confidence_low": "低信心", + "issue_review_status_open": "未解決", + "issue_review_status_recovered": "已復原", + "issue_review_status_recurring": "反覆出現", + "issue_review_status_observed": "已觀察到", + "issue_review_action_skill": "建立技能", + "issue_review_action_script": "建立指令碼", + "issue_review_action_rule": "強制執行規則", + "issue_review_action_tool_fix": "修復工具", + "issue_review_suggestion": "建議的下一步:{action}", + "issue_review_suggestion_label": "建議的下一步:", + "issue_review_open_github_issue": "開啟 GitHub 問題 {reference}", + "issue_review_p95": "p95 {duration}", + "issue_review_coverage": "已測量覆蓋率 {value}%", + "issue_review_wasted_proxy": "測得超出 30 秒的額外耗時 {duration}", + "issue_review_duration_minutes": "{value} 分鐘", + "issue_review_duration_seconds": "{value} 秒", + "issue_review_reason_missing_file": "缺少檔案或路徑", + "issue_review_reason_missing_dependency": "缺少相依項目", + "issue_review_reason_permission_auth": "權限或驗證失敗", + "issue_review_reason_rate_limit": "速率限制", + "issue_review_reason_network": "網路失敗", + "issue_review_reason_timeout": "逾時", + "issue_review_reason_windows_shell": "Windows shell 失敗", + "issue_review_reason_line_endings": "換行符號不符", + "issue_review_reason_git_github_ci": "Git、GitHub 或 CI 失敗", + "issue_review_reason_github_issue_reference": "引用的 GitHub 問題", + "issue_review_reason_build_test": "建置、測試或遷移失敗", + "issue_review_reason_failed_edit": "編輯或修補失敗", + "issue_review_reason_tool_crash": "工具當機", + "issue_review_reason_generic_tool_failure": "工具失敗", + "issue_review_reason_command_failure": "命令或巢狀工具失敗", + "issue_review_reason_retry_after_failure": "失敗後的重複呼叫", + "issue_review_reason_repeated_polling": "重複輪詢", + "issue_review_reason_repeated_read": "重複讀取未變內容", + "issue_review_reason_shell_syntax": "Shell 語法或引號失敗", + "issue_review_reason_slow_tool": "工具緩慢", + "issue_review_reason_repeated_workflow": "重複工作流程", + "issue_review_reason_repeated_question": "重複的使用者請求", + "issue_review_reason_user_correction": "使用者更正或困惑", + "issue_review_reason_reported_blocker": "助手回報的阻礙", + "issue_review_reason_response_retry": "應用程式回應重試", + "issue_review_reason_tool_router_error": "應用程式工具路由錯誤", + "issue_review_reason_hook_failure": "掛鉤失敗", + "issue_review_reason_app_session_error": "應用程式工作階段錯誤", + "issue_review_reason_shell_snapshot_failure": "PowerShell 快照失敗", + "issue_review_source_tool_result": "工具結果", + "issue_review_source_tool_execution": "工具執行", + "issue_review_source_tool_call": "工具呼叫", + "issue_review_source_user_message": "使用者訊息", + "issue_review_source_assistant_commentary": "助手評註", + "issue_review_source_codex_log": "Codex 日誌", + "issue_review_source_event": "工作階段事件", + "issue_review_source_message": "聊天訊息", + "issue_review_scanned_messages": "{count} 則候選訊息", + "issue_review_duplicates_excluded": "已排除 {count} 個匯入副本", + "issue_review_showing_findings": "顯示 {shown} / {total} 個發現", + "issue_review_load_more": "載入更多結果", + "issue_review_occurrences": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 次出現","countPlural=other":"{count} 次出現"}}], + "issue_review_chats": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 個聊天","countPlural=other":"{count} 個聊天"}}], + "issue_review_projects": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 個專案","countPlural=other":"{count} 個專案"}}], + "issue_review_scanned_sessions": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"已掃描 {count} 個聊天","countPlural=other":"已掃描 {count} 個聊天"}}], + "issue_review_scanned_calls": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 次工具呼叫","countPlural=other":"{count} 次工具呼叫"}}], + "issue_review_telemetry_unavailable": "Codex 工具遙測無法使用;聊天與工具結果分析仍在執行。", + "issue_review_scanned_logs": [{"declarations":["input count","local countPlural = count: plural"],"selectors":["countPlural"],"match":{"countPlural=one":"{count} 個本機日誌訊號","countPlural=other":"{count} 個本機日誌訊號"}}] } diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index eb769c7f57..78a3727ced 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -54,6 +54,12 @@ export type { DbHeatmapResponse } from './models/DbHeatmapResponse'; export type { DbHourOfWeekCell } from './models/DbHourOfWeekCell'; export type { DbHourOfWeekResponse } from './models/DbHourOfWeekResponse'; export type { DbInsight } from './models/DbInsight'; +export type { DbIssueFacet } from './models/DbIssueFacet'; +export type { DbIssueReviewEvidence } from './models/DbIssueReviewEvidence'; +export type { DbIssueReviewFacets } from './models/DbIssueReviewFacets'; +export type { DbIssueReviewFinding } from './models/DbIssueReviewFinding'; +export type { DbIssueReviewFindingState } from './models/DbIssueReviewFindingState'; +export type { DbIssueReviewResponse } from './models/DbIssueReviewResponse'; export type { DbMachineBreakdown } from './models/DbMachineBreakdown'; export type { DbMessage } from './models/DbMessage'; export type { DbModelBreakdown } from './models/DbModelBreakdown'; @@ -162,6 +168,7 @@ export type { GenerateInsightRequest } from './models/GenerateInsightRequest'; export type { GithubConfigResponse } from './models/GithubConfigResponse'; export type { InsightCannedSessionFilters } from './models/InsightCannedSessionFilters'; export type { InsightsResponse } from './models/InsightsResponse'; +export { IssueReviewFindingStateInputBody } from './models/IssueReviewFindingStateInputBody'; export type { MachinesResponse } from './models/MachinesResponse'; export type { ModelTotal } from './models/ModelTotal'; export type { MoneyMoney } from './models/MoneyMoney'; diff --git a/frontend/src/lib/api/generated/models/DbIssueFacet.ts b/frontend/src/lib/api/generated/models/DbIssueFacet.ts new file mode 100644 index 0000000000..d82efee2d3 --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbIssueFacet.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DbIssueFacet = { + count: number; + label?: string; + value: string; +}; diff --git a/frontend/src/lib/api/generated/models/DbIssueReviewEvidence.ts b/frontend/src/lib/api/generated/models/DbIssueReviewEvidence.ts new file mode 100644 index 0000000000..626c12d3b1 --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbIssueReviewEvidence.ts @@ -0,0 +1,20 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DbIssueReviewEvidence = { + agent: string; + call_index?: number; + cwd: string; + date: string; + duration_ms?: number; + event_status?: string; + excerpt: string; + message_ordinal?: number; + outcome: string; + project: string; + recovered: boolean; + session_id: string; + source: string; + tool: string; +}; diff --git a/frontend/src/lib/api/generated/models/DbIssueReviewFacets.ts b/frontend/src/lib/api/generated/models/DbIssueReviewFacets.ts new file mode 100644 index 0000000000..0343b7bf0e --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbIssueReviewFacets.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DbIssueFacet } from './DbIssueFacet'; +export type DbIssueReviewFacets = { + category: Array; + confidence: Array; + folder: Array; + outcome: Array; + recommendation_type: Array; + review_state: Array; + session: Array; + severity: Array; + source: Array; + status: Array; + tool: Array; +}; diff --git a/frontend/src/lib/api/generated/models/DbIssueReviewFinding.ts b/frontend/src/lib/api/generated/models/DbIssueReviewFinding.ts new file mode 100644 index 0000000000..425bbea67c --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbIssueReviewFinding.ts @@ -0,0 +1,31 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DbIssueReviewEvidence } from './DbIssueReviewEvidence'; +export type DbIssueReviewFinding = { + confidence: string; + duration_coverage: number; + duration_source?: string; + evidence: Array; + github_reference?: string; + id: string; + incomplete_session_count: number; + last_seen: string; + occurrences: number; + p95_duration_ms?: number; + project_count: number; + reason_code: string; + recommendation: string; + recommendation_type: string; + review_state: string; + review_state_expires_at?: string; + session_count: number; + severity: string; + signature: string; + sources: Array; + status: string; + tool: string; + total_duration_ms: number; + wasted_duration_ms: number; +}; diff --git a/frontend/src/lib/api/generated/models/DbIssueReviewFindingState.ts b/frontend/src/lib/api/generated/models/DbIssueReviewFindingState.ts new file mode 100644 index 0000000000..c28da7e32f --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbIssueReviewFindingState.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DbIssueReviewFindingState = { + accepted_last_seen: string; + finding_id: string; + review_state: string; + suppressed_until?: string; + updated_at: string; +}; diff --git a/frontend/src/lib/api/generated/models/DbIssueReviewResponse.ts b/frontend/src/lib/api/generated/models/DbIssueReviewResponse.ts new file mode 100644 index 0000000000..8f6548510d --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbIssueReviewResponse.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { DbIssueReviewFacets } from './DbIssueReviewFacets'; +import type { DbIssueReviewFinding } from './DbIssueReviewFinding'; +export type DbIssueReviewResponse = { + analyzed_messages: number; + analyzed_tool_calls: number; + duplicate_messages: number; + duplicate_tool_calls: number; + facets: DbIssueReviewFacets; + findings: Array; + generated_at: string; + scanned_messages: number; + scanned_sessions: number; + scanned_telemetry: number; + scanned_tool_calls: number; + telemetry_status: string; + total_findings: number; + truncated: boolean; +}; diff --git a/frontend/src/lib/api/generated/models/IssueReviewFindingStateInputBody.ts b/frontend/src/lib/api/generated/models/IssueReviewFindingStateInputBody.ts new file mode 100644 index 0000000000..f0b857a060 --- /dev/null +++ b/frontend/src/lib/api/generated/models/IssueReviewFindingStateInputBody.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type IssueReviewFindingStateInputBody = { + /** + * Finding last_seen snapshot + */ + finding_last_seen: string; + /** + * Accepted finding state + */ + review_state: IssueReviewFindingStateInputBody.review_state; + /** + * Suppress for 1, 7, or 30 days; omit for permanent suppression + */ + suppression_days?: number; +}; +export namespace IssueReviewFindingStateInputBody { + /** + * Accepted finding state + */ + export enum review_state { + ACKNOWLEDGED = 'acknowledged', + SUPPRESSED = 'suppressed', + } +} diff --git a/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts b/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts index b6243db504..213095b530 100644 --- a/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts +++ b/frontend/src/lib/api/generated/models/ServiceSessionDetail.ts @@ -49,6 +49,7 @@ export type ServiceSessionDetail = { quality_signals?: DbQualitySignals; relationship_type?: string; secret_leak_count: number; + session_kind?: string; signals_pending_since?: string; source_session_id?: string; source_version?: string; diff --git a/frontend/src/lib/api/generated/services/AnalyticsService.ts b/frontend/src/lib/api/generated/services/AnalyticsService.ts index 1445e4af40..0c7e6d2752 100644 --- a/frontend/src/lib/api/generated/services/AnalyticsService.ts +++ b/frontend/src/lib/api/generated/services/AnalyticsService.ts @@ -6,6 +6,8 @@ import type { DbActivityResponse } from '../models/DbActivityResponse'; import type { DbAnalyticsSummary } from '../models/DbAnalyticsSummary'; import type { DbHeatmapResponse } from '../models/DbHeatmapResponse'; import type { DbHourOfWeekResponse } from '../models/DbHourOfWeekResponse'; +import type { DbIssueReviewFindingState } from '../models/DbIssueReviewFindingState'; +import type { DbIssueReviewResponse } from '../models/DbIssueReviewResponse'; import type { DbProjectsAnalyticsResponse } from '../models/DbProjectsAnalyticsResponse'; import type { DbSessionShapeResponse } from '../models/DbSessionShapeResponse'; import type { DbSignalsAnalyticsResponse } from '../models/DbSignalsAnalyticsResponse'; @@ -14,6 +16,7 @@ import type { DbSkillsAnalyticsResponse } from '../models/DbSkillsAnalyticsRespo import type { DbToolsAnalyticsResponse } from '../models/DbToolsAnalyticsResponse'; import type { DbTopSessionsResponse } from '../models/DbTopSessionsResponse'; import type { DbVelocityResponse } from '../models/DbVelocityResponse'; +import type { IssueReviewFindingStateInputBody } from '../models/IssueReviewFindingStateInputBody'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; @@ -402,6 +405,322 @@ export class AnalyticsService { }, }); } + /** + * Get proactive issue review + * @returns DbIssueReviewResponse OK + * @throws ApiError + */ + public static getApiV1AnalyticsIssueReview({ + from, + to, + timezone, + machine, + project, + gitBranch, + agent, + model, + dow, + hour, + minUserMessages, + activeSince, + automatedScope, + includeOneShot, + includeAutomated, + termination, + sessionId, + folder, + category, + reason, + tool, + source, + outcome, + severity, + confidence, + status, + reviewState, + recommendationType, + minOccurrences = 1, + minSessions = 1, + minProjects, + minWastedMs, + sort = 'impact', + refresh = false, + offset, + limit = 50, + }: { + /** + * Range start date + */ + from?: string, + /** + * Range end date + */ + to?: string, + /** + * IANA timezone name + */ + timezone?: string, + /** + * Filter by machine + */ + machine?: string, + /** + * Filter by project + */ + project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, + /** + * Filter by agent + */ + agent?: string, + /** + * Comma-separated model filter + */ + model?: string, + /** + * Day of week, Monday=0 through Sunday=6 + */ + dow?: number, + /** + * Hour of day, 0 through 23 + */ + hour?: number, + /** + * Minimum user message count + */ + minUserMessages?: number, + /** + * Filter sessions active since this RFC3339 timestamp + */ + activeSince?: string, + /** + * Automation scope + */ + automatedScope?: 'human' | 'all' | 'automated', + /** + * Include one-shot sessions + */ + includeOneShot?: boolean, + /** + * Include automated sessions + */ + includeAutomated?: boolean, + /** + * Filter by termination reason + */ + termination?: string, + /** + * Exact chat session ID + */ + sessionId?: string, + /** + * Exact session working directory + */ + folder?: string, + /** + * Issue reason code + */ + category?: string, + /** + * Issue reason code alias + */ + reason?: string, + /** + * Normalized tool name + */ + tool?: string, + /** + * Evidence source + */ + source?: string, + /** + * Session outcome + */ + outcome?: string, + /** + * Finding severity + */ + severity?: 'high' | 'medium' | 'low', + /** + * Finding confidence + */ + confidence?: 'high' | 'medium' | 'low', + /** + * Finding status + */ + status?: 'open' | 'recovered' | 'recurring' | 'observed', + /** + * User review state; suppressed findings are hidden when omitted + */ + reviewState?: 'active' | 'acknowledged' | 'suppressed', + /** + * Suggested action type + */ + recommendationType?: 'skill' | 'script' | 'rule' | 'tool_fix', + /** + * Minimum repeated occurrences + */ + minOccurrences?: number, + /** + * Minimum distinct chats + */ + minSessions?: number, + /** + * Minimum distinct projects + */ + minProjects?: number, + /** + * Minimum estimated wasted duration in milliseconds + */ + minWastedMs?: number, + /** + * Finding sort order + */ + sort?: 'impact' | 'frequency' | 'recent' | 'waste' | 'duration', + /** + * Bypass the short analysis cache + */ + refresh?: boolean, + /** + * Findings to skip after filtering and sorting + */ + offset?: number, + /** + * Maximum findings + */ + limit?: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/analytics/issue-review', + query: { + 'from': from, + 'to': to, + 'timezone': timezone, + 'machine': machine, + 'project': project, + 'git_branch': gitBranch, + 'agent': agent, + 'model': model, + 'dow': dow, + 'hour': hour, + 'min_user_messages': minUserMessages, + 'active_since': activeSince, + 'automated_scope': automatedScope, + 'include_one_shot': includeOneShot, + 'include_automated': includeAutomated, + 'termination': termination, + 'session_id': sessionId, + 'folder': folder, + 'category': category, + 'reason': reason, + 'tool': tool, + 'source': source, + 'outcome': outcome, + 'severity': severity, + 'confidence': confidence, + 'status': status, + 'review_state': reviewState, + 'recommendation_type': recommendationType, + 'min_occurrences': minOccurrences, + 'min_sessions': minSessions, + 'min_projects': minProjects, + 'min_wasted_ms': minWastedMs, + 'sort': sort, + 'refresh': refresh, + 'offset': offset, + 'limit': limit, + }, + errors: { + 400: `Bad Request`, + 401: `Unauthorized`, + 403: `Forbidden`, + 404: `Not Found`, + 409: `Conflict`, + 422: `Unprocessable Entity`, + 500: `Internal Server Error`, + 501: `Not Implemented`, + 502: `Bad Gateway`, + 503: `Service Unavailable`, + 504: `Gateway Timeout`, + }, + }); + } + /** + * Reopen issue review finding + * @returns void + * @throws ApiError + */ + public static deleteApiV1AnalyticsIssueReviewFindingsIdState({ + id, + }: { + /** + * Stable finding ID + */ + id: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/analytics/issue-review/findings/{id}/state', + path: { + 'id': id, + }, + errors: { + 400: `Bad Request`, + 401: `Unauthorized`, + 403: `Forbidden`, + 404: `Not Found`, + 409: `Conflict`, + 422: `Unprocessable Entity`, + 500: `Internal Server Error`, + 501: `Not Implemented`, + 502: `Bad Gateway`, + 503: `Service Unavailable`, + 504: `Gateway Timeout`, + }, + }); + } + /** + * Set issue review finding state + * @returns DbIssueReviewFindingState OK + * @throws ApiError + */ + public static putApiV1AnalyticsIssueReviewFindingsIdState({ + id, + requestBody, + }: { + /** + * Stable finding ID + */ + id: string, + requestBody: IssueReviewFindingStateInputBody, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/analytics/issue-review/findings/{id}/state', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Bad Request`, + 401: `Unauthorized`, + 403: `Forbidden`, + 404: `Not Found`, + 409: `Conflict`, + 422: `Unprocessable Entity`, + 500: `Internal Server Error`, + 501: `Not Implemented`, + 502: `Bad Gateway`, + 503: `Service Unavailable`, + 504: `Gateway Timeout`, + }, + }); + } /** * Get analytics by project * @returns DbProjectsAnalyticsResponse OK diff --git a/frontend/src/lib/api/types/analytics.ts b/frontend/src/lib/api/types/analytics.ts index d79a3317f5..80ae848651 100644 --- a/frontend/src/lib/api/types/analytics.ts +++ b/frontend/src/lib/api/types/analytics.ts @@ -332,6 +332,91 @@ export interface SignalsAnalyticsResponse { calibration: Record; } +export type IssueSeverity = "high" | "medium" | "low"; +export type IssueConfidence = "high" | "medium" | "low"; +export type IssueStatus = "open" | "recovered" | "recurring" | "observed"; +export type IssueReviewState = "active" | "acknowledged" | "suppressed"; +export type IssueRecommendationType = "skill" | "script" | "rule" | "tool_fix"; + +export interface IssueFacet { + value: string; + label?: string; + count: number; +} + +export interface IssueReviewEvidence { + session_id: string; + project: string; + cwd: string; + agent: string; + date: string; + outcome: string; + source: string; + tool: string; + excerpt: string; + message_ordinal?: number; + call_index?: number; + event_status?: string; + recovered: boolean; + duration_ms?: number; +} + +export interface IssueReviewFinding { + id: string; + reason_code: string; + tool: string; + signature: string; + severity: IssueSeverity; + confidence: IssueConfidence; + status: IssueStatus; + review_state: IssueReviewState; + review_state_expires_at?: string; + recommendation_type: IssueRecommendationType; + recommendation: string; + github_reference?: string; + sources: string[]; + occurrences: number; + session_count: number; + project_count: number; + incomplete_session_count: number; + total_duration_ms: number; + wasted_duration_ms: number; + p95_duration_ms?: number; + duration_coverage: number; + duration_source?: string; + last_seen: string; + evidence: IssueReviewEvidence[]; +} + +export interface IssueReviewResponse { + generated_at: string; + scanned_sessions: number; + scanned_messages: number; + scanned_tool_calls: number; + analyzed_messages: number; + analyzed_tool_calls: number; + duplicate_messages: number; + duplicate_tool_calls: number; + scanned_telemetry: number; + telemetry_status: string; + total_findings: number; + truncated: boolean; + findings: IssueReviewFinding[]; + facets: { + category: IssueFacet[]; + tool: IssueFacet[]; + source: IssueFacet[]; + severity: IssueFacet[]; + confidence: IssueFacet[]; + status: IssueFacet[]; + review_state: IssueFacet[]; + recommendation_type: IssueFacet[]; + session: IssueFacet[]; + folder: IssueFacet[]; + outcome: IssueFacet[]; + }; +} + export interface TrendsBucket { date: string; message_count: number; diff --git a/frontend/src/lib/components/insights/InsightsPage.svelte b/frontend/src/lib/components/insights/InsightsPage.svelte index 890f4ac0c0..f3f5e2b8da 100644 --- a/frontend/src/lib/components/insights/InsightsPage.svelte +++ b/frontend/src/lib/components/insights/InsightsPage.svelte @@ -50,6 +50,7 @@ type QualityPatternSeverity, type QualityPatternView, } from "./qualityPatterns.js"; + import IssueReviewPanel from "./IssueReviewPanel.svelte"; const REFRESH_INTERVAL_MS = 5 * 60 * 1000; const INSIGHTS_WINDOW_PARAM = "window_days"; @@ -63,6 +64,7 @@ >[0]; let refreshTimer: ReturnType | undefined; + let issueReviewPanel: { refresh(): Promise } | undefined; let unsubEvents: (() => void) | undefined; let copiedInsightLinkId: number | null = $state(null); let copiedInsightLinkTimer: @@ -388,6 +390,7 @@ function handleRefresh() { fetchInsightSignals(); + void issueReviewPanel?.refresh(); insights.load(); } @@ -855,6 +858,8 @@
+ +
diff --git a/frontend/src/lib/components/insights/InsightsPage.test.ts b/frontend/src/lib/components/insights/InsightsPage.test.ts index 8a53ebe0b1..63e6693f5d 100644 --- a/frontend/src/lib/components/insights/InsightsPage.test.ts +++ b/frontend/src/lib/components/insights/InsightsPage.test.ts @@ -21,6 +21,59 @@ import type { SignalsAnalyticsResponse } from "../../api/types.js"; // @ts-ignore import InsightsPage from "./InsightsPage.svelte"; import source from "./InsightsPage.svelte?raw"; +import issueReviewSource from "./IssueReviewPanel.svelte?raw"; + +describe("IssueReviewPanel proactive detector contract", () => { + it("sends every issue filter to the server", () => { + for (const field of [ + "sessionId", "folder", "category", "tool", "source", "outcome", "severity", "confidence", + "status", "reviewState", "recommendationType", "minOccurrences", "minSessions", + "minProjects", "minWastedMs", "sort", + ]) { + expect(issueReviewSource).toContain(`${field}:`); + } + expect(issueReviewSource).toContain("getApiV1AnalyticsIssueReview"); + }); + + it("persists accepted finding decisions through typed routes", () => { + expect(issueReviewSource).toContain("putApiV1AnalyticsIssueReviewFindingsIdState"); + expect(issueReviewSource).toContain("deleteApiV1AnalyticsIssueReviewFindingsIdState"); + expect(issueReviewSource).toContain("finding_last_seen: finding.last_seen"); + }); + + it("forces manual refresh and uses the analysis cache for background refreshes", () => { + expect(issueReviewSource).toContain("onclick={() => refresh(true)}"); + expect(issueReviewSource).toContain("events.subscribeDebounced(() => void refresh())"); + expect(issueReviewSource).toContain("globalScopeKey()"); + expect(issueReviewSource).toContain("setInterval(() => void refresh()"); + expect(issueReviewSource).toContain("issue_review_cached_warning"); + expect(issueReviewSource).toContain("response.telemetry_status !== \"available\""); + expect(issueReviewSource).toContain("issue_review_telemetry_unavailable"); + }); + + it("opens exact ordinal evidence and formats numbers in the app locale", () => { + expect(issueReviewSource).toContain("ui.scrollToOrdinal(evidence.message_ordinal"); + expect(issueReviewSource).toContain("msg: String(evidence.message_ordinal)"); + expect(issueReviewSource).toContain("new Intl.NumberFormat(getLocale()"); + }); + + it("persists and clears filters while rendering actionable recommendations", () => { + expect(issueReviewSource).toContain("agentsview.issue-review.filters.v1"); + expect(issueReviewSource).toContain("localStorage.setItem"); + expect(issueReviewSource).toContain("function clearFilters()"); + expect(issueReviewSource).toContain("finding.recommendation"); + expect(issueReviewSource).toContain("finding.github_reference"); + expect(issueReviewSource).toContain("duplicate_tool_calls"); + expect(issueReviewSource).not.toContain(" { + expect(issueReviewSource).toContain("offset,"); + expect(issueReviewSource).toContain("refresh(false, findings.length)"); + expect(issueReviewSource).toContain("issue_review_load_more"); + expect(issueReviewSource).toContain("response.findings, ...page.findings"); + }); +}); describe("InsightsPage sidebar filter sync", () => { it("syncs the automated-session scope from the sidebar", () => { @@ -414,7 +467,7 @@ describe("InsightsPage date yoke integration", () => { expect(yokedDates.range).toBeNull(); const refresh = document.querySelector( - 'button[aria-label="Refresh insights"]', + 'button[aria-label="Refresh issue review"]', ); expect(refresh).not.toBeNull(); const callsBeforeRefresh = fetchStates.length; @@ -768,16 +821,23 @@ describe("InsightsPage selected insight actions", () => { document.body.innerHTML = ""; }); - it("renders the deterministic-vs-generated insights help affordance", async () => { + it("labels the recurring issue review and its evidence", async () => { component = mount(InsightsPage, { target: document.body }); await tick(); + expect(document.querySelector("#actions-title")?.textContent).toContain( + "What to fix next", + ); + expect(document.querySelector("#facts-title")?.textContent).toContain( + "Top issue drivers", + ); + const helpBlock = document.querySelector("p.insights-help"); expect(helpBlock).not.toBeNull(); const helpText = helpBlock?.textContent ?? ""; expect( helpText.includes("insights_page_insights_help_intro") || - helpText.includes("Deterministic sections are computed"), + helpText.includes("This review ranks recurring patterns"), ).toBe(true); const docsLink = document.querySelector( @@ -786,7 +846,7 @@ describe("InsightsPage selected insight actions", () => { expect(docsLink).not.toBeNull(); expect( (docsLink!.textContent?.includes("insights_page_insights_help_docs") || - docsLink!.textContent?.includes("Read Insights docs")), + docsLink!.textContent?.includes("Read issue review docs")), ).toBe(true); expect(docsLink!.getAttribute("target")).toBe("_blank"); expect(docsLink!.getAttribute("rel")).toContain("noopener"); diff --git a/frontend/src/lib/components/insights/IssueReviewPanel.svelte b/frontend/src/lib/components/insights/IssueReviewPanel.svelte new file mode 100644 index 0000000000..2e99ff4a7b --- /dev/null +++ b/frontend/src/lib/components/insights/IssueReviewPanel.svelte @@ -0,0 +1,680 @@ + + +
+
+
+ {m.issue_review_proactive()} +

{m.issue_review_title()}

+

{m.issue_review_description()}

+
+
+ {#if refreshing}{/if} + + +
+
+ +
+ +
+ +
+ + +
+ +
+ 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) => reviewState = 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} + {#if reviewError}{/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)}{#if findingReviewState(finding) !== "active"}{reviewStateLabel(findingReviewState(finding))}{/if}{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}

+
+ + selectSuppression(finding.id, value)} /> + + {#if findingReviewState(finding) !== "active"}{/if} + {#if updatingFinding === finding.id}{/if} +
+ {#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/frontend/src/lib/components/insights/IssueReviewPanel.test.ts b/frontend/src/lib/components/insights/IssueReviewPanel.test.ts new file mode 100644 index 0000000000..e9011932bb --- /dev/null +++ b/frontend/src/lib/components/insights/IssueReviewPanel.test.ts @@ -0,0 +1,313 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { mount, tick, unmount } from "svelte"; +import type { IssueReviewResponse } from "../../api/types.js"; + +const mocks = vi.hoisted(() => ({ + getIssueReview: vi.fn(), + putFindingState: vi.fn(), + deleteFindingState: vi.fn(), +})); + +vi.mock("../../api/generated/index.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + AnalyticsService: { + getApiV1AnalyticsIssueReview: mocks.getIssueReview, + putApiV1AnalyticsIssueReviewFindingsIdState: mocks.putFindingState, + deleteApiV1AnalyticsIssueReviewFindingsIdState: mocks.deleteFindingState, + }, + }; +}); + +// @ts-ignore +import IssueReviewPanel from "./IssueReviewPanel.svelte"; + +const FILTER_STORAGE_KEY = "agentsview.issue-review.filters.v1"; +const SAVED_VIEWS_STORAGE_KEY = "agentsview.issue-review.saved-views.v1"; + +function filters(overrides: Record = {}) { + return { + sessionId: "", + folder: "", + category: "", + tool: "", + source: "", + outcome: "", + severity: "", + confidence: "", + status: "", + reviewState: "", + recommendationType: "", + minOccurrences: "2", + minSessions: "2", + minProjects: "0", + minWastedMs: "0", + sort: "impact", + ...overrides, + }; +} + +const response: IssueReviewResponse = { + generated_at: "2026-08-10T00:00:00Z", + scanned_sessions: 0, + scanned_messages: 0, + scanned_tool_calls: 0, + analyzed_messages: 0, + analyzed_tool_calls: 0, + duplicate_messages: 0, + duplicate_tool_calls: 0, + scanned_telemetry: 0, + telemetry_status: "available", + total_findings: 0, + truncated: false, + findings: [], + facets: { + category: [], + tool: [], + source: [], + severity: [ + { value: "high", label: "High", count: 2 }, + { value: "medium", label: "Medium", count: 1 }, + ], + confidence: [], + status: [], + review_state: [], + recommendation_type: [], + session: [], + folder: [], + outcome: [], + }, +}; + +const finding = { + id: "0123456789abcdef", + reason_code: "timeout", + tool: "shell_command", + signature: "command timed out", + severity: "medium" as const, + confidence: "high" as const, + status: "recurring" as const, + review_state: "active" as const, + recommendation_type: "script" as const, + recommendation: "Add a bounded retry.", + sources: ["tool_result"], + occurrences: 2, + session_count: 2, + project_count: 1, + incomplete_session_count: 0, + total_duration_ms: 1_000, + wasted_duration_ms: 0, + duration_coverage: 1, + last_seen: "2026-08-10", + evidence: [], +}; + +let component: ReturnType | undefined; + +async function settle() { + await tick(); + await Promise.resolve(); + await tick(); +} + +async function mountPanel() { + component = mount(IssueReviewPanel, { target: document.body }); + await settle(); +} + +function button(label: string): HTMLButtonElement { + const match = [...document.querySelectorAll("button")].find( + (item) => item.textContent?.trim() === label, + ); + expect(match).toBeDefined(); + return match!; +} + +async function choose(label: string, optionLabel: string) { + const trigger = document.querySelector(`button[aria-label="${label}"]`); + expect(trigger).not.toBeNull(); + trigger!.click(); + await tick(); + const option = [...document.querySelectorAll(".kit-typeahead__option")].find( + (item) => item.textContent?.trim() === optionLabel, + ); + expect(option).toBeDefined(); + option!.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + await settle(); +} + +async function nameView(name: string) { + const input = document.querySelector('input[aria-label="View name"]'); + expect(input).not.toBeNull(); + input!.value = name; + input!.dispatchEvent(new InputEvent("input", { bubbles: true, data: name })); + await settle(); +} + +beforeEach(() => { + localStorage.clear(); + mocks.getIssueReview.mockReset().mockResolvedValue(response); + mocks.putFindingState.mockReset().mockResolvedValue({}); + mocks.deleteFindingState.mockReset().mockResolvedValue(undefined); +}); + +describe("IssueReviewPanel finding review state", () => { + beforeEach(() => { + mocks.getIssueReview.mockResolvedValue({ + ...response, + total_findings: 1, + findings: [finding], + facets: { + ...response.facets, + review_state: [{ value: "active", count: 1 }], + }, + }); + }); + + it("acknowledges the current finding snapshot", async () => { + await mountPanel(); + button("Acknowledge").click(); + await settle(); + + expect(mocks.putFindingState).toHaveBeenCalledWith({ + id: finding.id, + requestBody: { + review_state: "acknowledged", + finding_last_seen: finding.last_seen, + suppression_days: undefined, + }, + }); + }); + + it("suppresses for the selected duration", async () => { + await mountPanel(); + await choose("Suppress for", "30 days"); + button("Suppress").click(); + await settle(); + + expect(mocks.putFindingState).toHaveBeenCalledWith({ + id: finding.id, + requestBody: { + review_state: "suppressed", + finding_last_seen: finding.last_seen, + suppression_days: 30, + }, + }); + }); + + it("reopens a reviewed finding", async () => { + mocks.getIssueReview.mockResolvedValue({ + ...response, + total_findings: 1, + findings: [{ ...finding, review_state: "acknowledged" }], + facets: { + ...response.facets, + review_state: [{ value: "acknowledged", count: 1 }], + }, + }); + await mountPanel(); + button("Reopen").click(); + await settle(); + + expect(mocks.deleteFindingState).toHaveBeenCalledWith({ id: finding.id }); + }); +}); + +afterEach(async () => { + if (component) await unmount(component); + component = undefined; + document.body.innerHTML = ""; + localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("IssueReviewPanel saved views", () => { + it("saves, restores, and applies a named filter view", async () => { + await mountPanel(); + await choose("Severity", "High (2)"); + await nameView("Critical"); + button("Save view").click(); + await settle(); + + expect(JSON.parse(localStorage.getItem(SAVED_VIEWS_STORAGE_KEY)!)).toEqual([ + { name: "Critical", filters: filters({ severity: "high" }) }, + ]); + + await unmount(component!); + component = undefined; + document.body.innerHTML = ""; + localStorage.removeItem(FILTER_STORAGE_KEY); + mocks.getIssueReview.mockClear(); + await mountPanel(); + await choose("Saved view", "Critical"); + + expect(mocks.getIssueReview).toHaveBeenLastCalledWith( + expect.objectContaining({ severity: "high" }), + ); + }); + + it("overwrites and deletes a selected view", async () => { + await mountPanel(); + await choose("Severity", "High (2)"); + await nameView("Triage"); + button("Save view").click(); + await settle(); + + await choose("Severity", "Medium (1)"); + button("Save view").click(); + await settle(); + + expect(JSON.parse(localStorage.getItem(SAVED_VIEWS_STORAGE_KEY)!)).toEqual([ + { name: "Triage", filters: filters({ severity: "medium" }) }, + ]); + + button("Delete view").click(); + await settle(); + expect(JSON.parse(localStorage.getItem(SAVED_VIEWS_STORAGE_KEY)!)).toEqual([]); + }); + + it("removes invalid saved-view storage", async () => { + localStorage.setItem(SAVED_VIEWS_STORAGE_KEY, "{}"); + await mountPanel(); + + expect(localStorage.getItem(SAVED_VIEWS_STORAGE_KEY)).toBeNull(); + expect(button("Delete view").disabled).toBe(true); + }); + + it("caps restored and newly saved views at 50", async () => { + localStorage.setItem( + SAVED_VIEWS_STORAGE_KEY, + JSON.stringify( + Array.from({ length: 51 }, (_, index) => ({ + name: `View ${index + 1}`, + filters: filters(), + })), + ), + ); + await mountPanel(); + expect(JSON.parse(localStorage.getItem(SAVED_VIEWS_STORAGE_KEY)!)).toHaveLength(50); + + const trigger = document.querySelector('button[aria-label="Saved view"]'); + trigger!.click(); + await tick(); + expect( + [...document.querySelectorAll(".kit-typeahead__option")].some( + (item) => item.textContent?.trim() === "View 51", + ), + ).toBe(false); + document + .querySelector('input[aria-label="Saved view"]')! + .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(); + + await nameView("New view"); + button("Save view").click(); + await settle(); + const saved = JSON.parse(localStorage.getItem(SAVED_VIEWS_STORAGE_KEY)!); + expect(saved).toHaveLength(50); + expect(saved[0].name).toBe("View 2"); + expect(saved.at(-1).name).toBe("New view"); + }); +}); diff --git a/frontend/src/lib/components/layout/AppHeader.test.ts b/frontend/src/lib/components/layout/AppHeader.test.ts index 1199fef114..6fcbca07ed 100644 --- a/frontend/src/lib/components/layout/AppHeader.test.ts +++ b/frontend/src/lib/components/layout/AppHeader.test.ts @@ -249,7 +249,7 @@ describe("AppHeader export actions", () => { "Trends", "Recall", "Pinned", - "Insights", + "Issue Review", "Trash", "Recent Edits", "Data", diff --git a/internal/db/db.go b/internal/db/db.go index 7cc949be7c..cc118e7dfc 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 @@ -1409,6 +1411,7 @@ var readOnlyRequiredTables = []string{ "insights", "pinned_messages", "starred_sessions", + "issue_review_finding_states", "excluded_sessions", "worktree_project_mappings", "archive_metadata", diff --git a/internal/db/issue_review.go b/internal/db/issue_review.go new file mode 100644 index 0000000000..9cd45ac219 --- /dev/null +++ b/internal/db/issue_review.go @@ -0,0 +1,2034 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/tidwall/gjson" + + "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 + ReviewState 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"` + ReviewState []IssueFacet `json:"review_state" 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"` + ReviewState string `json:"review_state"` + ReviewStateExpiresAt string `json:"review_state_expires_at,omitempty"` + 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 +} + +const ( + IssueReviewStateActive = "active" + IssueReviewStateAcknowledged = "acknowledged" + IssueReviewStateSuppressed = "suppressed" +) + +type IssueReviewFindingState struct { + FindingID string `json:"finding_id"` + ReviewState string `json:"review_state"` + AcceptedLastSeen string `json:"accepted_last_seen"` + SuppressedUntil string `json:"suppressed_until,omitempty"` + UpdatedAt string `json:"updated_at"` +} + +func NewIssueReviewFindingState( + findingID, reviewState, acceptedLastSeen string, + suppressionDays *int, now time.Time, +) (IssueReviewFindingState, error) { + if len(findingID) != 16 || strings.IndexFunc(findingID, func(r rune) bool { + return r < '0' || r > '9' && r < 'a' || r > 'f' + }) >= 0 { + return IssueReviewFindingState{}, fmt.Errorf("invalid finding id") + } + if _, err := time.Parse(time.DateOnly, acceptedLastSeen); err != nil { + return IssueReviewFindingState{}, fmt.Errorf("invalid finding last_seen: use YYYY-MM-DD") + } + if reviewState != IssueReviewStateAcknowledged && reviewState != IssueReviewStateSuppressed { + return IssueReviewFindingState{}, fmt.Errorf("invalid review state") + } + if reviewState == IssueReviewStateAcknowledged && suppressionDays != nil { + return IssueReviewFindingState{}, fmt.Errorf("suppression_days requires suppressed state") + } + state := IssueReviewFindingState{ + FindingID: findingID, ReviewState: reviewState, + AcceptedLastSeen: acceptedLastSeen, + UpdatedAt: now.UTC().Format(time.RFC3339), + } + if reviewState == IssueReviewStateSuppressed && suppressionDays != nil { + switch *suppressionDays { + case 1, 7, 30: + state.SuppressedUntil = now.UTC().AddDate(0, 0, *suppressionDays).Format(time.RFC3339) + default: + return IssueReviewFindingState{}, fmt.Errorf("suppression_days must be 1, 7, or 30") + } + } + return state, nil +} + +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, refresh bool) (IssueReviewResponse, bool) { + if 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 c.entry.response, 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) { + 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, command) && hasOneExit && !hasSpecificSearchFailure(resultLower) { + return "", false + } + logicalFailure := hasLogicalFailure(command, resultLower) + markerFailure := !logicalFailure && explicitFailureMarker(resultLower) + if !failedStatus && !hasNonZeroExit && isReadInvocation(tool, command) { + 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, command) { + return "failed_edit", true + } + if isGitHubInvocation(tool, command) { + return "git_github_ci", true + } + if isBuildTestInvocation(tool, command) { + 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 { + 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 + } + 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 + } + } + 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, command string) bool { + if isSearchTool(tool) { + return true + } + if !isShellTool(tool) { + return false + } + return searchCommandRE.MatchString(command) +} + +func issueCommandInput(input string) string { + if result := gjson.Get(input, "command"); result.Type == gjson.String && result.Str != "" && gjson.Valid(input) { + return result.Str + } + return input +} + +func isEditInvocation(tool, command string) bool { + lowerTool := normalizeTool(tool) + if strings.Contains(lowerTool, "apply_patch") || strings.Contains(lowerTool, "edit") { + return true + } + lower := strings.ToLower(command) + return strings.HasPrefix(strings.TrimSpace(lower), "apply_patch") +} + +func isGitHubInvocation(tool, command string) bool { + if !isShellTool(tool) && !strings.Contains(normalizeTool(tool), "github") { + return false + } + 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, command string) bool { + if !isShellTool(tool) { + return false + } + 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 + } + } + return false +} + +func canonicalGitHubReference(value string) string { + 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] + } + } + } + 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 "" +} + +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") || !strings.Contains(input, "tools.") { + 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 + command 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(ApplyIssueReviewStates(AnalyzeIssueReviewBase(sessions, messages, calls, telemetry), nil, time.Now()), q) +} + +// FilterIssueReview applies cheap result filters and pagination to a base analysis. +func FilterIssueReview(response IssueReviewResponse, q IssueReviewQuery) IssueReviewResponse { + return filterIssueReviewResponse(ApplyIssueReviewStates(response, nil, time.Now()), q) +} + +func FilterIssueReviewWithStates(response IssueReviewResponse, states []IssueReviewFindingState, q IssueReviewQuery, now time.Time) IssueReviewResponse { + return filterIssueReviewResponse(ApplyIssueReviewStates(response, states, now), q) +} + +func ApplyIssueReviewStates(response IssueReviewResponse, states []IssueReviewFindingState, now time.Time) IssueReviewResponse { + byID := make(map[string]IssueReviewFindingState, len(states)) + for _, state := range states { + byID[state.FindingID] = state + } + findings := make([]IssueReviewFinding, len(response.Findings)) + counts := map[string]int{} + for i, finding := range response.Findings { + finding.ReviewState = IssueReviewStateActive + finding.ReviewStateExpiresAt = "" + if state, ok := byID[finding.ID]; ok { + switch state.ReviewState { + case IssueReviewStateAcknowledged: + if finding.LastSeen <= state.AcceptedLastSeen { + finding.ReviewState = IssueReviewStateAcknowledged + } + case IssueReviewStateSuppressed: + if state.SuppressedUntil == "" { + finding.ReviewState = IssueReviewStateSuppressed + } else if until, err := time.Parse(time.RFC3339, state.SuppressedUntil); err == nil && now.Before(until) { + finding.ReviewState = IssueReviewStateSuppressed + finding.ReviewStateExpiresAt = state.SuppressedUntil + } + } + } + counts[finding.ReviewState]++ + findings[i] = finding + } + response.Findings = findings + response.Facets.ReviewState = issueFacetCounts(counts) + return response +} + +// 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) + 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) + } + toolCounts[tool]++ + if row.DurationMS != nil && *row.DurationMS < 0 { + row.DurationMS = nil + row.DurationSource = "" + } + 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]++ + } + } + 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 { + 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.command) { + break + } + intervening++ + } + } + bySession[sessionID] = 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) + 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, 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 + "|" + 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 { + 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, call.command) { + normalized, ok := normalizedInputs[call.row.Input] + if !ok { + normalized = normalizeIssueText(call.row.Input) + normalizedInputs[call.row.Input] = 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} + 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.command) { + 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" + } + 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(findingKey, "repeated_workflow", row.tool, row.row.Input, "medium", "high", recommendation, e, row.row.DurationMS, row.row.DurationMS, false) + } + } + addSlowToolFindings(a, calls, effectiveTools, 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 finding.ReviewState == "" { + finding.ReviewState = IssueReviewStateActive + } + 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.ReviewState != "" && finding.ReviewState != q.ReviewState || q.ReviewState == "" && finding.ReviewState == IssueReviewStateSuppressed || 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); gjson.Valid(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, command string) bool { + if isWaitTool(tool) || isReadInvocation(tool, command) || len(strings.TrimSpace(input)) < 80 { + return false + } + input = strings.TrimSpace(input) + for _, prefix := range []string{"git status", "pwd", "get-location", "ls", "dir", "rg ", "grep ", "find ", "get-childitem", "get-content"} { + if hasFoldPrefix(input, 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, 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 + } + } + command = strings.TrimSpace(command) + for _, prefix := range []string{"get-content ", "cat ", "type ", "head ", "tail ", "sed -n "} { + if hasFoldPrefix(command, prefix) { + return true + } + } + return false +} + +func isRecoveryDiagnostic(tool, command string) bool { + t := normalizeTool(tool) + if isShellTool(t) { + command = strings.TrimSpace(command) + if strings.ContainsAny(command, "\r\n;|&") { + return false + } + } + 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_") { + return true + } + if !isShellTool(t) { + return false + } + for _, diagnostic := range []string{"git status", "pwd", "get-location", "ls", "dir", "get-childitem"} { + if strings.EqualFold(command, diagnostic) || hasFoldPrefix(command, diagnostic+" ") { + return true + } + } + return false +} + +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 i, call := range calls { + tool := effectiveTools[i] + 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": {}, "review_state": {}, "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, "review_state": firstNonEmptyString(finding.ReviewState, IssueReviewStateActive), "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"], ReviewState: out["review_state"], + RecommendationType: out["recommendation_type"], Session: out["session"], Folder: out["folder"], + Outcome: out["outcome"], + } +} + +func issueFacetCounts(counts map[string]int) []IssueFacet { + values := make([]IssueFacet, 0, len(counts)) + for value, count := range counts { + values = append(values, IssueFacet{Value: value, Count: count}) + } + sort.Slice(values, func(i, j int) bool { + if values[i].Count != values[j].Count { + return values[i].Count > values[j].Count + } + return values[i].Value < values[j].Value + }) + return values +} + +// 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) + response, ok := db.issueReviewCache.Get(key, q.Refresh) + if !ok { + 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) + } + states, err := db.issueReviewFindingStates(ctx) + if err != nil { + return IssueReviewResponse{}, err + } + return filterIssueReviewResponse(ApplyIssueReviewStates(response, states, time.Now()), q), nil +} + +func (db *DB) PutIssueReviewFindingState(ctx context.Context, state IssueReviewFindingState) error { + db.mu.Lock() + defer db.mu.Unlock() + _, err := db.getWriter().ExecContext(ctx, ` + INSERT INTO issue_review_finding_states + (finding_id, review_state, accepted_last_seen, suppressed_until, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(finding_id) DO UPDATE SET + review_state = excluded.review_state, + accepted_last_seen = excluded.accepted_last_seen, + suppressed_until = excluded.suppressed_until, + updated_at = excluded.updated_at`, + state.FindingID, state.ReviewState, state.AcceptedLastSeen, + state.SuppressedUntil, state.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("saving issue review finding state: %w", err) + } + return nil +} + +func (db *DB) DeleteIssueReviewFindingState(ctx context.Context, findingID string) error { + db.mu.Lock() + defer db.mu.Unlock() + if _, err := db.getWriter().ExecContext(ctx, + "DELETE FROM issue_review_finding_states WHERE finding_id = ?", findingID, + ); err != nil { + return fmt.Errorf("deleting issue review finding state: %w", err) + } + return nil +} + +func (db *DB) issueReviewFindingStates(ctx context.Context) ([]IssueReviewFindingState, error) { + rows, err := db.getReader().QueryContext(ctx, ` + SELECT finding_id, review_state, accepted_last_seen, + COALESCE(suppressed_until,''), updated_at + FROM issue_review_finding_states`) + if err != nil { + return nil, fmt.Errorf("querying issue review finding states: %w", err) + } + defer rows.Close() + var states []IssueReviewFindingState + for rows.Next() { + var state IssueReviewFindingState + if err := rows.Scan(&state.FindingID, &state.ReviewState, + &state.AcceptedLastSeen, &state.SuppressedUntil, &state.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning issue review finding state: %w", err) + } + states = append(states, state) + } + return states, rows.Err() +} + +// 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..b7b5c9f69e --- /dev/null +++ b/internal/db/issue_review_test.go @@ -0,0 +1,822 @@ +package db + +import ( + "context" + "database/sql" + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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)) + }) + } + assert.Equal(t, "url/repo#47", canonicalGitHubReferenceParts("Short/Repo#46", "https://github.com/URL/Repo/issues/47")) +} + +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 TestIssueReviewStateOverlayAndExpiry(t *testing.T) { + now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) + base := IssueReviewResponse{Findings: []IssueReviewFinding{ + {ID: "acknowledged0001", LastSeen: "2026-08-10", Occurrences: 1, SessionCount: 1}, + {ID: "advanced00000002", LastSeen: "2026-08-11", Occurrences: 1, SessionCount: 1}, + {ID: "suppressed000003", LastSeen: "2026-08-10", Occurrences: 1, SessionCount: 1}, + {ID: "expired000000004", LastSeen: "2026-08-10", Occurrences: 1, SessionCount: 1}, + {ID: "permanent0000005", LastSeen: "2026-08-10", Occurrences: 1, SessionCount: 1}, + }} + states := []IssueReviewFindingState{ + {FindingID: "acknowledged0001", ReviewState: IssueReviewStateAcknowledged, AcceptedLastSeen: "2026-08-10"}, + {FindingID: "advanced00000002", ReviewState: IssueReviewStateAcknowledged, AcceptedLastSeen: "2026-08-10"}, + {FindingID: "suppressed000003", ReviewState: IssueReviewStateSuppressed, SuppressedUntil: now.Add(time.Hour).Format(time.RFC3339)}, + {FindingID: "expired000000004", ReviewState: IssueReviewStateSuppressed, SuppressedUntil: now.Add(-time.Second).Format(time.RFC3339)}, + {FindingID: "permanent0000005", ReviewState: IssueReviewStateSuppressed}, + } + + overlaid := ApplyIssueReviewStates(base, states, now) + assert.Equal(t, "", base.Findings[0].ReviewState, "cached base must not be mutated") + assert.Equal(t, []string{ + IssueReviewStateAcknowledged, IssueReviewStateActive, + IssueReviewStateSuppressed, IssueReviewStateActive, + IssueReviewStateSuppressed, + }, []string{ + overlaid.Findings[0].ReviewState, overlaid.Findings[1].ReviewState, + overlaid.Findings[2].ReviewState, overlaid.Findings[3].ReviewState, + overlaid.Findings[4].ReviewState, + }) + + visible := filterIssueReviewResponse(overlaid, IssueReviewQuery{Limit: 100}) + assert.Equal(t, 3, visible.TotalFindings) + suppressed := filterIssueReviewResponse(overlaid, IssueReviewQuery{ReviewState: IssueReviewStateSuppressed, Limit: 100}) + assert.Equal(t, 2, suppressed.TotalFindings) + assert.Equal(t, 2, facetCount(overlaid.Facets.ReviewState, IssueReviewStateSuppressed)) +} + +func TestNewIssueReviewFindingStateValidation(t *testing.T) { + now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC) + days := 7 + state, err := NewIssueReviewFindingState( + "0123456789abcdef", IssueReviewStateSuppressed, + "2026-08-10", &days, now, + ) + require.NoError(t, err) + assert.Equal(t, "2026-08-17T12:00:00Z", state.SuppressedUntil) + + badDays := 2 + _, err = NewIssueReviewFindingState("0123456789abcdef", IssueReviewStateSuppressed, "2026-08-10", &badDays, now) + assert.EqualError(t, err, "suppression_days must be 1, 7, or 30") + _, err = NewIssueReviewFindingState("bad", IssueReviewStateAcknowledged, "2026-08-10", nil, now) + assert.EqualError(t, err, "invalid finding id") + _, err = NewIssueReviewFindingState("0123456789abcdef", IssueReviewStateAcknowledged, "2026-08-10", &days, now) + assert.EqualError(t, err, "suppression_days requires suppressed state") +} + +func TestGetAnalyticsIssueReviewRefreshesReviewStateOverCachedAnalysis(t *testing.T) { + database := testDB(t) + started := "2026-08-10T10:00:00Z" + insertSession(t, database, "state-s1", "alpha", func(session *Session) { + session.StartedAt = &started + session.MessageCount = 2 + }) + insertMessages(t, database, + userMsgAt("state-s1", 0, "Open the required missing file", started), + Message{SessionID: "state-s1", Ordinal: 1, Role: "assistant", Content: "opening", Timestamp: started, HasToolUse: true, + ToolCalls: []ToolCall{{SessionID: "state-s1", ToolName: "shell_command", ToolUseID: "state-call", InputJSON: `{"command":"open missing.txt"}`, ResultEvents: []ToolResultEvent{{ToolUseID: "state-call", Source: "tool_execution", Status: "errored", Content: "file not found", Timestamp: started}}}}}, + ) + filter := AnalyticsFilter{From: "2026-08-10", To: "2026-08-10", 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) + + state, err := NewIssueReviewFindingState(first.Findings[0].ID, IssueReviewStateAcknowledged, first.Findings[0].LastSeen, nil, time.Now()) + require.NoError(t, err) + require.NoError(t, database.PutIssueReviewFindingState(context.Background(), state)) + acknowledged, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + require.Len(t, acknowledged.Findings, 1) + assert.Equal(t, IssueReviewStateAcknowledged, acknowledged.Findings[0].ReviewState) + assert.Equal(t, first.GeneratedAt, acknowledged.GeneratedAt) + + state.ReviewState = IssueReviewStateSuppressed + state.SuppressedUntil = "" + require.NoError(t, database.PutIssueReviewFindingState(context.Background(), state)) + hidden, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + assert.Empty(t, hidden.Findings) + query.ReviewState = IssueReviewStateSuppressed + suppressed, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + require.Len(t, suppressed.Findings, 1) + + require.NoError(t, database.DeleteIssueReviewFindingState(context.Background(), state.FindingID)) + query.ReviewState = "" + reopened, err := database.GetAnalyticsIssueReview(context.Background(), filter, query) + require.NoError(t, err) + require.Len(t, reopened.Findings, 1) + assert.Equal(t, IssueReviewStateActive, reopened.Findings[0].ReviewState) +} + +func TestCopySessionMetadataFromPreservesIssueReviewState(t *testing.T) { + source := testDB(t) + destination := testDB(t) + state := IssueReviewFindingState{ + FindingID: "0123456789abcdef", ReviewState: IssueReviewStateSuppressed, + AcceptedLastSeen: "2026-08-10", UpdatedAt: "2026-08-10T12:00:00Z", + } + require.NoError(t, source.PutIssueReviewFindingState(context.Background(), state)) + require.NoError(t, destination.CopySessionMetadataFrom(source.path)) + states, err := destination.issueReviewFindingStates(context.Background()) + require.NoError(t, err) + require.Equal(t, []IssueReviewFindingState{state}, states) +} + +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 facetCount(facets []IssueFacet, value string) int { + for _, facet := range facets { + if facet.Value == value { + return facet.Count + } + } + return 0 +} + +func ms(value int64) *int64 { return &value } diff --git a/internal/db/orphaned.go b/internal/db/orphaned.go index 0f2f73d999..a026b5dbc0 100644 --- a/internal/db/orphaned.go +++ b/internal/db/orphaned.go @@ -1078,8 +1078,8 @@ func (d *DB) CopyExcludedSessionsFrom( // CopySessionMetadataFrom merges user-managed data from the // source DB into sessions that were re-synced into this DB. // This preserves display_name, deleted_at, starred_sessions, pinned_messages, -// archive metadata, project identity observations, and worktree project -// mappings across full DB rebuilds. Immutable project snapshots are restored +// Issue Review decisions, archive metadata, project identity observations, and +// worktree project mappings across full DB rebuilds. Immutable project snapshots are restored // only from source versions that recorded parser-source labels reliably. func (d *DB) CopySessionMetadataFrom( sourcePath string, @@ -1456,6 +1456,24 @@ func (d *DB) CopySessionMetadataFrom( } } + if oldDBHasTable(ctx, tx, "issue_review_finding_states") { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO main.issue_review_finding_states + (finding_id, review_state, accepted_last_seen, + suppressed_until, updated_at) + SELECT finding_id, review_state, accepted_last_seen, + COALESCE(suppressed_until,''), updated_at + FROM old_db.issue_review_finding_states + WHERE true + ON CONFLICT(finding_id) DO UPDATE SET + review_state = excluded.review_state, + accepted_last_seen = excluded.accepted_last_seen, + suppressed_until = excluded.suppressed_until, + updated_at = excluded.updated_at`); err != nil { + return fmt.Errorf("copying issue review finding states: %w", err) + } + } + return tx.Commit() } diff --git a/internal/db/read_only_test.go b/internal/db/read_only_test.go index 7289eb644a..76002debc9 100644 --- a/internal/db/read_only_test.go +++ b/internal/db/read_only_test.go @@ -238,6 +238,15 @@ func TestOpenReadOnlyWriteMethodsReturnErrReadOnly(t *testing.T) { requireReadOnlyOp(t, "BulkStarSessions", func() error { return readonly.BulkStarSessions(nil) }) + requireReadOnlyOp(t, "PutIssueReviewFindingState", func() error { + return readonly.PutIssueReviewFindingState(context.Background(), IssueReviewFindingState{ + FindingID: "0123456789abcdef", ReviewState: IssueReviewStateAcknowledged, + AcceptedLastSeen: "2026-08-10", UpdatedAt: "2026-08-10T12:00:00Z", + }) + }) + requireReadOnlyOp(t, "DeleteIssueReviewFindingState", func() error { + return readonly.DeleteIssueReviewFindingState(context.Background(), "0123456789abcdef") + }) requireReadOnlyOp(t, "DeleteParserExcludedSessions", func() error { _, err := readonly.DeleteParserExcludedSessions(nil) return err diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 745a54dd82..69c9c86577 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -697,6 +697,17 @@ CREATE TABLE IF NOT EXISTS starred_sessions ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); +-- Issue Review curation: detector status stays derived; user decisions persist. +CREATE TABLE IF NOT EXISTS issue_review_finding_states ( + finding_id TEXT PRIMARY KEY, + review_state TEXT NOT NULL CHECK ( + review_state IN ('acknowledged', 'suppressed') + ), + accepted_last_seen TEXT NOT NULL, + suppressed_until TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + -- Excluded sessions: tracks session IDs that were permanently -- deleted by the user so the sync engine does not re-import them. CREATE TABLE IF NOT EXISTS excluded_sessions ( diff --git a/internal/db/store.go b/internal/db/store.go index e4f05bbe08..b68aa4aa05 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -88,6 +88,9 @@ type Store interface { GetAnalyticsTopSessions(ctx context.Context, f AnalyticsFilter, metric string) (TopSessionsResponse, error) GetAnalyticsSignals(ctx context.Context, f AnalyticsFilter) (SignalsAnalyticsResponse, error) GetAnalyticsSignalSessions(ctx context.Context, f AnalyticsFilter, signal string, limit int) (SignalSessionsResponse, error) + GetAnalyticsIssueReview(ctx context.Context, f AnalyticsFilter, q IssueReviewQuery) (IssueReviewResponse, error) + PutIssueReviewFindingState(ctx context.Context, state IssueReviewFindingState) error + DeleteIssueReviewFindingState(ctx context.Context, findingID string) error GetTrendsTerms(ctx context.Context, f AnalyticsFilter, terms []TrendTermInput, granularity string) (TrendsTermsResponse, error) GetActivityReport(ctx context.Context, f AnalyticsFilter, q activity.Query) (activity.Report, error) RecentEdits(ctx context.Context, p RecentEditsParams) (RecentEditsResult, error) diff --git a/internal/duckdb/issue_review.go b/internal/duckdb/issue_review.go new file mode 100644 index 0000000000..14e6a1c22b --- /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.Refresh); ok { + return db.FilterIssueReview(cached, q), 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/duckdb/stubs.go b/internal/duckdb/stubs.go index 21cd2e0cf8..731f5eb75c 100644 --- a/internal/duckdb/stubs.go +++ b/internal/duckdb/stubs.go @@ -8,6 +8,12 @@ import ( func (s *Store) InsertInsight(_ db.Insight) (int64, error) { return 0, db.ErrReadOnly } func (s *Store) DeleteInsight(_ int64) error { return db.ErrReadOnly } +func (s *Store) PutIssueReviewFindingState(_ context.Context, _ db.IssueReviewFindingState) error { + return db.ErrReadOnly +} +func (s *Store) DeleteIssueReviewFindingState(_ context.Context, _ string) error { + return db.ErrReadOnly +} func (s *Store) ListInsights(_ context.Context, _ db.InsightFilter) ([]db.Insight, error) { return []db.Insight{}, nil } diff --git a/internal/parser/codex.go b/internal/parser/codex.go index 18f0cfd9bd..31d6de8d6e 100644 --- a/internal/parser/codex.go +++ b/internal/parser/codex.go @@ -231,7 +231,7 @@ func (b *codexSessionBuilder) processLine( if b.forkGate.suppresses(codexTypeEventMsg, payload) { return false } - b.handleEventMsg(payload) + b.handleEventMsg(payload, ts) } return false } @@ -371,11 +371,15 @@ func (b *codexSessionBuilder) handleAgentMessage( b.ordinal++ } -func (b *codexSessionBuilder) handleEventMsg(payload gjson.Result) { +func (b *codexSessionBuilder) handleEventMsg( + payload gjson.Result, ts time.Time, +) { eventType := payload.Get("type").Str switch eventType { case "task_started", "task_complete", "turn_aborted": b.observeTaskEvent(eventType) + case "agent_message": + b.handleCommentaryMessage(payload, ts) case "token_count": b.handleTokenCountEvent(payload) case "collab_agent_spawn_end": @@ -385,6 +389,29 @@ func (b *codexSessionBuilder) handleEventMsg(payload gjson.Result) { } } +func (b *codexSessionBuilder) handleCommentaryMessage( + payload gjson.Result, ts time.Time, +) { + if payload.Get("phase").Str != "commentary" { + return + } + content := strings.TrimSpace(payload.Get("message").Str) + if content == "" { + return + } + b.messages = append(b.messages, ParsedMessage{ + Ordinal: b.ordinal, + Role: RoleAssistant, + Content: content, + Timestamp: ts, + ContentLength: len(content), + Model: b.model, + SourceType: codexTypeEventMsg, + SourceSubtype: "commentary", + }) + b.ordinal++ +} + func (b *codexSessionBuilder) markFirstUserReplayPossible() { b.codexCursorState.markFirstUserReplayPossible() } diff --git a/internal/parser/codex_parser_test.go b/internal/parser/codex_parser_test.go index 7a80909216..7e532b597f 100644 --- a/internal/parser/codex_parser_test.go +++ b/internal/parser/codex_parser_test.go @@ -2062,6 +2062,54 @@ func codexEventMsgJSON( `","payload":{"type":"` + eventType + `"}}` } +func codexCommentaryEventMsgJSON(message, timestamp string) string { + return fmt.Sprintf( + `{"type":"event_msg","timestamp":%q,"payload":{"type":"agent_message","phase":"commentary","message":%q}}`, + timestamp, message, + ) +} + +func TestParseCodexSession_CommentaryEvents(t *testing.T) { + content := testjsonl.JoinJSONL( + testjsonl.CodexSessionMetaJSON("commentary", "/tmp", "user", tsEarly), + testjsonl.CodexMsgJSON("user", "fix it", tsEarlyS1), + codexCommentaryEventMsgJSON( + "The local repair is in progress.", tsEarlyS5), + testjsonl.CodexMsgJSON("assistant", "Fixed.", tsLate), + ) + + _, msgs := runCodexParserTest(t, "commentary.jsonl", content, false) + require.Len(t, msgs, 3) + assert.Equal(t, RoleAssistant, msgs[1].Role) + assert.Equal(t, "The local repair is in progress.", msgs[1].Content) + assert.Equal(t, "event_msg", msgs[1].SourceType) + assert.Equal(t, "commentary", msgs[1].SourceSubtype) +} + +func TestParseCodexSessionFrom_CommentaryEvents(t *testing.T) { + initial := testjsonl.JoinJSONL( + testjsonl.CodexSessionMetaJSON("commentary-inc", "/tmp", "user", tsEarly), + testjsonl.CodexMsgJSON("user", "fix it", tsEarlyS1), + ) + path := createTestFile(t, "commentary-incremental.jsonl", initial) + info, err := os.Stat(path) + require.NoError(t, err) + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + _, err = f.WriteString(codexCommentaryEventMsgJSON( + "The local repair is in progress.", tsEarlyS5)) + require.NoError(t, err) + require.NoError(t, f.Close()) + + msgs, _, _, err := parseCodexTestSessionFrom(t, path, info.Size(), 1, false) + require.NoError(t, err) + require.Len(t, msgs, 1) + assert.Equal(t, 1, msgs[0].Ordinal) + assert.Equal(t, RoleAssistant, msgs[0].Role) + assert.Equal(t, "commentary", msgs[0].SourceSubtype) +} + // TestParseCodexSession_TerminationStatus exercises the lifecycle // event tracking that drives termination_status for Codex sessions. // Codex doesn't go through Classify() — it sets the status from the diff --git a/internal/postgres/issue_review.go b/internal/postgres/issue_review.go new file mode 100644 index 0000000000..fb7f8d53be --- /dev/null +++ b/internal/postgres/issue_review.go @@ -0,0 +1,189 @@ +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) + response, ok := s.issueReviewCache.Get(key, q.Refresh) + if !ok { + 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) + } + states, err := s.issueReviewFindingStates(ctx) + if err != nil { + return db.IssueReviewResponse{}, err + } + return db.FilterIssueReviewWithStates(response, states, q, time.Now()), nil +} + +func (s *Store) PutIssueReviewFindingState(ctx context.Context, state db.IssueReviewFindingState) error { + _, err := s.pg.ExecContext(ctx, ` + INSERT INTO issue_review_finding_states + (finding_id, review_state, accepted_last_seen, suppressed_until, updated_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT(finding_id) DO UPDATE SET + review_state = EXCLUDED.review_state, + accepted_last_seen = EXCLUDED.accepted_last_seen, + suppressed_until = EXCLUDED.suppressed_until, + updated_at = EXCLUDED.updated_at`, + state.FindingID, state.ReviewState, state.AcceptedLastSeen, + state.SuppressedUntil, state.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("saving issue review finding state: %w", err) + } + return nil +} + +func (s *Store) DeleteIssueReviewFindingState(ctx context.Context, findingID string) error { + if _, err := s.pg.ExecContext(ctx, + "DELETE FROM issue_review_finding_states WHERE finding_id = $1", findingID, + ); err != nil { + return fmt.Errorf("deleting issue review finding state: %w", err) + } + return nil +} + +func (s *Store) issueReviewFindingStates(ctx context.Context) ([]db.IssueReviewFindingState, error) { + rows, err := s.pg.QueryContext(ctx, ` + SELECT finding_id, review_state, accepted_last_seen, + suppressed_until, updated_at + FROM issue_review_finding_states`) + if err != nil { + return nil, fmt.Errorf("querying issue review finding states: %w", err) + } + defer rows.Close() + var states []db.IssueReviewFindingState + for rows.Next() { + var state db.IssueReviewFindingState + if err := rows.Scan(&state.FindingID, &state.ReviewState, + &state.AcceptedLastSeen, &state.SuppressedUntil, &state.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning issue review finding state: %w", err) + } + states = append(states, state) + } + return states, rows.Err() +} + +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..17434c09ca --- /dev/null +++ b/internal/postgres/issue_review_pgtest_test.go @@ -0,0 +1,83 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestIssueReviewFindingStatePersistence(t *testing.T) { + store := setupIssueReviewStore(t) + days := 7 + state, err := db.NewIssueReviewFindingState( + "0123456789abcdef", db.IssueReviewStateSuppressed, + "2026-08-10", &days, + time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC), + ) + require.NoError(t, err) + require.NoError(t, store.PutIssueReviewFindingState(context.Background(), state)) + states, err := store.issueReviewFindingStates(context.Background()) + require.NoError(t, err) + require.Equal(t, []db.IssueReviewFindingState{state}, states) + require.NoError(t, store.DeleteIssueReviewFindingState(context.Background(), state.FindingID)) + states, err = store.issueReviewFindingStates(context.Background()) + require.NoError(t, err) + assert.Empty(t, states) +} + +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)`, 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)`, 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')`, 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) + 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/schema.go b/internal/postgres/schema.go index 6f1e1114c2..85e6ccd08a 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -203,6 +203,16 @@ CREATE TABLE IF NOT EXISTS starred_sessions ( REFERENCES sessions(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS issue_review_finding_states ( + finding_id TEXT PRIMARY KEY, + review_state TEXT NOT NULL CHECK ( + review_state IN ('acknowledged', 'suppressed') + ), + accepted_last_seen TEXT NOT NULL, + suppressed_until TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS excluded_sessions ( id TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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/analytics_test.go b/internal/server/analytics_test.go index 8dfad74a37..1019bf7f5a 100644 --- a/internal/server/analytics_test.go +++ b/internal/server/analytics_test.go @@ -2,6 +2,7 @@ package server_test import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -19,6 +20,29 @@ import ( "go.kenn.io/agentsview/internal/dbtest" ) +func TestIssueReviewFindingStateHTTP(t *testing.T) { + te := setup(t) + const path = "/api/v1/analytics/issue-review/findings/0123456789abcdef/state" + + w := te.put(t, path, `{"review_state":"suppressed","finding_last_seen":"2026-08-10","suppression_days":7}`) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + var state db.IssueReviewFindingState + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &state)) + assert.Equal(t, db.IssueReviewStateSuppressed, state.ReviewState) + assert.NotEmpty(t, state.SuppressedUntil) + + w = te.put(t, path, `{"review_state":"suppressed","finding_last_seen":"2026-08-10","suppression_days":2}`) + require.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + + w = te.del(t, path) + require.Equal(t, http.StatusNoContent, w.Code, "body: %s", w.Body.String()) + var count int + require.NoError(t, te.db.Reader().QueryRow( + "SELECT COUNT(*) FROM issue_review_finding_states", + ).Scan(&count)) + assert.Zero(t, count) +} + const basePath = "/api/v1/analytics/" // seedStats holds expected values after seeding the database. diff --git a/internal/server/huma_routes_analytics.go b/internal/server/huma_routes_analytics.go index b6c1778f0d..5131c9f4b9 100644 --- a/internal/server/huma_routes_analytics.go +++ b/internal/server/huma_routes_analytics.go @@ -25,6 +25,9 @@ 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) + put(s, group, "/issue-review/findings/{id}/state", "Set issue review finding state", s.humaPutIssueReviewFindingState) + deleteRoute(s, group, "/issue-review/findings/{id}/state", "Reopen issue review finding", s.humaDeleteIssueReviewFindingState) } type analyticsGranularity string @@ -78,6 +81,43 @@ 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"` + ReviewState string `query:"review_state" enum:"active,acknowledged,suppressed" doc:"User review state; suppressed findings are hidden when omitted"` + 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 issueReviewFindingStateInput struct { + ID string `path:"id" pattern:"^[0-9a-f]{16}$" doc:"Stable finding ID"` + Body struct { + ReviewState string `json:"review_state" enum:"acknowledged,suppressed" required:"true" doc:"Accepted finding state"` + FindingLastSeen string `json:"finding_last_seen" format:"date" required:"true" doc:"Finding last_seen snapshot"` + SuppressionDays *int `json:"suppression_days,omitempty" doc:"Suppress for 1, 7, or 30 days; omit for permanent suppression"` + } +} + +type issueReviewFindingStatePathInput struct { + ID string `path:"id" pattern:"^[0-9a-f]{16}$" doc:"Stable finding ID"` +} + func analyticsFilterFromInput(in AnalyticsFilterInput) (db.AnalyticsFilter, error) { tz := in.Timezone if tz == "" { @@ -301,3 +341,75 @@ 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 + } + reason := in.Category + if reason == "" { + reason = in.Reason + } + result, err := s.db.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, + ReviewState: in.ReviewState, + 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 +} + +func (s *Server) humaPutIssueReviewFindingState( + ctx context.Context, + in *issueReviewFindingStateInput, +) (*jsonOutput[db.IssueReviewFindingState], error) { + state, err := db.NewIssueReviewFindingState( + in.ID, in.Body.ReviewState, in.Body.FindingLastSeen, + in.Body.SuppressionDays, time.Now(), + ) + if err != nil { + return nil, apiError(http.StatusBadRequest, err.Error()) + } + if err := s.db.PutIssueReviewFindingState(ctx, state); err != nil { + if handled := handleHumaReadOnly(err); handled != nil { + return nil, handled + } + return nil, internalError("save issue review finding state", err) + } + return &jsonOutput[db.IssueReviewFindingState]{Body: state}, nil +} + +func (s *Server) humaDeleteIssueReviewFindingState( + ctx context.Context, + in *issueReviewFindingStatePathInput, +) (*noContentOutput, error) { + if err := s.db.DeleteIssueReviewFindingState(ctx, in.ID); err != nil { + if handled := handleHumaReadOnly(err); handled != nil { + return nil, handled + } + return nil, internalError("delete issue review finding state", err) + } + return &noContentOutput{Status: http.StatusNoContent}, nil +}