From 2209ce3a4330be06fcf7483d2044332c59cbc1fc Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:35:51 +0000 Subject: [PATCH 01/62] feat(friction): add detectors and provenance --- docs/internal/jilog-adaptation.md | 139 ++++++++++ internal/friction/detect_corrections.go | 91 +++++++ internal/friction/detect_corrections_test.go | 256 ++++++++++++++++++ internal/friction/detect_errors.go | 72 +++++ internal/friction/detect_errors_test.go | 181 +++++++++++++ internal/friction/detect_text.go | 92 +++++++ internal/friction/detect_text_test.go | 125 +++++++++ internal/friction/format.go | 68 +++++ internal/friction/format_test.go | 88 ++++++ internal/friction/helpers_test.go | 45 ++++ internal/friction/iteration.go | 93 +++++++ internal/friction/iteration_test.go | 150 +++++++++++ internal/friction/noise.go | 178 +++++++++++++ internal/friction/noise_test.go | 61 +++++ internal/friction/p0.go | 37 +++ internal/friction/p0_test.go | 109 ++++++++ internal/friction/signal.go | 118 ++++++++ internal/friction/title.go | 37 +++ internal/friction/title_test.go | 68 +++++ internal/friction/usd.go | 111 ++++++++ internal/friction/usd_test.go | 95 +++++++ internal/serdejson/decode.go | 83 ++++++ internal/serdejson/decode_test.go | 44 +++ internal/serdejson/encode.go | 266 +++++++++++++++++++ internal/serdejson/encode_test.go | 118 ++++++++ internal/serdejson/number.go | 195 ++++++++++++++ internal/serdejson/number_test.go | 110 ++++++++ internal/serdejson/timestamp.go | 25 ++ internal/serdejson/timestamp_test.go | 31 +++ 29 files changed, 3086 insertions(+) create mode 100644 docs/internal/jilog-adaptation.md create mode 100644 internal/friction/detect_corrections.go create mode 100644 internal/friction/detect_corrections_test.go create mode 100644 internal/friction/detect_errors.go create mode 100644 internal/friction/detect_errors_test.go create mode 100644 internal/friction/detect_text.go create mode 100644 internal/friction/detect_text_test.go create mode 100644 internal/friction/format.go create mode 100644 internal/friction/format_test.go create mode 100644 internal/friction/helpers_test.go create mode 100644 internal/friction/iteration.go create mode 100644 internal/friction/iteration_test.go create mode 100644 internal/friction/noise.go create mode 100644 internal/friction/noise_test.go create mode 100644 internal/friction/p0.go create mode 100644 internal/friction/p0_test.go create mode 100644 internal/friction/signal.go create mode 100644 internal/friction/title.go create mode 100644 internal/friction/title_test.go create mode 100644 internal/friction/usd.go create mode 100644 internal/friction/usd_test.go create mode 100644 internal/serdejson/decode.go create mode 100644 internal/serdejson/decode_test.go create mode 100644 internal/serdejson/encode.go create mode 100644 internal/serdejson/encode_test.go create mode 100644 internal/serdejson/number.go create mode 100644 internal/serdejson/number_test.go create mode 100644 internal/serdejson/timestamp.go create mode 100644 internal/serdejson/timestamp_test.go diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md new file mode 100644 index 0000000000..e07deb9307 --- /dev/null +++ b/docs/internal/jilog-adaptation.md @@ -0,0 +1,139 @@ +--- +last_edited: 2026-09-23 +--- + +# jilog adaptation provenance + +AgentsView's Friction Log adapts the session review from +[jilog](https://github.com/Joi/jilog) at commit `9e8e094` (workspace version +0.8.1). jilog's detectors, thresholds, digest format, and tests are the +behavioral reference. This page records the source, the changes, and the MIT +notice. Source paths below are relative to that jilog commit. + +PR 1 provides pure detection, signal, formatting, and JSON packages. The +archive adapter, persisted review, digest rendering, scheduling, NanoClaw +integration, and Kata filing are planned for later PRs. Descriptions of those +parts below record the approved mapping; they do not describe PR 1 behavior. + +## Kept in PR 1 + +- The correction, error, workaround, deferral, and pattern signal model + (`crates/jilog-review/src/signal.rs:6-219`) maps to `friction.Signal`. +- Coding and chat correction detection keeps the ten chat marker patterns + (`crates/jilog-review/src/detectors.rs:89-176`). +- Error detection keeps the `{"error","success"}` envelope rules, message + precedence, and expected-noise rules for `mode` denials and content-free + `bash` failures (`detectors.rs:205-488`). +- Workaround and deferral detection keeps the eight and nine patterns and + labels, respectively (`detectors.rs:31-78,504-601`). +- P0 alerts require at least three distinct root sessions per tool + (`detectors.rs:81,609-633`). Operational diagnostics remain excluded by + subject kind, without a list of tool names. +- `iteration_runaway` requires 150 tool calls without an intervening user + message; sub-agents are exempt (`health.rs:220-278`). +- Issue title construction (`tracker.rs:59-90`), `python_repr`, rune truncation, + truncation with a marker (`util.rs:69-113`), display sanitization + (`digest.rs:147-155`), and USD formatting (`digest.rs:1120-1127`) have pure + Go equivalents. A source-specific title exception was dropped. +- `internal/serdejson` reproduces the relevant serde_json 1.x output: sorted + keys, no HTML escaping, and float layout. This keeps JSON serialization + byte-stable for future digests within the documented parity limits. + +## Planned replacements + +- AgentsView parsers and its archive replace jilog's transcript readers. An + adapter will build detector input from archived sessions in PR 2. +- Existing retry, runaway-loop, edit-churn, mid-task-compaction, and + context-pressure signals replace jilog's `stuck_loop` and + `compaction_storm`. The review will expose them as pattern kinds. +- Session parent relationships replace jilog's 16-zero sub-agent ID prefix. +- Database tables replace the processed-sessions file and retry sidecar. A + catch-up job will build each completed local day once, replacing the nightly + run. +- A direct usage-rollup query will replace the `agentsview usage daily` + shell-out for archive spend. +- A native Kata HTTP client will replace the Kata CLI tracker. Filing remains + off until a Kata hub is configured. +- Generic NanoClaw persona and channel resolution, trust filtering, and + message-envelope cleanup will retain the public NanoClaw behavior. They will + be inactive until a NanoClaw data directory is configured. + +## Dropped + +- `resume_storm`, whose only source is Amplifier `session:resume` events. +- The GitHub tracker, synthetic IDs from the none tracker, file-path helpers + (`contract_tilde`, `expand_tilde`), and `run_with_timeout`. +- Collectors and rules tied to a particular worker setup: private diagnostic + collectors, seats inferred from fixed pool-profile paths, a worker-specific + title exception, and a P0 exclusion list of worker tool names. A configured + source can provide generic diagnostics and seats in later PRs. +- Migration or matching of existing `[jilog/…]` issues. Friction Log starts + with its own issue history. + +## Planned additions + +- `frustration` and `interruption` kinds will use AgentsView's existing + frustration markers and interrupted-turn rows. They do not change a jilog + kind. +- Archived tool calls will supply error, P0, and pattern findings. jilog's + AgentsView reader cannot produce those findings from its session rows. +- Detection and digests will be on by default, as running jilog makes them. + +## Deliberate differences + +- Titles use `[friction/]`, labels use `friction`, and the planned digest + heading is `# Friction Log — `. +- The planned adapter removes system, compact-boundary, and tool-result rows + from every correction stream, extending jilog's NanoClaw rule to all + sessions. +- The planned adapter drops thinking blocks and tool renderings from stored + assistant content, matching jilog's text-block-only extraction. +- The planned adapter synthesizes error envelopes from tool rows and maps the + `Bash` tool category to `bash` for the noise allowlist. + +## Parity notes + +- Go's RE2 `\b` is ASCII-only; Rust's word boundary is Unicode-aware. A + corrective marker beside a non-ASCII letter (`wrongé`) matches here but not + in jilog. `TestChatCorrectionWordBoundary` pins this accepted difference. +- RE2 `\d` is ASCII-only. A timeout sentence with fullwidth digits is + reported as an error here and suppressed by jilog. + `TestBareTimeoutDigitClass` pins the difference. +- serde_json 1.0.149 formats floats with zmij, not ryu. + `internal/serdejson` ports zmij's layout. serde_json's default parser can be + one unit in the last place off for some long literals, such as + `12345678901234567.0`; Go parses them exactly, so those values print + differently. +- `friction.ParseUSD` accepts plain decimals only. rust_decimal also accepts + exponents and underscores, which AgentsView does not produce. +- An empty tool name becomes `unknown`; jilog does this only for a missing + name. + +## Digest golden deltas + +The digest renderer is planned for PR 4. That PR will replace this note with +every difference between AgentsView's golden digest and jilog's +`tests/golden/learning-digest.md`. + +## License + +The adapted code is distributed under the MIT License: + +Copyright (c) 2026 Joichi Ito + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/internal/friction/detect_corrections.go b/internal/friction/detect_corrections.go new file mode 100644 index 0000000000..1d8c1d8700 --- /dev/null +++ b/internal/friction/detect_corrections.go @@ -0,0 +1,91 @@ +package friction + +import ( + "regexp" + "strings" +) + +// chatCorrectionPatterns is jilog detectors.rs:89-100, verbatim. Go RE2's +// \b is ASCII-only where Rust's is Unicode-aware; the difference is +// pinned by TestChatCorrectionWordBoundary (spec Open question 8). +var chatCorrectionPatterns = compileAll([]string{ + `(?i)^no[,.! ]`, + `(?i)\bdon'?t\b`, + `(?i)\bdo not\b`, + `(?i)\bplease stop\b`, + `(?i)\bstop (doing|replying|posting|answering|sending|using|adding)\b`, + `(?i)\bwrong\b`, + `(?i)\bincorrect\b`, + `(?i)\bnot (that|what|like that|right|the right)\b`, + `(?i)\bshould(n'?t| not| never)\b`, + `(?i)\bthat'?s not\b`, +}) + +func compileAll(patterns []string) []*regexp.Regexp { + out := make([]*regexp.Regexp, len(patterns)) + for i, p := range patterns { + out[i] = regexp.MustCompile(p) + } + return out +} + +// firstMatch returns the index of the lowest-index pattern that matches +// anywhere in text (RegexSet semantics: declaration order, not position). +func firstMatch(res []*regexp.Regexp, text string) (int, bool) { + for i, re := range res { + if re.MatchString(text) { + return i, true + } + } + return 0, false +} + +// DetectCorrections ports detect_corrections (jilog detectors.rs:108-168): +// an assistant→user→assistant window whose user text is at most 200 bytes +// raw and at least 15 bytes trimmed. +func DetectCorrections(msgs []Message, subjectID string) []Signal { + return detectCorrections(msgs, subjectID, false) +} + +// DetectCorrectionsChat ports detect_corrections_chat (detectors.rs:116-118): +// the coding rule plus a corrective-language gate on the trimmed text. +func DetectCorrectionsChat(msgs []Message, subjectID string) []Signal { + return detectCorrections(msgs, subjectID, true) +} + +func detectCorrections(msgs []Message, subjectID string, chat bool) []Signal { + var out []Signal + for i := 0; i+2 < len(msgs); i++ { + a, u, b := msgs[i], msgs[i+1], msgs[i+2] + if a.Role != "assistant" || u.Role != "user" || b.Role != "assistant" { + continue + } + if u.HadToolResult { + continue + } + if len(u.Text) > MaxCorrectionLength { + continue + } + trimmed := strings.TrimSpace(u.Text) + if len(trimmed) < MinCorrectionLength { + continue + } + detector := DetectorCorrectionCoding + if chat { + if _, ok := firstMatch(chatCorrectionPatterns, trimmed); !ok { + continue + } + detector = DetectorCorrectionChat + } + out = append(out, Signal{ + Kind: KindCorrection, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: detector, + Text: u.Text, + Ordinal: new(u.Ordinal), + OccurredAt: u.Timestamp, + }) + } + return out +} diff --git a/internal/friction/detect_corrections_test.go b/internal/friction/detect_corrections_test.go new file mode 100644 index 0000000000..fa50de1b15 --- /dev/null +++ b/internal/friction/detect_corrections_test.go @@ -0,0 +1,256 @@ +package friction + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Ports jilog detectors.rs tests :670-820 (coding corrections). +func TestDetectCorrections(t *testing.T) { + tests := []struct { + name string + msgs []Message + want []string + }{ + { + "corrections_basic_triple", + []Message{assistant("first reply"), user("no, you misunderstood the goal here"), assistant("second reply")}, + []string{"no, you misunderstood the goal here"}, + }, + { + "corrections_too_short_skipped", + []Message{assistant("first"), user("just a short"), assistant("second")}, + []string{}, + }, + { + "corrections_too_long_skipped", + []Message{assistant("a"), user(strings.Repeat("x", 201)), assistant("b")}, + []string{}, + }, + { + "corrections_exact_minimum_length", + []Message{assistant("a"), user("123456789012345"), assistant("b")}, + []string{"123456789012345"}, + }, + { + "corrections_exact_maximum_length", + []Message{assistant("a"), user(strings.Repeat("x", 200)), assistant("b")}, + []string{strings.Repeat("x", 200)}, + }, + { + "corrections_wrong_role_pattern_skipped", + []Message{user("first message in transcript"), user("another short user message"), assistant("reply")}, + []string{}, + }, + { + "corrections_multiple_in_one_transcript", + []Message{assistant("a1"), user("first correction please"), assistant("a2"), user("second correction please"), assistant("a3")}, + []string{"first correction please", "second correction please"}, + }, + {"corrections_empty_transcript/none", nil, []string{}}, + {"corrections_empty_transcript/one", []Message{assistant("a")}, []string{}}, + {"corrections_empty_transcript/two", []Message{assistant("a"), user("hi there friend")}, []string{}}, + { + "corrections_tool_result_user_turn_excluded", + []Message{assistant("ran it"), toolResultUser("(Bash completed with no output)"), assistant("next")}, + []string{}, + }, + { + "corrections_tool_result_error_user_turn_excluded", + []Message{assistant("a"), toolResultUser("File has not been read yet. Read it first."), assistant("b")}, + []string{}, + }, + { + "corrections_real_user_string_still_detected", + []Message{assistant("first"), user("no, you misunderstood the goal here"), assistant("second")}, + []string{"no, you misunderstood the goal here"}, + }, + { + "corrections_digest_2026_06_24_fixture", + []Message{ + assistant("a0"), + toolResultUser("--- icon def block ---\n39: const I = {};"), + assistant("a1"), + user("read the deck and make sure the edits land"), + assistant("a2"), + toolResultUser("File has not been read yet."), + assistant("a3"), + user("yes - clean it up please"), + assistant("a4"), + toolResultUser("=== reverted ===\n## main...origin/main"), + assistant("a5"), + toolResultUser("(Bash completed with no output)"), + assistant("a6"), + }, + []string{"read the deck and make sure the edits land", "yes - clean it up please"}, + }, + // agentsview additions. + { + "length_is_bytes_not_runes", + // 67 three-byte runes = 201 bytes: over the raw limit although 67 runes. + []Message{assistant("a"), user(strings.Repeat("日", 67)), assistant("b")}, + []string{}, + }, + { + "trim_is_measured_but_context_is_untrimmed", + []Message{assistant("a"), user(" exactly fifteen b "), assistant("b")}, + []string{" exactly fifteen b "}, + }, + { + "system_role_breaks_the_window", + []Message{assistant("a"), {Role: "system", Text: "note"}, user("no, you misunderstood the goal"), assistant("b")}, + []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectCorrections(numbered(tt.msgs...), "s1") + assert.Equal(t, tt.want, contexts(got)) + for _, s := range got { + assert.Equal(t, "s1", s.SubjectID) + assert.Equal(t, KindCorrection, s.Kind) + assert.Equal(t, SubjectSession, s.SubjectKind) + assert.Equal(t, DetectorCorrectionCoding, s.Detector) + } + }) + } +} + +func TestDetectCorrectionsFields(t *testing.T) { + ts := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + msgs := []Message{ + {Ordinal: 3, Role: "assistant", Text: "a"}, + {Ordinal: 7, Role: "user", Text: "no, you misunderstood the goal here", Timestamp: ts}, + {Ordinal: 8, Role: "assistant", Text: "b"}, + } + got := DetectCorrections(msgs, "sess") + require.Len(t, got, 1) + require.NotNil(t, got[0].Ordinal) + assert.Equal(t, 7, *got[0].Ordinal) + assert.Nil(t, got[0].CallIndex) + assert.Equal(t, ts, got[0].OccurredAt) + assert.Equal(t, Dims{}, got[0].Dims) + assert.Zero(t, got[0].Seq) +} + +// Ports jilog detectors.rs tests :823-897 (chat corrections). +func TestDetectCorrectionsChat(t *testing.T) { + window := func(text string) []Message { return []Message{assistant("a"), user(text), assistant("b")} } + tests := []struct { + name string + msgs []Message + want []string + }{ + { + "chat_corrections_require_corrective_language", + []Message{assistant("Here is the summary you asked for."), user("thanks, that looks really great"), assistant("Happy to help.")}, + []string{}, + }, + {"chat_corrections_detect_corrective_markers/no_helper", window("no helper, don't answer in that channel"), []string{"no helper, don't answer in that channel"}}, + {"chat_corrections_detect_corrective_markers/stop_replying", window("stop replying to every message"), []string{"stop replying to every message"}}, + {"chat_corrections_detect_corrective_markers/thats_not", window("that's not what the group decided"), []string{"that's not what the group decided"}}, + {"chat_corrections_detect_corrective_markers/wrong_group", window("wrong group — that was for the steering committee"), []string{"wrong group — that was for the steering committee"}}, + {"chat_corrections_detect_corrective_markers/should_never", window("you should never post invoices here"), []string{"you should never post invoices here"}}, + {"chat_corrections_ignore_conversational_marker_lookalikes/stop_by", window("let's stop by the cafe after the session"), []string{}}, + {"chat_corrections_ignore_conversational_marker_lookalikes/never_been", window("I've never been to that part of town"), []string{}}, + {"chat_corrections_ignore_conversational_marker_lookalikes/instead", window("let's meet on Zoom instead of in person"), []string{}}, + {"chat_corrections_ignore_conversational_marker_lookalikes/actually", window("actually, that sounds great to me"), []string{}}, + { + "chat_corrections_anchor_matches_after_leading_whitespace", + window(" no, that message was for the other group"), + []string{" no, that message was for the other group"}, + }, + {"chat_corrections_keep_length_window_and_tool_result_filter/too_short", window("no, stop"), []string{}}, + { + "chat_corrections_keep_length_window_and_tool_result_filter/tool_echo", + []Message{assistant("a"), toolResultUser("don't do that, wrong file"), assistant("b")}, + []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectCorrectionsChat(numbered(tt.msgs...), "s1") + assert.Equal(t, tt.want, contexts(got)) + for _, s := range got { + assert.Equal(t, DetectorCorrectionChat, s.Detector) + } + }) + } + // chat_corrections_require_corrective_language, second half: the same + // window IS a coding correction. + plain := []Message{assistant("Here is the summary you asked for."), user("thanks, that looks really great"), assistant("Happy to help.")} + assert.Len(t, DetectCorrections(plain, "s1"), 1) +} + +func TestDetectCorrectionsChatMarkers(t *testing.T) { + tests := []struct { + name string + text string + }{ + {"no prefix", "no, choose the other draft"}, + {"don't", "don't publish this summary"}, + {"do not", "do not publish this summary"}, + {"please stop", "please stop right now"}, + {"stop action", "stop adding unrelated lines"}, + {"wrong", "that is wrong for this group"}, + {"incorrect", "this is incorrect for the group"}, + {"not right", "that is not right for us"}, + {"should never", "you should never post here"}, + {"that's not", "that's not our meeting"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectCorrectionsChat([]Message{assistant("a"), user(tt.text), assistant("b")}, "s1") + require.Len(t, got, 1) + assert.Equal(t, tt.text, got[0].Text) + }) + } +} + +func TestFirstMatch(t *testing.T) { + patterns := compileAll([]string{"alpha", "beta"}) + tests := []struct { + name string + text string + wantIndex int + wantMatch bool + }{ + {"declaration order wins over text position", "beta then alpha", 0, true}, + {"second pattern alone", "beta only", 1, true}, + {"no match", "gamma only", 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + index, matched := firstMatch(patterns, tt.text) + assert.Equal(t, tt.wantIndex, index) + assert.Equal(t, tt.wantMatch, matched) + }) + } +} + +// TestChatCorrectionWordBoundary pins the accepted RE2 difference (spec +// §6, Open question 8): Go's \b is ASCII-only, so a non-ASCII letter next +// to a marker still counts as a boundary. Rust regex 1.12.3 returns false +// for all three texts (captured with a throwaway Rust program). +func TestChatCorrectionWordBoundary(t *testing.T) { + tests := []struct { + name string + text string + goWant bool + }{ + {"accented letter after wrong", "that is so wrongé today", true}, + {"accented letter before wrong", "that is éwrong today now", true}, + {"accented letter before don't", "please Ödon't do it again", true}, + {"underscore is a word char in both", "that is wrong_ today now", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectCorrectionsChat([]Message{assistant("a"), user(tt.text), assistant("b")}, "s1") + assert.Equal(t, tt.goWant, len(got) == 1) + }) + } +} diff --git a/internal/friction/detect_errors.go b/internal/friction/detect_errors.go new file mode 100644 index 0000000000..66c03852c4 --- /dev/null +++ b/internal/friction/detect_errors.go @@ -0,0 +1,72 @@ +package friction + +import "go.kenn.io/agentsview/internal/serdejson" + +// DetectErrors ports detect_errors (jilog detectors.rs:205-245): a tool +// message whose text parses as a JSON object with "success" exactly false +// and that is not expected noise. +func DetectErrors(msgs []Message, subjectID string) []Signal { + var out []Signal + for _, m := range msgs { + if m.Role != "tool" { + continue + } + v, err := serdejson.Decode([]byte(m.Text)) + if err != nil { + continue + } + data, ok := v.(map[string]any) + if !ok { + continue + } + if success, ok := data["success"].(bool); !ok || success { + continue + } + toolName := m.ToolName + if toolName == "" { + toolName = "unknown" + } + noiseName := m.NoiseName + if noiseName == "" { + noiseName = toolName + } + if isExpectedNoise(noiseName, data) { + continue + } + out = append(out, Signal{ + Kind: KindError, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: DetectorError, + Text: extractErrorMessage(data), + ToolName: toolName, + Ordinal: new(m.Ordinal), + CallIndex: new(m.CallIndex), + OccurredAt: m.Timestamp, + }) + } + return out +} + +// extractErrorMessage ports detectors.rs:252-269: error[0] for a non-empty +// array, error for any other non-null value, else the whole object as +// compact sorted JSON. +func extractErrorMessage(data map[string]any) string { + switch e := data["error"].(type) { + case nil: + case []any: + if len(e) > 0 { + return valueAsString(e[0]) + } + default: + return valueAsString(e) + } + return serdejson.CompactString(data) +} + +func valueAsString(v any) string { + if s, ok := v.(string); ok { + return s + } + return serdejson.CompactString(v) +} diff --git a/internal/friction/detect_errors_test.go b/internal/friction/detect_errors_test.go new file mode 100644 index 0000000000..d68ec37c56 --- /dev/null +++ b/internal/friction/detect_errors_test.go @@ -0,0 +1,181 @@ +package friction + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Ports jilog detectors.rs tests :899-968 (errors) and :970-1209 (the +// expected-noise allowlist, jilog#42fd). Envelopes are the jilog json! +// literals serialized with sorted keys, as jilog's tool() helper does. +func TestDetectErrors(t *testing.T) { + tests := []struct { + name string + msg Message + wantN int + wantMsg string // exact message when non-empty + wantTool string + }{ + {"errors_success_false_detected", tool("bash", `{"error":"boom","success":false}`), 1, "boom", "bash"}, + {"errors_success_true_skipped", tool("bash", `{"output":"ok","success":true}`), 0, "", ""}, + {"errors_not_tool_role_skipped", Message{Role: "user", Text: `{"success":false}`}, 0, "", ""}, + {"errors_invalid_json_content_skipped", tool("bash", `not valid json {`), 0, "", ""}, + {"errors_error_as_list_takes_first", tool("validator", `{"error":["first issue","second issue"],"success":false}`), 1, "first issue", "validator"}, + {"errors_error_null_falls_back_to_data", tool("weird", `{"error":null,"info":"see logs","success":false}`), 1, `{"error":null,"info":"see logs","success":false}`, "weird"}, + {"whole_blob_keeps_big_ints_and_serde_floats", tool("validator", `{"success":false,"info":{"negative_zero":-0,"html":"","float":1.50,"big":9007199254740993},"error":null}`), 1, `{"error":null,"info":{"big":9007199254740993,"float":1.5,"html":"","negative_zero":-0.0},"success":false}`, "validator"}, + {"errors_no_tool_name_falls_back", Message{Role: "tool", Text: `{"error":"x","success":false}`}, 1, "x", "unknown"}, + // Strictness of success (detectors.rs:222-225). + {"success_string_false_skipped", tool("bash", `{"error":"x","success":"false"}`), 0, "", ""}, + {"success_missing_skipped", tool("bash", `{"error":"x"}`), 0, "", ""}, + {"success_zero_skipped", tool("bash", `{"error":"x","success":0}`), 0, "", ""}, + {"non_object_json_skipped", tool("bash", `[false]`), 0, "", ""}, + {"empty_text_skipped", tool("bash", ``), 0, "", ""}, + // Message extraction shapes (detectors.rs:252-269). + {"error_object_is_compact_sorted_json", tool("mode", `{"error":{"message":"no such mode","code":"invalid_transition"},"success":false}`), 1, `{"code":"invalid_transition","message":"no such mode"}`, "mode"}, + {"error_number_is_serialized", tool("x", `{"error":7,"success":false}`), 1, "7", "x"}, + {"error_empty_list_falls_back", tool("x", `{"error":[],"success":false}`), 1, `{"error":[],"success":false}`, "x"}, + {"error_list_first_object", tool("x", `{"error":[{"b":1,"a":"<"}],"success":false}`), 1, `{"a":"<","b":1}`, "x"}, + { + "golden_fixture_whole_blob", tool("bash", `{"error":null,"output":{"returncode":101,"stderr":"error[E0308]: mismatched types","stdout":""},"success":false}`), 1, + `{"error":null,"output":{"returncode":101,"stderr":"error[E0308]: mismatched types","stdout":""},"success":false}`, "bash", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectErrors([]Message{tt.msg}, "s1") + require.Len(t, got, tt.wantN) + if tt.wantN == 0 { + return + } + assert.Equal(t, tt.wantTool, got[0].ToolName) + assert.Equal(t, tt.wantMsg, got[0].Text) + assert.Equal(t, KindError, got[0].Kind) + assert.Equal(t, SubjectSession, got[0].SubjectKind) + assert.Equal(t, DetectorError, got[0].Detector) + }) + } +} + +func TestDetectErrorsFields(t *testing.T) { + ts := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + got := DetectErrors([]Message{{ + Ordinal: 12, CallIndex: 2, Role: "tool", ToolName: "Bash", + Text: `{"error":"boom","success":false}`, Timestamp: ts, + }}, "sess") + require.Len(t, got, 1) + require.NotNil(t, got[0].Ordinal) + require.NotNil(t, got[0].CallIndex) + assert.Equal(t, 12, *got[0].Ordinal) + assert.Equal(t, 2, *got[0].CallIndex) + assert.Equal(t, "sess", got[0].SubjectID) + assert.Equal(t, ts, got[0].OccurredAt) + assert.Equal(t, "Bash", got[0].ToolName) +} + +// NoiseName selects the allowlist key while the displayed tool name is +// kept (spec §6.3, D12). +func TestDetectErrorsNoiseName(t *testing.T) { + bare := `{"error":null,"output":{"returncode":1,"stderr":"","stdout":""},"success":false}` + tests := []struct { + name string + toolName string + noiseName string + wantN int + }{ + {"agentsview Bash category maps to bash", "Bash", "bash", 0}, + {"no noise name keeps jilog exact match", "Bash", "", 1}, + {"codex shell mapped to bash", "exec_command", "bash", 0}, + {"non-bash noise name", "Bash", "python_check", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectErrors([]Message{{Role: "tool", ToolName: tt.toolName, NoiseName: tt.noiseName, Text: bare}}, "s1") + require.Len(t, got, tt.wantN) + if tt.wantN == 1 { + assert.Equal(t, tt.toolName, got[0].ToolName) + } + }) + } +} + +// Every pinned case in jilog-inventory §2.4, grouped by jilog test name. +func TestDetectErrorsNoise(t *testing.T) { + const timeout30 = "Command timed out after 30 seconds" + tests := []struct { + name string + tool string + envelope string + emitted bool + }{ + {"errors_skip_mode_denial_status", "mode", `{"error":null,"output":{"status":"denied","user_instruction":"Inform the user: I'd like to clear the current mode"},"success":false}`, false}, + {"errors_skip_mode_denied_mode", "mode", `{"error":null,"output":{"denied_mode":"debug","status":"denied"},"success":false}`, false}, + {"errors_skip_mode_clear_denied_code", "mode", `{"error":{"code":"clear_denied","message":"Cannot clear mode while in 'debug'."},"success":false}`, false}, + {"errors_skip_mode_flat_denied_code", "mode", `{"code":"switch_denied","success":false}`, false}, + {"errors_skip_mode_denied_mode_null_value", "mode", `{"output":{"denied_mode":null},"success":false}`, false}, + {"errors_keep_mode_non_denial", "mode", `{"error":{"code":"invalid_transition","message":"no such mode"},"success":false}`, true}, + {"errors_keep_denied_status_from_other_tool", "delegate", `{"error":null,"output":{"status":"denied"},"success":false}`, true}, + {"errors_skip_bash_bare_timeout/object", "bash", `{"error":{"message":"` + timeout30 + `"},"success":false}`, false}, + {"errors_skip_bash_bare_timeout/string", "bash", `{"error":"Command timed out after 120 seconds.","success":false}`, false}, + {"errors_skip_bash_bare_timeout/top_message", "bash", `{"message":"Command timed out after 25 seconds","success":false}`, false}, + {"errors_skip_bash_bare_nonzero_exit/empty", "bash", `{"error":null,"output":{"returncode":1,"stderr":"","stdout":""},"success":false}`, false}, + {"errors_skip_bash_bare_nonzero_exit/whitespace", "bash", `{"error":null,"output":{"returncode":2,"stderr":" \n","stdout":"\n"},"success":false}`, false}, + {"errors_skip_bash_bare_nonzero_exit/absent_streams", "bash", `{"output":{"returncode":127},"success":false}`, false}, + {"errors_keep_bash_banner_stdout", "bash", `{"error":null,"output":{"returncode":1,"stderr":"","stdout":"=== today's health report ===\n"},"success":false}`, true}, + {"errors_keep_bash_stderr", "bash", `{"error":null,"output":{"returncode":1,"stderr":"deploy-tool: status check failed","stdout":""},"success":false}`, true}, + {"errors_keep_bash_marker_free_stdout", "bash", `{"error":null,"output":{"returncode":1,"stderr":"","stdout":"checksum mismatch: expected X, got Y"},"success":false}`, true}, + {"errors_keep_bash_structured_stdout_and_stderr/object_stdout", "bash", `{"error":null,"output":{"returncode":1,"stderr":"","stdout":{"failed":["a"]}},"success":false}`, true}, + {"errors_keep_bash_structured_stdout_and_stderr/array_stderr", "bash", `{"error":null,"output":{"returncode":1,"stderr":["boom"],"stdout":""},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/in_output", "bash", `{"output":{"message":"disk full","returncode":1},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/beside_timeout", "bash", `{"error":{"message":"` + timeout30 + `","partial":"tail of log"},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/top_level", "bash", `{"diagnostics":"oom","error":null,"output":{"returncode":1,"stderr":"","stdout":""},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/timeout_top", "bash", `{"error":"` + timeout30 + `","stderr":"killed","success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/msg_beside_error", "bash", `{"error":"` + timeout30 + `","message":"Traceback (most recent call last): ...","success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/msg_beside_error_obj", "bash", `{"error":{"message":"` + timeout30 + `"},"message":"disk full","success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/msg_struct", "bash", `{"error":"` + timeout30 + `","message":{"detail":"disk full"},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/rc_text", "bash", `{"error":{"message":"` + timeout30 + `"},"output":{"returncode":"see log: disk full","stderr":"","stdout":""},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/rc_obj", "bash", `{"error":{"message":"` + timeout30 + `"},"output":{"returncode":{"signal":"SIGKILL"}},"success":false}`, true}, + {"errors_keep_bash_extra_fields_anywhere/rc_null", "bash", `{"error":{"message":"` + timeout30 + `"},"output":{"returncode":null,"stderr":"","stdout":""},"success":false}`, true}, + {"errors_skip_bash_production_timeout_envelope/real", "bash", `{"error":{"message":"` + timeout30 + `"},"output":"` + timeout30 + `","success":false}`, false}, + {"errors_skip_bash_production_timeout_envelope/empty", "bash", `{"error":"` + timeout30 + `","output":"","success":false}`, false}, + {"errors_skip_bash_production_timeout_envelope/whitespace", "bash", `{"error":"` + timeout30 + `","output":" \n","success":false}`, false}, + {"errors_skip_bash_production_timeout_envelope/padded", "bash", `{"error":{"message":"` + timeout30 + `"},"output":" ` + timeout30 + `\n","success":false}`, false}, + {"errors_keep_bash_non_object_output_with_content/string", "bash", `{"error":"` + timeout30 + `","output":"partial build log","success":false}`, true}, + {"errors_keep_bash_non_object_output_with_content/other_sentence", "bash", `{"error":"` + timeout30 + `","output":"` + timeout30 + `\nTraceback (most recent call last):","success":false}`, true}, + {"errors_keep_bash_non_object_output_with_content/array", "bash", `{"output":["returncode",1],"success":false}`, true}, + {"errors_keep_bash_non_object_output_with_content/number", "bash", `{"error":"` + timeout30 + `","output":1,"success":false}`, true}, + {"errors_keep_bash_non_object_output_with_content/exit_status_string", "bash", `{"output":"exit status 1","success":false}`, true}, + {"errors_keep_bash_structured_error/object", "bash", `{"error":{"code":"E1"},"output":{"returncode":1,"stderr":"","stdout":""},"success":false}`, true}, + {"errors_keep_bash_structured_error/array", "bash", `{"error":["x"],"output":{"returncode":1,"stderr":"","stdout":""},"success":false}`, true}, + {"errors_keep_bash_timeout_with_output/stdout", "bash", `{"error":{"message":"` + timeout30 + `"},"output":{"stderr":"","stdout":"partial build log"},"success":false}`, true}, + {"errors_keep_bash_timeout_with_output/stderr", "bash", `{"error":"` + timeout30 + `","output":{"stderr":"warning: slow","stdout":""},"success":false}`, true}, + {"errors_keep_bash_other_error_text/spawn", "bash", `{"error":"spawn failed","success":false}`, true}, + {"errors_keep_bash_other_error_text/extra_words", "bash", `{"error":"Command timed out after 30 seconds while running cargo","success":false}`, true}, + {"errors_keep_bash_unknown_envelope", "bash", `{"success":false,"weird":1}`, true}, + {"errors_keep_bash_zero_or_non_integer_returncode/zero", "bash", `{"error":null,"output":{"returncode":0,"stderr":"","stdout":""},"success":false}`, true}, + {"errors_keep_bash_zero_or_non_integer_returncode/string", "bash", `{"error":null,"output":{"returncode":"1","stderr":"","stdout":""},"success":false}`, true}, + {"errors_keep_bare_shapes_from_other_tools", "python_check", `{"error":null,"output":{"returncode":1,"stderr":"","stdout":""},"success":false}`, true}, + {"error_text_classification/null_message_nonzero_exit", "bash", `{"error":null,"message":null,"output":{"returncode":1,"stderr":"","stdout":""},"success":false}`, false}, + {"error_text_classification/null_message_timeout", "bash", `{"error":{"message":"` + timeout30 + `"},"message":null,"output":"","success":false}`, false}, + // agentsview additions: serde_json as_i64 edges. + {"returncode_float_is_not_integer", "bash", `{"output":{"returncode":1.0},"success":false}`, true}, + {"returncode_above_i64_is_not_integer", "bash", `{"output":{"returncode":9223372036854775808},"success":false}`, true}, + {"returncode_negative_integer_is_nonzero", "bash", `{"output":{"returncode":-9},"success":false}`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectErrors([]Message{tool(tt.tool, tt.envelope)}, "s1") + if tt.emitted { + assert.Len(t, got, 1) + } else { + assert.Empty(t, got) + } + }) + } + // errors_keep_mode_non_denial also pins the message. + got := DetectErrors([]Message{tool("mode", `{"error":{"code":"invalid_transition","message":"no such mode"},"success":false}`)}, "s1") + require.Len(t, got, 1) + assert.Contains(t, got[0].Text, "invalid_transition") +} diff --git a/internal/friction/detect_text.go b/internal/friction/detect_text.go new file mode 100644 index 0000000000..b1ed4a40f0 --- /dev/null +++ b/internal/friction/detect_text.go @@ -0,0 +1,92 @@ +package friction + +// The workaround patterns and labels port jilog detectors.rs:31-52. They are +// parallel by index; the patterns intentionally have no word boundaries. +var ( + workaroundPatterns = compileAll([]string{ + `(?i)for now`, + `(?i)temporary`, + `(?i)workaround`, + `(?i)hardcoded`, + `(?i)TODO`, + `(?i)FIXME`, + `(?i)quick fix`, + `(?i)hack`, + }) + workaroundLabels = []string{ + "for now", "temporary", "workaround", "hardcoded", + "TODO", "FIXME", "quick fix", "hack", + } +) + +// The deferral patterns and labels port jilog detectors.rs:55-78. +var ( + deferralPatterns = compileAll([]string{ + `(?i)\bI'?ll come back to (this|that|it)`, + `(?i)\bdeferr?ing (this|that|it|until)`, + `(?i)\bdefer (this|that|it)(?: (to|until|for))?`, + `(?i)\bpunt(ing)? on (this|that|it)`, + `(?i)\bleav(e|ing) (this|that|it) for (later|now|next)`, + `(?i)\bskipping for now`, + `(?i)\bpark(ing)? (this|that|it) for now`, + `(?i)\bnext session`, + `(?i)\bcircle back (to|on)`, + }) + deferralLabels = []string{ + "come back later", "deferring", "defer", "punt", "leave for later", + "skipping for now", "park for now", "next session", "circle back", + } +) + +const workaroundContextRunes = 200 + +// DetectWorkarounds emits one signal per matching assistant message. The +// lowest-index matching pattern wins; context is limited to 200 runes. +func DetectWorkarounds(msgs []Message, subjectID string) []Signal { + var out []Signal + for _, m := range msgs { + if m.Role != "assistant" || m.Text == "" { + continue + } + idx, ok := firstMatch(workaroundPatterns, m.Text) + if !ok { + continue + } + out = append(out, Signal{ + Kind: KindWorkaround, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: DetectorWorkaround, + Label: workaroundLabels[idx], + Text: TruncateRunes(m.Text, workaroundContextRunes), + Ordinal: new(m.Ordinal), + OccurredAt: m.Timestamp, + }) + } + return out +} + +// DetectDeferrals emits one label-only signal per matching assistant message. +// Its pattern precedence is independent of DetectWorkarounds. +func DetectDeferrals(msgs []Message, subjectID string) []Signal { + var out []Signal + for _, m := range msgs { + if m.Role != "assistant" || m.Text == "" { + continue + } + idx, ok := firstMatch(deferralPatterns, m.Text) + if !ok { + continue + } + out = append(out, Signal{ + Kind: KindDeferral, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: DetectorDeferral, + Label: deferralLabels[idx], + Ordinal: new(m.Ordinal), + OccurredAt: m.Timestamp, + }) + } + return out +} diff --git a/internal/friction/detect_text_test.go b/internal/friction/detect_text_test.go new file mode 100644 index 0000000000..e9293df522 --- /dev/null +++ b/internal/friction/detect_text_test.go @@ -0,0 +1,125 @@ +package friction + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Message.Text holds the assistant text extracted from content blocks. The +// tool-only examples pass the resulting text, rather than tool input. +func TestDetectWorkarounds(t *testing.T) { + tests := []struct { + name string + msgs []Message + want []string + }{ + {"for_now", []Message{assistant("Using a hardcoded value for now until config is wired.")}, []string{"for now"}}, + {"one_per_message", []Message{assistant("TODO: refactor this hack workaround later.")}, []string{"workaround"}}, + {"lowest_pattern_index_not_text_position", []Message{assistant("hack temporary")}, []string{"temporary"}}, + {"hardcoded", []Message{assistant("A hardcoded value remains.")}, []string{"hardcoded"}}, + {"skip_tool_use_blocks", []Message{assistant("I ran the command.")}, []string{}}, + {"text_blocks_in_list", []Message{assistant("Quick fix incoming.")}, []string{"quick fix"}}, + {"case_insensitive", []Message{assistant("HACK: this is gross.")}, []string{"hack"}}, + {"no_match", []Message{assistant("All systems nominal. Tests passing.")}, []string{}}, + {"user_role_skipped", []Message{user("TODO this is from the user, should not match")}, []string{}}, + {"no_word_boundaries", []Message{assistant("the hackathon was temporarily moved")}, []string{"hack"}}, + {"todo_in_ordinary_prose", []Message{assistant("All data collected. Let me update the todo list and finalize")}, []string{"TODO"}}, + {"empty_text_skipped", []Message{assistant("")}, []string{}}, + {"tool_role_skipped", []Message{tool("bash", "for now")}, []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectWorkarounds(numbered(tt.msgs...), "s1") + assert.Equal(t, tt.want, labels(got)) + for _, s := range got { + assert.Equal(t, KindWorkaround, s.Kind) + assert.Equal(t, SubjectSession, s.SubjectKind) + assert.Equal(t, DetectorWorkaround, s.Detector) + assert.Equal(t, "s1", s.SubjectID) + } + }) + } +} + +func TestDetectWorkaroundsContext(t *testing.T) { + tests := []struct { + name string + text string + }{ + {"ascii", "FIXME " + strings.Repeat("x", 300)}, + {"multibyte_counts_runes", "FIXME " + strings.Repeat("日", 300)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectWorkarounds([]Message{assistant(tt.text)}, "s1") + require.Len(t, got, 1) + assert.Equal(t, 200, utf8.RuneCountInString(got[0].Text)) + assert.True(t, strings.HasPrefix(tt.text, got[0].Text)) + }) + } + short := DetectWorkarounds([]Message{assistant("for now")}, "s1") + require.Len(t, short, 1) + assert.Equal(t, "for now", short[0].Text) +} + +func TestDetectDeferrals(t *testing.T) { + tests := []struct { + name string + msgs []Message + want []string + }{ + {"come_back", []Message{assistant("I'll come back to this after the tests pass.")}, []string{"come back later"}}, + {"short_text", []Message{assistant("next session")}, []string{"next session"}}, + {"first_match_wins", []Message{assistant("I'll come back to this next session.")}, []string{"come back later"}}, + {"user_role_skipped", []Message{user("please punt on this until next session")}, []string{}}, + {"tool_blocks_excluded", []Message{assistant("I ran the command.")}, []string{}}, + {"no_match", []Message{assistant("All requested work is complete.")}, []string{}}, + {"defer", []Message{assistant("Let me defer that until the next pass.")}, []string{"defer"}}, + {"deferring", []Message{assistant("Deferring this until review.")}, []string{"deferring"}}, + {"punt", []Message{assistant("I'm punting on that one.")}, []string{"punt"}}, + {"leave_for_later", []Message{assistant("Leaving it for later.")}, []string{"leave for later"}}, + {"park_for_now", []Message{assistant("Parking this for now.")}, []string{"park for now"}}, + {"circle_back", []Message{assistant("We can circle back on the naming.")}, []string{"circle back"}}, + {"skipping_for_now", []Message{assistant("Skipping for now.")}, []string{"skipping for now"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectDeferrals(numbered(tt.msgs...), "s1") + assert.Equal(t, tt.want, labels(got)) + for _, s := range got { + assert.Equal(t, KindDeferral, s.Kind) + assert.Equal(t, SubjectSession, s.SubjectKind) + assert.Equal(t, DetectorDeferral, s.Detector) + assert.Equal(t, "s1", s.SubjectID) + assert.Empty(t, s.Text) + } + }) + } +} + +func TestTextDetectorsIndependentAndPreserveMessageIdentity(t *testing.T) { + when := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + msgs := []Message{ + {Ordinal: 7, Role: "assistant", Text: "Skipping for now while we validate the migration.", Timestamp: when}, + {Ordinal: 8, Role: "assistant", Text: "FIXME before the next session.", Timestamp: when.Add(time.Minute)}, + } + workarounds := DetectWorkarounds(msgs, "s1") + deferrals := DetectDeferrals(msgs, "s1") + require.Len(t, workarounds, 2) + require.Len(t, deferrals, 2) + assert.Equal(t, []string{"for now", "FIXME"}, labels(workarounds)) + assert.Equal(t, []string{"skipping for now", "next session"}, labels(deferrals)) + for _, signals := range [][]Signal{workarounds, deferrals} { + for i, s := range signals { + require.NotNil(t, s.Ordinal) + assert.Equal(t, msgs[i].Ordinal, *s.Ordinal) + assert.Equal(t, msgs[i].Timestamp, s.OccurredAt) + assert.Equal(t, "s1", s.SubjectID) + } + } +} diff --git a/internal/friction/format.go b/internal/friction/format.go new file mode 100644 index 0000000000..faae98d74b --- /dev/null +++ b/internal/friction/format.go @@ -0,0 +1,68 @@ +package friction + +import ( + "fmt" + "strings" + "unicode" + + "go.kenn.io/agentsview/internal/stringutil" +) + +// truncatedMarker is jilog's suffix. +const truncatedMarker = " … [truncated]" + +// TruncateRunes keeps at most n runes and adds no ellipsis. +func TruncateRunes(s string, n int) string { + return stringutil.TruncateRunes(s, n, "") +} + +// TruncateWithMarker keeps at most n runes and appends the marker only +// when text was removed. +func TruncateWithMarker(s string, n int) string { + return stringutil.TruncateRunes(s, n, truncatedMarker) +} + +// PythonRepr approximates Python repr() for a string: single quotes; +// backslash, quote, newline, carriage return, and tab escaped; other +// runes below 0x20 as \xHH; everything else verbatim. +func PythonRepr(s string) string { + var b strings.Builder + b.Grow(len(s) + 2) + b.WriteByte('\'') + for _, r := range s { + switch { + case r == '\\': + b.WriteString(`\\`) + case r == '\'': + b.WriteString(`\'`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\t': + b.WriteString(`\t`) + case r < 0x20: + fmt.Fprintf(&b, `\x%02x`, r) + default: + b.WriteRune(r) + } + } + b.WriteByte('\'') + return b.String() +} + +// SanitizeDisplay makes a free-form dimension safe for a one-line Markdown +// code span: backticks become apostrophes and Unicode control runes become +// spaces. +func SanitizeDisplay(s string) string { + return strings.Map(func(r rune) rune { + switch { + case r == '`': + return '\'' + case unicode.IsControl(r): + return ' ' + default: + return r + } + }, s) +} diff --git a/internal/friction/format_test.go b/internal/friction/format_test.go new file mode 100644 index 0000000000..cd12fe166e --- /dev/null +++ b/internal/friction/format_test.go @@ -0,0 +1,88 @@ +package friction + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTruncateRunes(t *testing.T) { + tests := []struct { + name string + in string + n int + want string + }{ + {"unicode/no truncation", "日本語", 3, "日本語"}, + {"unicode/two", "日本語", 2, "日本"}, + {"empty", "", 5, ""}, + {"zero", "abc", 0, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, TruncateRunes(tt.in, tt.n)) + }) + } +} + +func TestTruncateWithMarker(t *testing.T) { + tests := []struct { + name string + in string + n int + want string + }{ + {"truncated", strings.Repeat("x", 10), 5, "xxxxx … [truncated]"}, + {"no truncation", "short", 100, "short"}, + {"exact limit", strings.Repeat("x", MaxErrorMessageLength), MaxErrorMessageLength, strings.Repeat("x", MaxErrorMessageLength)}, + {"rune count", strings.Repeat("日", 501), MaxErrorMessageLength, strings.Repeat("日", 500) + " … [truncated]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, TruncateWithMarker(tt.in, tt.n)) + }) + } +} + +func TestPythonRepr(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"plain", "hello", `'hello'`}, + {"quote", "it's", `'it\'s'`}, + {"newline", "a\nb", `'a\nb'`}, + {"correction", "don't do that", `'don\'t do that'`}, + {"backslash", `a\b`, `'a\\b'`}, + {"cr and tab", "a\r\tb", `'a\r\tb'`}, + {"other controls", "a\x01\x1fb", `'a\x01\x1fb'`}, + {"del and non-ASCII", "a\x7fé日", "'a\x7fé日'"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, PythonRepr(tt.in)) + }) + } +} + +func TestSanitizeDisplay(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"backtick and newline", "general`\ninjected", "general' injected"}, + {"backtick tab newline", "seat`\t\n01", "seat' 01"}, + {"machine label", "mac`1\n", "mac'1 "}, + {"c1 control", "a\u0085b", "a b"}, + {"nbsp", "a\u00a0b", "a\u00a0b"}, + {"plain", "helper", "helper"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, SanitizeDisplay(tt.in)) + }) + } +} diff --git a/internal/friction/helpers_test.go b/internal/friction/helpers_test.go new file mode 100644 index 0000000000..6adbb092d0 --- /dev/null +++ b/internal/friction/helpers_test.go @@ -0,0 +1,45 @@ +package friction + +import "time" + +// Test helpers mirror jilog detectors.rs:644-666 (assistant, user, tool) +// and :697-712 (tool_result_user). Ordinals are assigned by position. +func assistant(text string) Message { return Message{Role: "assistant", Text: text} } + +func user(text string) Message { return Message{Role: "user", Text: text} } + +func tool(name, envelope string) Message { + return Message{Role: "tool", ToolName: name, Text: envelope} +} + +// toolResultUser is a user turn that carried a tool_result block. The +// adapter drops these rows; the flag keeps jilog's guard testable. +func toolResultUser(content string) Message { + return Message{Role: "user", Text: content, HadToolResult: true} +} + +func numbered(msgs ...Message) []Message { + for i := range msgs { + msgs[i].Ordinal = i + } + return msgs +} + +func contexts(sigs []Signal) []string { + out := make([]string, 0, len(sigs)) + for _, s := range sigs { + out = append(out, s.Text) + } + return out +} + +func labels(sigs []Signal) []string { + out := make([]string, 0, len(sigs)) + for _, s := range sigs { + out = append(out, s.Label) + } + return out +} + +// fixtureBase is jilog health.rs tests' 2026-01-01 09:00 UTC. +var fixtureBase = time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) diff --git a/internal/friction/iteration.go b/internal/friction/iteration.go new file mode 100644 index 0000000000..61743e9ca1 --- /dev/null +++ b/internal/friction/iteration.go @@ -0,0 +1,93 @@ +package friction + +import ( + "cmp" + "fmt" + "slices" + "time" +) + +// formatRange formats a tool-call span in UTC, as in jilog health.rs:154-156. +func formatRange(first, last time.Time) string { + return first.UTC().Format("15:04") + "-" + last.UTC().Format("15:04") +} + +type iterationEvent struct { + ordinal int + user bool + call int // index in PatternInput.Calls when !user +} + +// detectIterationRunaway ports jilog health.rs:220-278. A real user turn +// resets the consecutive tool-call count; the longest qualifying stretch +// wins. Sub-agent sessions are exempt. Ordinals restore event order when +// the caller's user-turn and call slices are interleaved. +func detectIterationRunaway(subjectID string, isSubAgent bool, in PatternInput) (Signal, bool) { + if isSubAgent { + return Signal{}, false + } + + events := make([]iterationEvent, 0, len(in.UserOrdinals)+len(in.Calls)) + for _, ordinal := range in.UserOrdinals { + events = append(events, iterationEvent{ordinal: ordinal, user: true}) + } + for i, call := range in.Calls { + events = append(events, iterationEvent{ordinal: call.MessageOrdinal, call: i}) + } + slices.SortStableFunc(events, func(a, b iterationEvent) int { + if order := cmp.Compare(a.ordinal, b.ordinal); order != 0 { + return order + } + switch { + case a.user && !b.user: + return -1 + case b.user && !a.user: + return 1 + default: + return 0 + } + }) + + bestCount, bestFirst, bestLast := 0, -1, -1 + count, first, last := 0, -1, -1 + flush := func() { + if count >= IterationRunawayMinToolCalls && count > bestCount { + bestCount, bestFirst, bestLast = count, first, last + } + } + for _, event := range events { + if event.user { + flush() + count, first, last = 0, -1, -1 + continue + } + count++ + if first < 0 { + first = event.call + } + last = event.call + } + flush() + if bestCount == 0 { + return Signal{}, false + } + + callTime := func(i int) time.Time { + if i < len(in.CallTimes) { + return in.CallTimes[i] + } + return time.Time{} + } + start, end := callTime(bestFirst), callTime(bestLast) + return Signal{ + Kind: KindPattern, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: PatternDetector(PatternIterationRunaway), + Label: PatternIterationRunaway, + Text: fmt.Sprintf("iteration runaway: %d tool calls with no intervening user message", bestCount), + Evidence: fmt.Sprintf("%d tool calls without a user message %s", bestCount, formatRange(start, end)), + Ordinal: new(in.Calls[bestFirst].MessageOrdinal), + OccurredAt: start, + }, true +} diff --git a/internal/friction/iteration_test.go b/internal/friction/iteration_test.go new file mode 100644 index 0000000000..36630dccac --- /dev/null +++ b/internal/friction/iteration_test.go @@ -0,0 +1,150 @@ +package friction + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/signals" +) + +// runawayInput translates ordered user turns and tool calls into the separate +// slices carried by PatternInput. Ordinal gaps stand for assistant text rows. +type runawayStep struct { + user bool + minute int +} + +func calls(n, startMinute int) []runawayStep { + out := make([]runawayStep, n) + for i := range out { + out[i] = runawayStep{minute: startMinute + i} + } + return out +} + +func runawayInput(groups ...[]runawayStep) PatternInput { + var in PatternInput + ordinal := 0 + for _, group := range groups { + for _, step := range group { + if step.user { + in.UserOrdinals = append(in.UserOrdinals, ordinal) + } else { + in.Calls = append(in.Calls, signals.ToolCallRow{ + ToolName: "bash", InputJSON: fmt.Sprintf(`{"step":%d}`, ordinal), MessageOrdinal: ordinal, + }) + in.CallTimes = append(in.CallTimes, fixtureBase.Add(time.Duration(step.minute)*time.Minute)) + } + ordinal += 2 + } + } + return in +} + +// These cases port jilog's iteration_runaway tests in health.rs:521-590. +func TestDetectIterationRunaway(t *testing.T) { + userTurn := []runawayStep{{user: true}} + tests := []struct { + name string + subAgent bool + input PatternInput + fires bool + evidence string + }{ + {"iteration_runaway_fires_at_threshold", false, runawayInput(calls(150, 0)), true, "150 tool calls without a user message 09:00-11:29"}, + {"iteration_runaway_silent_below_threshold", false, runawayInput(calls(149, 0)), false, ""}, + {"iteration_runaway_root_session_fires_at_150/100", false, runawayInput(calls(100, 0)), false, ""}, + {"iteration_runaway_root_session_fires_at_150/149", false, runawayInput(calls(149, 0)), false, ""}, + {"iteration_runaway_root_session_fires_at_150/150", false, runawayInput(calls(150, 0)), true, "150 tool calls without a user message 09:00-11:29"}, + {"iteration_runaway_exempts_sub_agent_sessions/sub_agent", true, runawayInput(calls(200, 0)), false, ""}, + {"iteration_runaway_exempts_sub_agent_sessions/root", false, runawayInput(calls(200, 0)), true, "200 tool calls without a user message 09:00-12:19"}, + {"iteration_runaway_user_message_resets_count", false, runawayInput(calls(149, 0), userTurn, calls(149, 150)), false, ""}, + {"iteration_runaway_llm_responses_do_not_reset", false, runawayInput(calls(150, 0)), true, "150 tool calls without a user message 09:00-11:29"}, + {"iteration_runaway_reports_longest_stretch", false, runawayInput(calls(151, 0), userTurn, calls(155, 152)), true, "155 tool calls without a user message 11:32-14:06"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sig, ok := detectIterationRunaway("s1", tt.subAgent, tt.input) + require.Equal(t, tt.fires, ok) + if !ok { + assert.Equal(t, Signal{}, sig) + return + } + assert.Equal(t, PatternIterationRunaway, sig.Label) + assert.Equal(t, tt.evidence, sig.Evidence) + }) + } + assert.Equal(t, 150, IterationRunawayMinToolCalls) +} + +func TestDetectIterationRunawayFields(t *testing.T) { + in := runawayInput(calls(151, 0), []runawayStep{{user: true}}, calls(155, 152)) + sig, ok := detectIterationRunaway("sess", false, in) + require.True(t, ok) + assert.Equal(t, KindPattern, sig.Kind) + assert.Equal(t, SubjectSession, sig.SubjectKind) + assert.Equal(t, "sess", sig.SubjectID) + assert.Equal(t, "pattern.iteration_runaway", sig.Detector) + assert.Equal(t, "iteration_runaway", sig.Label) + assert.Equal(t, "iteration runaway: 155 tool calls with no intervening user message", sig.Text) + assert.Equal(t, "155 tool calls without a user message 11:32-14:06", sig.Evidence) + require.NotNil(t, sig.Ordinal) + assert.Equal(t, in.Calls[151].MessageOrdinal, *sig.Ordinal) + assert.Nil(t, sig.CallIndex) + assert.Equal(t, in.CallTimes[151], sig.OccurredAt) +} + +func TestDetectIterationRunawayTieKeepsFirst(t *testing.T) { + in := runawayInput(calls(150, 0), []runawayStep{{user: true}}, calls(150, 200)) + sig, ok := detectIterationRunaway("s1", false, in) + require.True(t, ok) + assert.Equal(t, "150 tool calls without a user message 09:00-11:29", sig.Evidence) + require.NotNil(t, sig.Ordinal) + assert.Equal(t, 0, *sig.Ordinal) +} + +func TestDetectIterationRunawayOrdersByOrdinal(t *testing.T) { + in := runawayInput(calls(151, 0), []runawayStep{{user: true}}, calls(155, 152)) + // Keep each call paired with its timestamp while scrambling input order + // across the user turn and within the longest stretch. + for _, pair := range [][2]int{{0, 305}, {151, 200}} { + in.Calls[pair[0]], in.Calls[pair[1]] = in.Calls[pair[1]], in.Calls[pair[0]] + in.CallTimes[pair[0]], in.CallTimes[pair[1]] = in.CallTimes[pair[1]], in.CallTimes[pair[0]] + } + sig, ok := detectIterationRunaway("s1", false, in) + require.True(t, ok) + assert.Equal(t, "iteration runaway: 155 tool calls with no intervening user message", sig.Text) + assert.Equal(t, "155 tool calls without a user message 11:32-14:06", sig.Evidence) + require.NotNil(t, sig.Ordinal) + assert.Equal(t, 304, *sig.Ordinal) + assert.Equal(t, time.Date(2026, 1, 1, 11, 32, 0, 0, time.UTC), sig.OccurredAt) +} + +func TestDetectIterationRunawayUserFirstOnTie(t *testing.T) { + in := runawayInput(calls(150, 0)) + in.UserOrdinals = []int{in.Calls[0].MessageOrdinal} + sig, ok := detectIterationRunaway("s1", false, in) + require.True(t, ok) + assert.Equal(t, "150 tool calls without a user message 09:00-11:29", sig.Evidence) +} + +func TestFormatRange(t *testing.T) { + tokyo := time.FixedZone("JST", 9*3600) + first := fixtureBase.Add(time.Minute) + last := fixtureBase.Add(8 * time.Minute) + assert.Equal(t, "09:01-09:08", formatRange(first, last)) + assert.Equal(t, "09:01-09:08", formatRange(first.In(tokyo), last.In(tokyo))) +} + +func TestDetectIterationRunawayMissingTimes(t *testing.T) { + in := runawayInput(calls(150, 0)) + in.CallTimes = nil + sig, ok := detectIterationRunaway("s1", false, in) + require.True(t, ok) + assert.Equal(t, "150 tool calls without a user message 00:00-00:00", sig.Evidence) + assert.True(t, sig.OccurredAt.IsZero()) +} diff --git a/internal/friction/noise.go b/internal/friction/noise.go new file mode 100644 index 0000000000..ca47cd8375 --- /dev/null +++ b/internal/friction/noise.go @@ -0,0 +1,178 @@ +package friction + +import ( + "regexp" + "slices" + "strings" + + "go.kenn.io/agentsview/internal/serdejson" +) + +// Expected-noise allowlist, ported verbatim from jilog detectors.rs:279-488 +// (jilog#42fd). Only positively identified shapes are suppressed; anything +// unrecognized is emitted. + +// bareTimeoutRe is detectors.rs:326. RE2 \d is ASCII-only (Rust's is +// Unicode); pinned by TestBareTimeoutDigitClass. +var bareTimeoutRe = regexp.MustCompile(`(?i)^command timed out after \d+ seconds?\.?$`) + +var ( + bareTopKeys = []string{"success", "error", "output", "message"} + bareOutputKeys = []string{"returncode", "stdout", "stderr"} + bareErrorKeys = []string{"message"} +) + +func isExpectedNoise(toolName string, data map[string]any) bool { + return isModeDenial(toolName, data) || isContentFreeBashFailure(toolName, data) +} + +// isModeDenial is detectors.rs:299-320. +func isModeDenial(toolName string, data map[string]any) bool { + if toolName != "mode" { + return false + } + output, _ := data["output"].(map[string]any) + statusDenied := output["status"] == "denied" + _, hasDeniedMode := output["denied_mode"] + errObj, _ := data["error"].(map[string]any) + codeDenied := false + for _, c := range []any{errObj["code"], data["code"]} { + if s, ok := c.(string); ok && strings.HasSuffix(s, "_denied") { + codeDenied = true + } + } + return statusDenied || hasDeniedMode || codeDenied +} + +type errTextKind int + +const ( + errTextAbsent errTextKind = iota + errTextText + errTextUnrecognized +) + +// errorText is detectors.rs:347-368. +func errorText(data map[string]any) (errTextKind, string) { + hasMessage := data["message"] != nil + errV := data["error"] + if errV == nil { + switch m := data["message"].(type) { + case string: + return errTextText, m + case nil: + return errTextAbsent, "" + default: + return errTextUnrecognized, "" + } + } + if hasMessage { + return errTextUnrecognized, "" + } + switch e := errV.(type) { + case string: + return errTextText, e + case map[string]any: + if s, ok := e["message"].(string); ok { + return errTextText, s + } + return errTextUnrecognized, "" + default: + return errTextUnrecognized, "" + } +} + +func keysWithin(m map[string]any, allowed []string) bool { + for k := range m { + if !slices.Contains(allowed, k) { + return false + } + } + return true +} + +// envelopeIsBare is detectors.rs:405-421. +func envelopeIsBare(data map[string]any) bool { + if !keysWithin(data, bareTopKeys) { + return false + } + errorOK := false + switch e := data["error"].(type) { + case nil, string: + errorOK = true + case map[string]any: + errorOK = keysWithin(e, bareErrorKeys) + } + outputOK := false + switch o := data["output"].(type) { + case nil, string: + outputOK = true + case map[string]any: + outputOK = keysWithin(o, bareOutputKeys) + } + return errorOK && outputOK +} + +// outputText is detectors.rs:373-380. +func outputText(output map[string]any, key string) (string, bool) { + switch v := output[key].(type) { + case nil: + return "", true + case string: + return v, true + default: + return "", false + } +} + +// isInt64 mirrors serde_json Value::as_i64().is_some(). +func isInt64(v any) (int64, bool) { + n, ok := v.(serdejson.Number) + if !ok { + return 0, false + } + return n.Int64() +} + +// outputIsBlank is detectors.rs:432-456. +func outputIsBlank(data map[string]any) bool { + switch o := data["output"].(type) { + case nil: + return true + case string: + t := strings.TrimSpace(o) + return t == "" || bareTimeoutRe.MatchString(t) + case map[string]any: + returncodeOK := true + if rc, present := o["returncode"]; present { + _, returncodeOK = isInt64(rc) + } + stdout, okOut := outputText(o, "stdout") + stderr, okErr := outputText(o, "stderr") + return returncodeOK && okOut && okErr && + strings.TrimSpace(stdout) == "" && strings.TrimSpace(stderr) == "" + default: + return false + } +} + +// isBareTimeout is detectors.rs:460-466. +func isBareTimeout(data map[string]any) bool { + kind, text := errorText(data) + return envelopeIsBare(data) && kind == errTextText && + bareTimeoutRe.MatchString(strings.TrimSpace(text)) && outputIsBlank(data) +} + +// isBareNonzeroExit is detectors.rs:472-483. +func isBareNonzeroExit(data map[string]any) bool { + output, _ := data["output"].(map[string]any) + rc, ok := isInt64(output["returncode"]) + nonzero := ok && rc != 0 + kind, _ := errorText(data) + return envelopeIsBare(data) && nonzero && kind == errTextAbsent && outputIsBlank(data) +} + +// isContentFreeBashFailure is detectors.rs:486-488. +func isContentFreeBashFailure(toolName string, data map[string]any) bool { + return toolName == "bash" && (isBareTimeout(data) || isBareNonzeroExit(data)) +} diff --git a/internal/friction/noise_test.go b/internal/friction/noise_test.go new file mode 100644 index 0000000000..8036a31655 --- /dev/null +++ b/internal/friction/noise_test.go @@ -0,0 +1,61 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/serdejson" +) + +// Ports jilog detectors.rs error_text_classification (:1190-1209). +func TestErrorText(t *testing.T) { + tests := []struct { + name string + data string + wantKind errTextKind + wantText string + }{ + {"empty", `{}`, errTextAbsent, ""}, + {"error null", `{"error":null}`, errTextAbsent, ""}, + {"error string", `{"error":"x"}`, errTextText, "x"}, + {"error object message", `{"error":{"message":"m"}}`, errTextText, "m"}, + {"top message", `{"message":"top"}`, errTextText, "top"}, + {"error object without message", `{"error":{"code":"c"}}`, errTextUnrecognized, ""}, + {"error number", `{"error":7}`, errTextUnrecognized, ""}, + {"message number", `{"message":7}`, errTextUnrecognized, ""}, + {"error beside message", `{"error":"x","message":"y"}`, errTextUnrecognized, ""}, + {"null error with message", `{"error":null,"message":"y"}`, errTextText, "y"}, + {"null message beside error", `{"error":"x","message":null}`, errTextText, "x"}, + {"both null", `{"error":null,"message":null}`, errTextAbsent, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := serdejson.Decode([]byte(tt.data)) + require.NoError(t, err) + kind, text := errorText(v.(map[string]any)) + assert.Equal(t, tt.wantKind, kind) + assert.Equal(t, tt.wantText, text) + }) + } +} + +// TestBareTimeoutDigitClass pins the RE2 \d difference: Rust regex's \d +// is Unicode (fullwidth "30" matches, captured with regex 1.12.3); RE2's +// is ASCII, so the fullwidth sentence is emitted here. +func TestBareTimeoutDigitClass(t *testing.T) { + tests := []struct { + name, text string + want int + }{ + {"ASCII digits suppressed", "Command timed out after 30 seconds", 0}, + {"fullwidth digits emitted", "Command timed out after 30 seconds", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetectErrors([]Message{tool("bash", `{"error":"`+tt.text+`","success":false}`)}, "s1") + assert.Len(t, got, tt.want) + }) + } +} diff --git a/internal/friction/p0.go b/internal/friction/p0.go new file mode 100644 index 0000000000..7dbed76da1 --- /dev/null +++ b/internal/friction/p0.go @@ -0,0 +1,37 @@ +package friction + +import "sort" + +// DetectP0Alerts groups error signals by tool and returns sorted subject IDs +// for tools with at least P0DistinctSessionThreshold distinct sessions. +// Diagnostic subjects and subjects accepted by isSubAgent are excluded. +// A nil predicate excludes no sub-agents. The result is always non-nil. +func DetectP0Alerts(errors []Signal, isSubAgent func(subjectID string) bool) map[string][]string { + byTool := make(map[string]map[string]struct{}) + for _, signal := range errors { + if signal.Kind != KindError || signal.SubjectKind == SubjectDiagnostic { + continue + } + if isSubAgent != nil && isSubAgent(signal.SubjectID) { + continue + } + if byTool[signal.ToolName] == nil { + byTool[signal.ToolName] = make(map[string]struct{}) + } + byTool[signal.ToolName][signal.SubjectID] = struct{}{} + } + + alerts := make(map[string][]string) + for tool, subjects := range byTool { + if len(subjects) < P0DistinctSessionThreshold { + continue + } + ids := make([]string, 0, len(subjects)) + for id := range subjects { + ids = append(ids, id) + } + sort.Strings(ids) + alerts[tool] = ids + } + return alerts +} diff --git a/internal/friction/p0_test.go b/internal/friction/p0_test.go new file mode 100644 index 0000000000..a9c08a3426 --- /dev/null +++ b/internal/friction/p0_test.go @@ -0,0 +1,109 @@ +package friction + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func p0Error(subject, tool string) Signal { + return Signal{Kind: KindError, SubjectID: subject, SubjectKind: SubjectSession, ToolName: tool} +} + +// The prefix retains the upstream sub-agent fixture's meaning. Callers use +// their own parent-session relationship to provide this predicate. +func p0FixtureSubAgent(id string) bool { return strings.HasPrefix(id, "0000000000000000") } + +// Ports the threshold, deduplication, and sub-agent cases from the upstream +// P0 detector tests. +func TestDetectP0Alerts(t *testing.T) { + tests := []struct { + name string + errors []Signal + want map[string][]string + }{ + { + name: "three distinct subjects sorted", + errors: []Signal{p0Error("c", "shell"), p0Error("a", "shell"), p0Error("b", "shell")}, + want: map[string][]string{"shell": {"a", "b", "c"}}, + }, + { + name: "two distinct subjects skipped", + errors: []Signal{p0Error("a", "shell"), p0Error("b", "shell")}, + want: map[string][]string{}, + }, + { + name: "repeated subject counted once", + errors: []Signal{p0Error("a", "shell"), p0Error("a", "shell"), p0Error("a", "shell")}, + want: map[string][]string{}, + }, + { + name: "sub-agent subjects excluded", + errors: []Signal{ + p0Error("0000000000000000-a", "shell"), + p0Error("0000000000000000-b", "shell"), + p0Error("0000000000000000-c", "shell"), + }, + want: map[string][]string{}, + }, + { + name: "non-error signals ignored", + errors: []Signal{ + {Kind: KindCorrection, SubjectID: "a", SubjectKind: SubjectSession, ToolName: "shell"}, + p0Error("b", "shell"), p0Error("c", "shell"), + }, + want: map[string][]string{}, + }, + { + name: "tools grouped independently", + errors: []Signal{ + p0Error("c", "shell"), p0Error("a", "shell"), p0Error("b", "shell"), + p0Error("b", "reader"), p0Error("a", "reader"), + }, + want: map[string][]string{"shell": {"a", "b", "c"}}, + }, + {name: "empty input returns empty non-nil map", want: map[string][]string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, DetectP0Alerts(tt.errors, p0FixtureSubAgent)) + }) + } +} + +// Four diagnostic subjects never raise P0; the same tool names on session +// subjects do. This checks subject kind rather than a private tool-name list. +func TestWorkerDiagnosticsDoNotRaiseP0(t *testing.T) { + for _, tool := range []string{"status_check", "review_check", "fallback_check", "hook_check", "shell"} { + t.Run(tool, func(t *testing.T) { + diagnostics := []Signal{ + p0Error("diag-a", tool), p0Error("diag-b", tool), + p0Error("diag-c", tool), p0Error("diag-d", tool), + } + for i := range diagnostics { + diagnostics[i].SubjectKind = SubjectDiagnostic + } + assert.Equal(t, map[string][]string{}, DetectP0Alerts(diagnostics, p0FixtureSubAgent)) + + sessions := []Signal{ + p0Error("session-d", tool), p0Error("session-b", tool), + p0Error("session-a", tool), p0Error("session-c", tool), + } + assert.Equal(t, map[string][]string{tool: {"session-a", "session-b", "session-c", "session-d"}}, + DetectP0Alerts(sessions, p0FixtureSubAgent)) + }) + } +} + +func TestDetectP0AlertsMixedSubjectsAndNilPredicate(t *testing.T) { + errors := []Signal{ + p0Error("0000000000000000-a", "shell"), + p0Error("b", "shell"), + p0Error("c", "shell"), + {Kind: KindError, SubjectID: "diag", SubjectKind: SubjectDiagnostic, ToolName: "shell"}, + } + assert.Equal(t, map[string][]string{}, DetectP0Alerts(errors, p0FixtureSubAgent)) + assert.Equal(t, map[string][]string{"shell": {"0000000000000000-a", "b", "c"}}, + DetectP0Alerts(errors, nil)) +} diff --git a/internal/friction/signal.go b/internal/friction/signal.go new file mode 100644 index 0000000000..1dd97defb2 --- /dev/null +++ b/internal/friction/signal.go @@ -0,0 +1,118 @@ +// Package friction ports jilog's session-review detectors (jilog 9e8e094, +// crates/jilog-review, MIT) onto agentsview data. It is pure: no database, +// no network. See docs/internal/jilog-adaptation.md for provenance. +package friction + +import ( + "time" + + "go.kenn.io/agentsview/internal/signals" +) + +// RulesVersion identifies the detector rule set. Bump it whenever a +// detector's output can change for the same input. +const RulesVersion = "friction-v1" + +// Kind is a friction signal kind (jilog signal.rs:60-68). +type Kind string + +const ( + KindCorrection Kind = "correction" + KindError Kind = "error" + KindWorkaround Kind = "workaround" + KindDeferral Kind = "deferral" + KindPattern Kind = "pattern" + // KindFrustration and KindInterruption are native kinds beyond jilog: + // frustration markers and interrupted turns. + KindFrustration Kind = "frustration" + KindInterruption Kind = "interruption" +) + +// Subject kinds. Detectors emit session subjects; producer-written ledger +// events use diagnostic subjects and never raise P0. +const ( + SubjectSession = "session" + SubjectDiagnostic = "diagnostic" +) + +// Detector names stored in friction_findings.detector. +const ( + DetectorCorrectionCoding = "correction.coding" + DetectorCorrectionChat = "correction.chat" + DetectorError = "error" + DetectorWorkaround = "workaround" + DetectorDeferral = "deferral" + DetectorFrustration = "frustration" + DetectorInterruption = "interruption" +) + +// PatternDetector returns the detector name for a pattern kind. +func PatternDetector(patternKind string) string { return "pattern." + patternKind } + +// Pattern kinds. +const ( + PatternRetryLoop = "retry_loop" + PatternRunawayLoop = "runaway_loop" + PatternEditChurn = "edit_churn" + PatternMidTaskCompaction = "mid_task_compaction" + PatternContextPressure = "context_pressure" + PatternIterationRunaway = "iteration_runaway" +) + +// Thresholds ported from jilog detectors.rs:18-28,81 and health.rs:48. +const ( + MinCorrectionLength = 15 + MaxCorrectionLength = 200 + P0DistinctSessionThreshold = 3 + IterationRunawayMinToolCalls = 150 + MaxErrorMessageLength = 500 +) + +// Dims are the per-subject dimensions stamped onto signals by the review. +// Detectors leave them empty. +type Dims struct{ Seat, Agent, Machine, Persona, Channel string } + +// Signal is one friction finding. It flattens jilog's five signal structs. +type Signal struct { + Kind Kind + SubjectID string // session ID, or diagnostic identity + SubjectKind string // SubjectSession or SubjectDiagnostic + Dims Dims + Detector string + Text string // correction context, error message, or pattern description + ToolName string + Label string // workaround pattern, deferral item, or pattern kind + Evidence string + Ordinal *int + CallIndex *int + OccurredAt time.Time + Seq int +} + +// Message is one entry of the detector stream with content flattened to text. +type Message struct { + Ordinal int + Role string // user, assistant, or tool + Text string + ToolName string + CallIndex int + HadToolResult bool + Timestamp time.Time + // NoiseName is the tool name used by the expected-noise allowlist. + // Empty means ToolName. The session adapter sets it to the normalized + // name so tool categories can match jilog's lowercase shapes. + NoiseName string +} + +// PatternInput carries the per-session tool and context facts read by +// pattern detectors. +type PatternInput struct { + Calls []signals.ToolCallRow + CallTimes []time.Time // parallel to Calls + UserOrdinals []int // real user turns reset iteration runaway + CompactBoundaries []int + BoundaryTimes []time.Time + MidTaskCompactions int + PressureMax *float64 + PressureAt time.Time +} diff --git a/internal/friction/title.go b/internal/friction/title.go new file mode 100644 index 0000000000..e35c461a9e --- /dev/null +++ b/internal/friction/title.go @@ -0,0 +1,37 @@ +package friction + +import ( + "crypto/sha256" + "encoding/hex" +) + +const titleContextRunes = 80 + +// Title is the deterministic issue title and fingerprint input. +// Text and labels are limited to 80 runes without an ellipsis. +func (s Signal) Title() string { + switch s.Kind { + case KindCorrection: + return "[friction/correction] " + s.SubjectID + ": " + TruncateRunes(s.Text, titleContextRunes) + case KindError: + return "[friction/error] " + s.ToolName + ": " + TruncateRunes(s.Text, titleContextRunes) + case KindWorkaround: + return "[friction/workaround] " + s.Label + ": " + TruncateRunes(s.Text, titleContextRunes) + case KindPattern: + return "[friction/pattern] " + s.SubjectID + ": " + TruncateRunes(s.Text, titleContextRunes) + case KindDeferral: + return "[friction/deferral] " + s.SubjectID + ": " + TruncateRunes(s.Label, titleContextRunes) + case KindFrustration: + return "[friction/frustration] " + s.SubjectID + ": " + TruncateRunes(s.Text, titleContextRunes) + case KindInterruption: + return "[friction/interruption] " + s.SubjectID + default: + return "[friction/" + string(s.Kind) + "] " + s.SubjectID + } +} + +// Fingerprint is the full SHA-256 hash of the deterministic title. +func (s Signal) Fingerprint() string { + sum := sha256.Sum256([]byte(s.Title())) + return "fl1:" + hex.EncodeToString(sum[:]) +} diff --git a/internal/friction/title_test.go b/internal/friction/title_test.go new file mode 100644 index 0000000000..7ca1fb46ca --- /dev/null +++ b/internal/friction/title_test.go @@ -0,0 +1,68 @@ +package friction + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSignalTitle(t *testing.T) { + long := strings.Repeat("é", 100) + tests := []struct { + name string + sig Signal + want string + }{ + {"correction", Signal{Kind: KindCorrection, SubjectID: "s1", Text: "no, use the other cli"}, "[friction/correction] s1: no, use the other cli"}, + {"correction truncates at 80 runes", Signal{Kind: KindCorrection, SubjectID: "s1", Text: long}, "[friction/correction] s1: " + strings.Repeat("é", 80)}, + {"error excludes session ID", Signal{Kind: KindError, SubjectID: "s1", ToolName: "bash", Text: "cargo test: mismatched types"}, "[friction/error] bash: cargo test: mismatched types"}, + {"diagnostic error keeps supplied identity", Signal{Kind: KindError, SubjectID: "diag-1", SubjectKind: SubjectDiagnostic, ToolName: "ledger_event", Text: "diag-1: 1 row"}, "[friction/error] ledger_event: diag-1: 1 row"}, + {"error truncates message", Signal{Kind: KindError, ToolName: "bash", Text: long}, "[friction/error] bash: " + strings.Repeat("é", 80)}, + {"workaround", Signal{Kind: KindWorkaround, SubjectID: "s1", Label: "TODO", Text: "update the task list"}, "[friction/workaround] TODO: update the task list"}, + {"pattern", Signal{Kind: KindPattern, SubjectID: "s1", Label: "retry_loop", Text: "same call six times"}, "[friction/pattern] s1: same call six times"}, + {"deferral", Signal{Kind: KindDeferral, SubjectID: "s1", Label: "next session"}, "[friction/deferral] s1: next session"}, + {"deferral truncates label", Signal{Kind: KindDeferral, SubjectID: "s1", Label: long}, "[friction/deferral] s1: " + strings.Repeat("é", 80)}, + {"frustration", Signal{Kind: KindFrustration, SubjectID: "s1", Text: "this is broken again"}, "[friction/frustration] s1: this is broken again"}, + {"interruption ignores text", Signal{Kind: KindInterruption, SubjectID: "s1", Text: "[Request interrupted by user]"}, "[friction/interruption] s1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.sig.Title()) + }) + } +} + +func TestInterruptionTitleIsStableAsCountGrows(t *testing.T) { + first := Signal{Kind: KindInterruption, SubjectID: "s1", Ordinal: new(3)} + later := Signal{Kind: KindInterruption, SubjectID: "s1", Ordinal: new(40), Text: "ignored"} + assert.Equal(t, first.Title(), later.Title()) + assert.Equal(t, first.Fingerprint(), later.Fingerprint()) +} + +func TestSignalFingerprint(t *testing.T) { + sig := Signal{Kind: KindDeferral, SubjectID: "s1", Label: "next session"} + sum := sha256.Sum256([]byte("[friction/deferral] s1: next session")) + assert.Equal(t, "fl1:"+hex.EncodeToString(sum[:]), sig.Fingerprint()) + assert.Len(t, sig.Fingerprint(), 4+64) + + // Dimensions and position do not change title identity. + other := sig + other.Dims = Dims{Seat: "seat-02"} + other.Ordinal = new(4) + other.Seq = 3 + assert.Equal(t, sig.Fingerprint(), other.Fingerprint()) + + // Error titles omit session ID, so identical errors share a fingerprint. + a := Signal{Kind: KindError, SubjectID: "a", ToolName: "bash", Text: "boom"} + b := Signal{Kind: KindError, SubjectID: "b", ToolName: "bash", Text: "boom"} + assert.Equal(t, a.Fingerprint(), b.Fingerprint()) + + // The producer's diagnostic identity is part of the message, so separate + // diagnostics remain distinct without a tool-specific title rule. + diagA := Signal{Kind: KindError, SubjectKind: SubjectDiagnostic, ToolName: "ledger_event", Text: "diag-1: 1 row"} + diagB := Signal{Kind: KindError, SubjectKind: SubjectDiagnostic, ToolName: "ledger_event", Text: "diag-2: 1 row"} + assert.NotEqual(t, diagA.Fingerprint(), diagB.Fingerprint()) +} diff --git a/internal/friction/usd.go b/internal/friction/usd.go new file mode 100644 index 0000000000..188cfcd13f --- /dev/null +++ b/internal/friction/usd.go @@ -0,0 +1,111 @@ +package friction + +import ( + "errors" + "fmt" + "math/big" + "strings" +) + +// maxUSDScale and maxUSDCoeff mirror rust_decimal's limits: 28 fractional +// digits and a 96-bit coefficient. +const maxUSDScale = 28 + +var maxUSDCoeff = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 96), big.NewInt(1)) + +// USD is a scale-preserving decimal for jilog's spend calculations. Addition +// keeps the larger scale, and formatting never rounds. +type USD struct { + Coeff *big.Int + Scale uint8 +} + +// ParseUSD parses a plain signed decimal with an optional fractional part. +// Exponents, underscores, and whitespace are not accepted. +func ParseUSD(s string) (USD, error) { + body := s + neg := false + if strings.HasPrefix(body, "-") || strings.HasPrefix(body, "+") { + neg = body[0] == '-' + body = body[1:] + } + intPart, frac, hasDot := strings.Cut(body, ".") + if intPart == "" && frac == "" { + return USD{}, fmt.Errorf("friction: invalid decimal %q", s) + } + if hasDot && strings.Contains(frac, ".") { + return USD{}, fmt.Errorf("friction: invalid decimal %q", s) + } + digits := intPart + frac + for _, r := range digits { + if r < '0' || r > '9' { + return USD{}, fmt.Errorf("friction: invalid decimal %q", s) + } + } + if len(frac) > maxUSDScale { + return USD{}, fmt.Errorf("friction: decimal %q has more than %d fractional digits", s, maxUSDScale) + } + coeff, ok := new(big.Int).SetString(digits, 10) + if !ok { + return USD{}, fmt.Errorf("friction: invalid decimal %q", s) + } + if coeff.Cmp(maxUSDCoeff) > 0 { + return USD{}, errors.New("friction: decimal overflow") + } + if neg { + coeff.Neg(coeff) + } + return USD{Coeff: coeff, Scale: uint8(len(frac))}, nil +} + +// USDFromMicros converts archive microdollars to a scale-6 decimal. +func USDFromMicros(micros int64) USD { + return USD{Coeff: big.NewInt(micros), Scale: 6} +} + +func (u USD) coeff() *big.Int { + if u.Coeff == nil { + return new(big.Int) + } + return u.Coeff +} + +func (u USD) rescale(scale uint8) USD { + if scale <= u.Scale { + return USD{Coeff: new(big.Int).Set(u.coeff()), Scale: u.Scale} + } + mul := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale-u.Scale)), nil) + return USD{Coeff: new(big.Int).Mul(u.coeff(), mul), Scale: scale} +} + +// Add returns u+v at the greater input scale without changing either input. +func (u USD) Add(v USD) USD { + scale := max(u.Scale, v.Scale) + a, b := u.rescale(scale), v.rescale(scale) + return USD{Coeff: a.Coeff.Add(a.Coeff, b.Coeff), Scale: scale} +} + +// String renders the raw decimal, retaining its scale and omitting a zero sign. +func (u USD) String() string { + c := u.coeff() + digits := new(big.Int).Abs(c).String() + if u.Scale > 0 { + if pad := int(u.Scale) + 1 - len(digits); pad > 0 { + digits = strings.Repeat("0", pad) + digits + } + cut := len(digits) - int(u.Scale) + digits = digits[:cut] + "." + digits[cut:] + } + if c.Sign() < 0 { + return "-" + digits + } + return digits +} + +// FormatUSD pads to at least cents, preserves subcent precision, and adds "$". +func FormatUSD(u USD) string { + if u.Scale < 2 { + u = u.rescale(2) + } + return "$" + u.String() +} diff --git a/internal/friction/usd_test.go b/internal/friction/usd_test.go new file mode 100644 index 0000000000..a80dad8f9a --- /dev/null +++ b/internal/friction/usd_test.go @@ -0,0 +1,95 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustUSD(t *testing.T, s string) USD { + t.Helper() + u, err := ParseUSD(s) + require.NoError(t, err) + return u +} + +// Ports format_usd_pads_cents_but_keeps_subcent_precision (jilog +// digest.rs:1522-1527) plus rust_decimal 1.42.1 Display cases. +func TestFormatUSD(t *testing.T) { + tests := []struct { + name string + in string + str string + want string + }{ + {"jilog/4.2", "4.2", "4.2", "$4.20"}, + {"jilog/7", "7", "7", "$7.00"}, + {"jilog/0.0003", "0.0003", "0.0003", "$0.0003"}, + {"jilog/4.20", "4.20", "4.20", "$4.20"}, + {"archive scale 6", "332.138392", "332.138392", "$332.138392"}, + {"negative", "-0.5", "-0.5", "$-0.50"}, + {"plus sign", "+1.5", "1.5", "$1.50"}, + {"leading dot", ".5", "0.5", "$0.50"}, + {"trailing dot", "5.", "5", "$5.00"}, + {"negative zero", "-0", "0", "$0.00"}, + {"negative zero scaled", "-0.00", "0.00", "$0.00"}, + {"leading zeros", "00.5", "0.5", "$0.50"}, + {"keeps trailing zeros", "0.000", "0.000", "$0.000"}, + {"max scale", "0.1234567890123456789012345678", "0.1234567890123456789012345678", "$0.1234567890123456789012345678"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u := mustUSD(t, tt.in) + assert.Equal(t, tt.str, u.String()) + assert.Equal(t, tt.want, FormatUSD(u)) + }) + } +} + +func TestParseUSDRejects(t *testing.T) { + for _, in := range []string{"", "abc", "1.2.3", "1,5", " 1", "1 ", "1e5", "1_000.5", "-", ".", "79228162514264337593543950336", "0.12345678901234567890123456789"} { + t.Run(in, func(t *testing.T) { + _, err := ParseUSD(in) + assert.Error(t, err) + }) + } + u := mustUSD(t, "79228162514264337593543950335") + assert.Equal(t, "79228162514264337593543950335", u.String()) +} + +func TestUSDArithmetic(t *testing.T) { + tests := []struct { + name string + a, b USD + want string + }{ + {"max scale kept", mustUSD(t, "1.5"), mustUSD(t, "2.25"), "3.75"}, + {"trailing zero scale kept", mustUSD(t, "1.50"), mustUSD(t, "1"), "2.50"}, + {"micros stay scale 6", USDFromMicros(1_200_000), USDFromMicros(3_000_000), "4.200000"}, + {"micros plus coarse", USDFromMicros(1), mustUSD(t, "4.2"), "4.200001"}, + {"zero value is zero", USD{}, mustUSD(t, "1.5"), "1.5"}, + {"negative cancellation keeps scale", mustUSD(t, "1.50"), mustUSD(t, "-1.50"), "0.00"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sum := tt.a.Add(tt.b) + assert.Equal(t, tt.want, sum.String()) + }) + } + // The returned sum must not share mutable coefficient storage with either operand. + a := mustUSD(t, "1.5") + b := mustUSD(t, "1.25") + sum := a.Add(b) + assert.Equal(t, "2.75", sum.String()) + assert.Equal(t, "1.5", a.String()) + assert.Equal(t, "1.25", b.String()) + sum.Coeff.SetInt64(0) + assert.Equal(t, "1.5", a.String()) + assert.Equal(t, "1.25", b.String()) + assert.Equal(t, "$1300.250000", FormatUSD(USDFromMicros(1_300_250_000))) + assert.Equal(t, "$0.000001", FormatUSD(USDFromMicros(1))) + assert.Equal(t, "0", USD{}.String()) + assert.Equal(t, "$0.00", FormatUSD(USD{})) + assert.Equal(t, "0", USD{}.Add(USD{}).String()) +} diff --git a/internal/serdejson/decode.go b/internal/serdejson/decode.go new file mode 100644 index 0000000000..395255a2f0 --- /dev/null +++ b/internal/serdejson/decode.go @@ -0,0 +1,83 @@ +package serdejson + +import ( + "bytes" + "encoding/json/jsontext" + "errors" + "fmt" + "io" +) + +// Decode parses one JSON value into the serdejson value model: nil, bool, +// string, Number, []any and map[string]any. Like serde_json::from_slice it +// rejects trailing data, invalid UTF-8, lone surrogate escapes and +// out-of-range numbers; duplicate object keys keep the last value (serde's +// Map insert). +func Decode(data []byte) (any, error) { + dec := jsontext.NewDecoder(bytes.NewReader(data), + jsontext.AllowDuplicateNames(true)) + v, err := decodeValue(dec) + if err != nil { + return nil, fmt.Errorf("serdejson: decode: %w", err) + } + if _, err := dec.ReadToken(); !errors.Is(err, io.EOF) { + return nil, errors.New("serdejson: decode: trailing characters") + } + return v, nil +} + +func decodeValue(dec *jsontext.Decoder) (any, error) { + tok, err := dec.ReadToken() + if err != nil { + return nil, err + } + switch tok.Kind() { + case 'n': + return nil, nil + case 't', 'f': + return tok.Bool(), nil + case '"': + return tok.String(), nil + case '0': + n := Number(tok.String()) + if n.Kind() == NumberF64 { + if _, ok := n.Float64(); !ok { + return nil, fmt.Errorf("serdejson: number out of range: %q", string(n)) + } + } + return n, nil + case '[': + arr := []any{} + for dec.PeekKind() != ']' { + v, err := decodeValue(dec) + if err != nil { + return nil, err + } + arr = append(arr, v) + } + if _, err := dec.ReadToken(); err != nil { + return nil, err + } + return arr, nil + case '{': + obj := map[string]any{} + for dec.PeekKind() != '}' { + keyTok, err := dec.ReadToken() + if err != nil { + return nil, err + } + key := keyTok.String() // copy before the next read voids the token + v, err := decodeValue(dec) + if err != nil { + return nil, err + } + obj[key] = v + } + if _, err := dec.ReadToken(); err != nil { + return nil, err + } + return obj, nil + default: + return nil, fmt.Errorf("unexpected token %v", tok.Kind()) + } +} diff --git a/internal/serdejson/decode_test.go b/internal/serdejson/decode_test.go new file mode 100644 index 0000000000..179893c45b --- /dev/null +++ b/internal/serdejson/decode_test.go @@ -0,0 +1,44 @@ +package serdejson + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecode(t *testing.T) { + tests := []struct { + name string + in string + want any + wantErr bool + }{ + {"object with numbers", `{"a":1,"b":[true,null,"x"]}`, map[string]any{"a": Number("1"), "b": []any{true, nil, "x"}}, false}, + {"duplicate key keeps last", `{"a":1,"a":2}`, map[string]any{"a": Number("2")}, false}, + {"u64 above 2^53 keeps literal", `18446744073709551615`, Number("18446744073709551615"), false}, + {"literal unicode", `"😀"`, "\U0001F600", false}, + {"escaped surrogate pair", `"\ud83d\ude00"`, "\U0001F600", false}, + {"trailing value", `1 2`, nil, true}, + {"trailing garbage", `{} x`, nil, true}, + {"trailing comma", `[1,]`, nil, true}, + {"leading zero", `01`, nil, true}, + {"bare dot", `1.`, nil, true}, + {"NaN", `NaN`, nil, true}, + {"lone surrogate", `"\ud800"`, nil, true}, + {"invalid utf8", "\"\xff\"", nil, true}, + {"float out of range", `1E400`, nil, true}, + {"empty", ``, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Decode([]byte(tt.in)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/serdejson/encode.go b/internal/serdejson/encode.go new file mode 100644 index 0000000000..44ceee050f --- /dev/null +++ b/internal/serdejson/encode.go @@ -0,0 +1,266 @@ +package serdejson + +import ( + "bytes" + "fmt" + "reflect" + "slices" + "strconv" + "strings" + "unicode/utf8" +) + +// Compact serializes v like serde_json::to_vec: no whitespace, map keys +// sorted byte-wise (BTreeMap order), struct fields in declaration order, +// and no escaping of '<', '>', '&', U+2028 or U+2029. +// +// Supported values: nil, bool, string, Number, Go integer and float +// kinds, slices and arrays, string-keyed maps, pointers and structs. +// Struct fields use the name in a `json:"name"` tag (else the Go field +// name); `json:"-"` skips a field and `,omitempty` omits it only when it +// is a nil pointer, interface, slice or map, matching Option::is_none. +func Compact(v any) ([]byte, error) { + var b bytes.Buffer + if err := encode(&b, reflect.ValueOf(v), "", ""); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// CompactString returns Compact as a string. Unsupported Go types cause +// a panic; strings and numbers produced by Decode do not. +func CompactString(v any) string { + b, err := Compact(v) + if err != nil { + panic(err) + } + return string(b) +} + +// Pretty serializes v like serde_json::to_string_pretty: two-space +// indentation, a space after each object colon and no trailing newline. +func Pretty(v any) ([]byte, error) { + var b bytes.Buffer + if err := encode(&b, reflect.ValueOf(v), "\n", " "); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +var numberType = reflect.TypeFor[Number]() + +func encode(b *bytes.Buffer, v reflect.Value, nl, indent string) error { + return encodeAt(b, v, nl, indent, 0) +} + +func encodeAt(b *bytes.Buffer, v reflect.Value, nl, indent string, depth int) error { + if !v.IsValid() { + b.WriteString("null") + return nil + } + if v.Type() == numberType { + s, err := Number(v.String()).canonical() + if err != nil { + return err + } + b.WriteString(s) + return nil + } + switch v.Kind() { + case reflect.Interface, reflect.Pointer: + if v.IsNil() { + b.WriteString("null") + return nil + } + return encodeAt(b, v.Elem(), nl, indent, depth) + case reflect.Bool: + b.WriteString(strconv.FormatBool(v.Bool())) + case reflect.String: + writeString(b, v.String()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + b.WriteString(strconv.FormatInt(v.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + b.WriteString(strconv.FormatUint(v.Uint(), 10)) + case reflect.Float32: + // First round to the shortest f32 digits, then apply serde's + // f64 notation thresholds to those digits. Converting directly + // to f64 would expose the f32 representation's extra digits. + shortest := strconv.FormatFloat(v.Float(), 'g', -1, 32) + f, err := strconv.ParseFloat(shortest, 64) + if err != nil { + return fmt.Errorf("serdejson: format float32: %w", err) + } + b.WriteString(formatFloat(f)) + case reflect.Float64: + b.WriteString(formatFloat(v.Float())) + case reflect.Slice, reflect.Array: + return encodeSeq(b, v, nl, indent, depth) + case reflect.Map: + if v.Type().Key().Kind() != reflect.String { + return fmt.Errorf("serdejson: unsupported map key type %s", v.Type().Key()) + } + return encodeMap(b, v, nl, indent, depth) + case reflect.Struct: + return encodeStruct(b, v, nl, indent, depth) + default: + return fmt.Errorf("serdejson: unsupported type %s", v.Type()) + } + return nil +} + +func newline(b *bytes.Buffer, nl, indent string, depth int) { + if nl == "" { + return + } + b.WriteString(nl) + b.WriteString(strings.Repeat(indent, depth)) +} + +func encodeSeq(b *bytes.Buffer, v reflect.Value, nl, indent string, depth int) error { + if v.Len() == 0 { + b.WriteString("[]") + return nil + } + b.WriteByte('[') + for i := range v.Len() { + if i > 0 { + b.WriteByte(',') + } + newline(b, nl, indent, depth+1) + if err := encodeAt(b, v.Index(i), nl, indent, depth+1); err != nil { + return err + } + } + newline(b, nl, indent, depth) + b.WriteByte(']') + return nil +} + +func encodeMap(b *bytes.Buffer, v reflect.Value, nl, indent string, depth int) error { + if v.Len() == 0 { + b.WriteString("{}") + return nil + } + keys := v.MapKeys() + slices.SortFunc(keys, func(a, c reflect.Value) int { + return strings.Compare(a.String(), c.String()) + }) + b.WriteByte('{') + for i, key := range keys { + if i > 0 { + b.WriteByte(',') + } + newline(b, nl, indent, depth+1) + writeString(b, key.String()) + b.WriteByte(':') + if nl != "" { + b.WriteByte(' ') + } + if err := encodeAt(b, v.MapIndex(key), nl, indent, depth+1); err != nil { + return err + } + } + newline(b, nl, indent, depth) + b.WriteByte('}') + return nil +} + +func encodeStruct(b *bytes.Buffer, v reflect.Value, nl, indent string, depth int) error { + t := v.Type() + wrote := false + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + name, opts, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + value := v.Field(i) + if hasOption(opts, "omitempty") && isNilish(value) { + continue + } + if wrote { + b.WriteByte(',') + } else { + b.WriteByte('{') + } + wrote = true + newline(b, nl, indent, depth+1) + writeString(b, name) + b.WriteByte(':') + if nl != "" { + b.WriteByte(' ') + } + if err := encodeAt(b, value, nl, indent, depth+1); err != nil { + return err + } + } + if !wrote { + b.WriteString("{}") + return nil + } + newline(b, nl, indent, depth) + b.WriteByte('}') + return nil +} + +func hasOption(opts, want string) bool { + for opt := range strings.SplitSeq(opts, ",") { + if opt == want { + return true + } + } + return false +} + +func isNilish(v reflect.Value) bool { + switch v.Kind() { + case reflect.Pointer, reflect.Interface, reflect.Slice, reflect.Map: + return v.IsNil() + default: + return false + } +} + +const hexDigits = "0123456789abcdef" + +// writeString ports serde_json's format_escaped_str: only a quotation +// mark, backslash and bytes below 0x20 are escaped. Invalid UTF-8, +// impossible in Rust strings, is replaced with U+FFFD. +func writeString(b *bytes.Buffer, s string) { + b.WriteByte('"') + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == utf8.RuneError && size == 1: + b.WriteRune(utf8.RuneError) + case r == '"': + b.WriteString(`\"`) + case r == '\\': + b.WriteString(`\\`) + case r == '\b': + b.WriteString(`\b`) + case r == '\f': + b.WriteString(`\f`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\t': + b.WriteString(`\t`) + case r < 0x20: + b.WriteString(`\u00`) + b.WriteByte(hexDigits[r>>4]) + b.WriteByte(hexDigits[r&0xf]) + default: + b.WriteString(s[i : i+size]) + } + i += size + } + b.WriteByte('"') +} diff --git a/internal/serdejson/encode_test.go b/internal/serdejson/encode_test.go new file mode 100644 index 0000000000..831832d389 --- /dev/null +++ b/internal/serdejson/encode_test.go @@ -0,0 +1,118 @@ +package serdejson + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Expected bytes were captured from serde_json 1.0.149 Value serialization. +func TestCompactRoundTrip(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "sorted keys and unescaped HTML and separators", + in: `{"b":1,"a":"<>&

é"}`, + want: "{\"a\":\"<>&

é\",\"b\":1}", + }, + { + name: "control escapes and DEL", + in: `"\u0001\u001f\u007f\"\\/\b\f\n\r\t"`, + want: "\"\\u0001\\u001f\u007f\\\"\\\\/\\b\\f\\n\\r\\t\"", + }, + { + name: "nested map sort", + in: `{"z":{"y":1,"x":[true,false,null]}}`, + want: `{"z":{"x":[true,false,null],"y":1}}`, + }, + { + name: "empty containers", + in: `{"k":" <&>","e":[],"o":{},"n":[{"a":{}}]}`, + want: `{"e":[],"k":" <&>","n":[{"a":{}}],"o":{}}`, + }, + { + name: "large exact integers", + in: `[18446744073709551615,9007199254740993]`, + want: `[18446744073709551615,9007199254740993]`, + }, + { + name: "float zero and integer fraction", + in: `[1.0,17.0,-0,1e5]`, + want: `[1.0,17.0,-0.0,100000.0]`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, err := Decode([]byte(tt.in)) + require.NoError(t, err) + got, err := Compact(v) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + assert.Equal(t, tt.want, CompactString(v)) + }) + } +} + +func TestPretty(t *testing.T) { + v, err := Decode([]byte(`{"k":" <&>","e":[],"o":{},"n":[{"a":{}}]}`)) + require.NoError(t, err) + got, err := Pretty(v) + require.NoError(t, err) + assert.Equal(t, "{\n \"e\": [],\n \"k\": \" <&>\",\n \"n\": [\n {\n \"a\": {}\n }\n ],\n \"o\": {}\n}", string(got)) +} + +func TestCompactGoValues(t *testing.T) { + type inner struct { + Z string `json:"z"` + A *int `json:"a,omitempty"` + } + type outer struct { + Second int `json:"second"` + First *string `json:"first"` + Skip string `json:"-"` + In inner `json:"in"` + } + type optional struct { + Zero int `json:"zero,omitempty"` + Empty string `json:"empty,omitempty"` + List []string `json:"list,omitempty"` + Lookup map[string]int `json:"lookup,omitempty"` + Nil *int `json:"nil,omitempty"` + } + tests := []struct { + name string + in any + want string + }{ + {"nil", nil, `null`}, + {"struct field order and null", outer{Second: 2, Skip: "x", In: inner{Z: "<"}}, `{"second":2,"first":null,"in":{"z":"<"}}`}, + {"omitempty skips only nil", optional{List: []string{}, Lookup: map[string]int{}}, `{"zero":0,"empty":"","list":[],"lookup":{}}`}, + {"go floats and integers", []any{1.0, 0.5, uint64(18446744073709551615), int64(-3)}, `[1.0,0.5,18446744073709551615,-3]`}, + {"float32 uses its own shortest digits", float32(0.1), `0.1`}, + {"float32 lower plain threshold", float32(1e-5), `0.00001`}, + {"float32 lower exponent threshold", float32(1e-6), `1e-6`}, + {"float32 upper plain threshold", float32(1e15), `1000000000000000.0`}, + {"float32 upper exponent threshold", float32(1e16), `1e+16`}, + {"float32 negative zero", float32(math.Copysign(0, -1)), `-0.0`}, + {"float32 NaN", float32(math.NaN()), `null`}, + {"float32 positive infinity", float32(math.Inf(1)), `null`}, + {"float32 negative infinity", float32(math.Inf(-1)), `null`}, + {"invalid UTF-8 becomes replacement", "a\xffb", "\"a�b\""}, + {"string map keys sort", map[string]string{"b": "1", "a": "2"}, `{"a":"2","b":"1"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Compact(tt.in) + require.NoError(t, err) + assert.Equal(t, tt.want, string(got)) + }) + } + _, err := Compact(map[int]string{1: "x"}) + require.Error(t, err) + assert.Panics(t, func() { CompactString(make(chan int)) }) +} diff --git a/internal/serdejson/number.go b/internal/serdejson/number.go new file mode 100644 index 0000000000..2ab449caa3 --- /dev/null +++ b/internal/serdejson/number.go @@ -0,0 +1,195 @@ +// Package serdejson reproduces the byte output of Rust's serde_json 1.x +// (as pinned by jilog 9e8e094: serde_json 1.0.149 with zmij float +// formatting, no preserve_order, no arbitrary_precision) so that +// ported digests, fingerprints and ledger checksums match jilog. +package serdejson + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// Number is a JSON number literal. It is classified the way serde_json's +// parser classifies it: u64, then i64, otherwise f64. +type Number string + +// NumberKind is serde_json's internal number representation. +type NumberKind int + +const ( + // NumberU64 is a non-negative integer that fits in uint64. + NumberU64 NumberKind = iota + // NumberI64 is a negative integer that fits in int64. + NumberI64 + // NumberF64 is everything else: fractions, exponents, "-0", + // and integers outside the u64/i64 ranges. + NumberF64 +) + +// Kind classifies n the way serde_json's parse_number/parse_integer do +// (serde_json-1.0.149/src/de.rs). "-0" is F64 because serde_json stores +// a negated zero significand as F64(-0.0). +func (n Number) Kind() NumberKind { + s := string(n) + if strings.ContainsAny(s, ".eE") { + return NumberF64 + } + if strings.HasPrefix(s, "-") { + if strings.TrimLeft(s[1:], "0") == "" { + return NumberF64 + } + if _, err := strconv.ParseInt(s, 10, 64); err == nil { + return NumberI64 + } + return NumberF64 + } + if _, err := strconv.ParseUint(s, 10, 64); err == nil { + return NumberU64 + } + return NumberF64 +} + +// Int64 mirrors serde_json's Value::as_i64: Some for an i64, or for a +// u64 that fits in i64; None for f64. +func (n Number) Int64() (int64, bool) { + switch n.Kind() { + case NumberU64: + u, _ := strconv.ParseUint(string(n), 10, 64) + if u > math.MaxInt64 { + return 0, false + } + return int64(u), true + case NumberI64: + i, _ := strconv.ParseInt(string(n), 10, 64) + return i, true + default: + return 0, false + } +} + +// Uint64 mirrors serde_json's Value::as_u64. +func (n Number) Uint64() (uint64, bool) { + if n.Kind() != NumberU64 { + return 0, false + } + u, _ := strconv.ParseUint(string(n), 10, 64) + return u, true +} + +// Float64 returns the literal as a float64 when it is finite and in range. +func (n Number) Float64() (float64, bool) { + if !validNumberLiteral(string(n)) { + return 0, false + } + f, err := strconv.ParseFloat(string(n), 64) + if err != nil || math.IsInf(f, 0) { + return 0, false + } + return f, true +} + +// canonical renders the number as serde_json would re-serialize it. +func (n Number) canonical() (string, error) { + if !validNumberLiteral(string(n)) { + return "", fmt.Errorf("serdejson: invalid number literal %q", string(n)) + } + switch n.Kind() { + case NumberU64, NumberI64: + return string(n), nil + default: + f, ok := n.Float64() + if !ok { + return "", fmt.Errorf("serdejson: number out of range: %q", string(n)) + } + return formatFloat(f), nil + } +} + +// validNumberLiteral applies the JSON number grammar (RFC 8259 §6). +func validNumberLiteral(s string) bool { + i := 0 + if i < len(s) && s[i] == '-' { + i++ + } + switch { + case i < len(s) && s[i] == '0': + i++ + case i < len(s) && s[i] >= '1' && s[i] <= '9': + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + default: + return false + } + if i < len(s) && s[i] == '.' { + i++ + start := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == start { + return false + } + } + if i < len(s) && (s[i] == 'e' || s[i] == 'E') { + i++ + if i < len(s) && (s[i] == '+' || s[i] == '-') { + i++ + } + start := i + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == start { + return false + } + } + return i == len(s) +} + +// formatFloat ports zmij 1.0.21 `write` (src/lib.rs:1010-1130), which +// serde_json 1.0.149 uses for f64 (src/ser.rs:1716-1723). Shortest +// round-trip digits; plain notation when the decimal exponent is in +// [-5, 15], otherwise d.ddde±X with no exponent padding. Non-finite +// values serialize as null (serde_json ser.rs serialize_f64). +func formatFloat(f float64) string { + if math.IsNaN(f) || math.IsInf(f, 0) { + return "null" + } + if f == 0 { + if math.Signbit(f) { + return "-0.0" + } + return "0.0" + } + sign := "" + if f < 0 { + sign = "-" + f = -f + } + sci := strconv.FormatFloat(f, 'e', -1, 64) // d.ddde±XX + mant, expStr, _ := strings.Cut(sci, "e") + digits := strings.Replace(mant, ".", "", 1) + exp, _ := strconv.Atoi(expStr) + n := len(digits) + if exp >= -5 && exp <= 15 { + switch { + case n-1 <= exp: + return sign + digits + strings.Repeat("0", exp-(n-1)) + ".0" + case exp >= 0: + return sign + digits[:exp+1] + "." + digits[exp+1:] + default: + return sign + "0." + strings.Repeat("0", -exp-1) + digits + } + } + out := sign + digits[:1] + if n > 1 { + out += "." + digits[1:] + } + if exp >= 0 { + return out + "e+" + strconv.Itoa(exp) + } + return out + "e-" + strconv.Itoa(-exp) +} diff --git a/internal/serdejson/number_test.go b/internal/serdejson/number_test.go new file mode 100644 index 0000000000..6f2f96bad8 --- /dev/null +++ b/internal/serdejson/number_test.go @@ -0,0 +1,110 @@ +package serdejson + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNumberKind(t *testing.T) { + tests := []struct { + name string + literal Number + want NumberKind + i64 int64 + i64OK bool + }{ + {"zero", "0", NumberU64, 0, true}, + {"u64 max", "18446744073709551615", NumberU64, 0, false}, + {"u64 overflow is f64", "18446744073709551616", NumberF64, 0, false}, + {"above 2^53 stays integer", "9007199254740993", NumberU64, 9007199254740993, true}, + {"negative", "-5", NumberI64, -5, true}, + {"i64 min", "-9223372036854775808", NumberI64, -9223372036854775808, true}, + {"below i64 min is f64", "-9223372036854775809", NumberF64, 0, false}, + {"negative zero is f64", "-0", NumberF64, 0, false}, + {"fraction", "1.0", NumberF64, 0, false}, + {"exponent", "1e5", NumberF64, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.literal.Kind()) + got, ok := tt.literal.Int64() + assert.Equal(t, tt.i64OK, ok) + assert.Equal(t, tt.i64, got) + }) + } +} + +// Expected strings were captured from serde_json 1.0.149 (zmij 1.0.x) +// Value::to_string on the same literals. +func TestFormatFloat(t *testing.T) { + tests := []struct { + literal Number + want string + }{ + {"1.0", "1.0"}, + {"1.5", "1.5"}, + {"-0", "-0.0"}, + {"-0.0", "-0.0"}, + {"0.1", "0.1"}, + {"0.3", "0.3"}, + {"1e16", "1e+16"}, + {"1e15", "1000000000000000.0"}, + {"1e-5", "0.00001"}, + {"1e-6", "1e-6"}, + {"1e-7", "1e-7"}, + {"1.0e2", "100.0"}, + {"17.0", "17.0"}, + {"3.14159", "3.14159"}, + {"4.35", "4.35"}, + {"5e-324", "5e-324"}, + {"100000000000000000000.0", "1e+20"}, + {"0.000001234", "1.234e-6"}, + {"123e-20", "1.23e-18"}, + {"1e22", "1e+22"}, + {"1.5e300", "1.5e+300"}, + {"1e100", "1e+100"}, + {"18446744073709551616", "1.8446744073709552e+19"}, + {"-9223372036854775809", "-9.223372036854776e+18"}, + {"123456789012345678901234567890", "1.2345678901234568e+29"}, + {"1.7976931348623157e308", "1.7976931348623157e+308"}, + {"2.2250738585072014e-308", "2.2250738585072014e-308"}, + } + for _, tt := range tests { + t.Run(string(tt.literal), func(t *testing.T) { + got, err := tt.literal.canonical() + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNumberAccessorsRejectInvalidLiterals(t *testing.T) { + for _, literal := range []Number{"01", "1.", "NaN", "1E400"} { + t.Run(string(literal), func(t *testing.T) { + _, ok := literal.Float64() + assert.False(t, ok) + _, err := literal.canonical() + require.Error(t, err) + }) + } + for _, tt := range []struct { + literal Number + want uint64 + ok bool + }{ + {"0", 0, true}, + {"18446744073709551615", 18446744073709551615, true}, + {"-1", 0, false}, + {"-0", 0, false}, + {"1.0", 0, false}, + {"18446744073709551616", 0, false}, + } { + t.Run("uint64/"+string(tt.literal), func(t *testing.T) { + got, ok := tt.literal.Uint64() + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/serdejson/timestamp.go b/internal/serdejson/timestamp.go new file mode 100644 index 0000000000..837149559d --- /dev/null +++ b/internal/serdejson/timestamp.go @@ -0,0 +1,25 @@ +package serdejson + +import ( + "fmt" + "time" +) + +// FormatTimestamp renders t in UTC like chrono's +// to_rfc3339_opts(SecondsFormat::AutoSi, true): no fraction when the +// nanoseconds are zero, otherwise 3, 6 or 9 digits, whichever is exact. +func FormatTimestamp(t time.Time) string { + t = t.UTC() + base := t.Format("2006-01-02T15:04:05") + ns := t.Nanosecond() + switch { + case ns == 0: + return base + "Z" + case ns%1_000_000 == 0: + return fmt.Sprintf("%s.%03dZ", base, ns/1_000_000) + case ns%1_000 == 0: + return fmt.Sprintf("%s.%06dZ", base, ns/1_000) + default: + return fmt.Sprintf("%s.%09dZ", base, ns) + } +} diff --git a/internal/serdejson/timestamp_test.go b/internal/serdejson/timestamp_test.go new file mode 100644 index 0000000000..9dbc204c8d --- /dev/null +++ b/internal/serdejson/timestamp_test.go @@ -0,0 +1,31 @@ +package serdejson + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// Expected strings are chrono 0.4 to_rfc3339_opts(SecondsFormat::AutoSi, true). +func TestFormatTimestamp(t *testing.T) { + base := time.Date(2026, 9, 22, 1, 2, 3, 0, time.UTC) + tests := []struct { + name string + ns int + want string + }{ + {"whole second", 0, "2026-09-22T01:02:03Z"}, + {"millis", 5_000_000, "2026-09-22T01:02:03.005Z"}, + {"micros", 120_000, "2026-09-22T01:02:03.000120Z"}, + {"nanos", 123_456_789, "2026-09-22T01:02:03.123456789Z"}, + {"small nanos", 100, "2026-09-22T01:02:03.000000100Z"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := base.Add(time.Duration(tt.ns)) + assert.Equal(t, tt.want, FormatTimestamp(ts)) + assert.Equal(t, tt.want, FormatTimestamp(ts.In(time.FixedZone("x", 9*3600)))) + }) + } +} From 3b5e627bd85a24b7d52599625f66dbf7fa0a30db Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:17:02 +0000 Subject: [PATCH 02/62] feat(signals): expose retry runs as spans --- internal/signals/spans_test.go | 102 +++++++++++++++++++++++++++++++++ internal/signals/toolhealth.go | 61 ++++++++++++++------ 2 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 internal/signals/spans_test.go diff --git a/internal/signals/spans_test.go b/internal/signals/spans_test.go new file mode 100644 index 0000000000..69a9db2bf7 --- /dev/null +++ b/internal/signals/spans_test.go @@ -0,0 +1,102 @@ +package signals + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func bashLs(ord, idx int) ToolCallRow { + return ToolCallRow{ + ToolName: "Bash", InputJSON: `{"cmd":"ls"}`, + MessageOrdinal: ord, CallIndex: idx, + } +} + +func TestRetryRuns(t *testing.T) { + tests := []struct { + name string + calls []ToolCallRow + want []ToolRun + }{ + {"empty", nil, nil}, + {"two identical is not a run", []ToolCallRow{bashLs(1, 0), bashLs(3, 0)}, nil}, + { + "three identical across messages", + []ToolCallRow{bashLs(1, 0), bashLs(3, 0), bashLs(5, 1)}, + []ToolRun{{ + ToolName: "Bash", Count: 3, + First: CallPos{MessageOrdinal: 1, CallIndex: 0}, + Last: CallPos{MessageOrdinal: 5, CallIndex: 1}, + }}, + }, + { + "two separate runs", + []ToolCallRow{ + bashLs(1, 0), bashLs(2, 0), bashLs(3, 0), + {ToolName: "Read", InputJSON: `{"f":"a"}`, MessageOrdinal: 4}, + {ToolName: "Edit", InputJSON: `{"f":"b"}`, MessageOrdinal: 5}, + {ToolName: "Edit", InputJSON: `{"f":"b"}`, MessageOrdinal: 6}, + {ToolName: "Edit", InputJSON: `{"f":"b"}`, MessageOrdinal: 7}, + {ToolName: "Edit", InputJSON: `{"f":"b"}`, MessageOrdinal: 8}, + }, + []ToolRun{ + {ToolName: "Bash", Count: 3, First: CallPos{MessageOrdinal: 1}, Last: CallPos{MessageOrdinal: 3}}, + {ToolName: "Edit", Count: 4, First: CallPos{MessageOrdinal: 5}, Last: CallPos{MessageOrdinal: 8}}, + }, + }, + { + "different input breaks run", + []ToolCallRow{ + bashLs(1, 0), bashLs(2, 0), + {ToolName: "Bash", InputJSON: `{"cmd":"pwd"}`, MessageOrdinal: 3}, + }, + nil, + }, + { + "run at end of slice", + []ToolCallRow{ + {ToolName: "Read", InputJSON: `{}`, MessageOrdinal: 0}, + bashLs(1, 0), bashLs(2, 0), bashLs(3, 0), bashLs(4, 0), + }, + []ToolRun{{ToolName: "Bash", Count: 4, First: CallPos{MessageOrdinal: 1}, Last: CallPos{MessageOrdinal: 4}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, RetryRuns(tt.calls)) + }) + } +} + +// TestRetryRunsSumEqualsCountRetries pins spec §26.3: +// countRetries == Σ(Count-1) over the TestRetryCount fixtures +// (toolhealth_test.go:237-310), reproduced here unchanged. +func TestRetryRunsSumEqualsCountRetries(t *testing.T) { + ls := ToolCallRow{ToolName: "Bash", InputJSON: `{"cmd":"ls"}`} + pwd := ToolCallRow{ToolName: "Bash", InputJSON: `{"cmd":"pwd"}`} + readLs := ToolCallRow{ToolName: "Read", InputJSON: `{"cmd":"ls"}`} + readA := ToolCallRow{ToolName: "Read", InputJSON: `{"f":"a"}`} + editB := ToolCallRow{ToolName: "Edit", InputJSON: `{"f":"b"}`} + tests := []struct { + name string + calls []ToolCallRow + want int + }{ + {"2 identical not retry", []ToolCallRow{ls, ls}, 0}, + {"3 identical = 2 retries", []ToolCallRow{ls, ls, ls}, 2}, + {"5 identical = 4 retries", []ToolCallRow{ls, ls, ls, ls, ls}, 4}, + {"different tool breaks streak", []ToolCallRow{ls, ls, readLs, ls}, 0}, + {"different input breaks streak", []ToolCallRow{ls, ls, pwd}, 0}, + {"two groups", []ToolCallRow{ls, ls, ls, readA, editB, editB, editB, editB}, 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sum := 0 + for _, r := range RetryRuns(tt.calls) { + sum += r.Count - 1 + } + assert.Equal(t, tt.want, sum) + }) + } +} diff --git a/internal/signals/toolhealth.go b/internal/signals/toolhealth.go index 76abd9b41a..2f48fc7760 100644 --- a/internal/signals/toolhealth.go +++ b/internal/signals/toolhealth.go @@ -167,34 +167,57 @@ func countFailures( return failures, maxStreak } -// countRetries counts retried calls using a sliding window. -// 3+ consecutive calls with same ToolName AND identical InputJSON -// = (count - 1) retries per group. -func countRetries(calls []ToolCallRow) int { - if len(calls) < 3 { - return 0 - } +// retryRunMinLen is the shortest run of identical consecutive calls +// that counts as retrying. +const retryRunMinLen = 3 - total := 0 - runLen := 1 +// ToolRun is one maximal run of consecutive calls with the same +// ToolName and byte-identical InputJSON, at least retryRunMinLen +// long. First and Last are the run's outermost calls. +type ToolRun struct { + ToolName string + Count int + First, Last CallPos +} - for i := 1; i < len(calls); i++ { - if calls[i].ToolName == calls[i-1].ToolName && +// RetryRuns returns every maximal retry run in call order. It is the +// per-occurrence form of countRetries: countRetries == Σ(Count-1). +func RetryRuns(calls []ToolCallRow) []ToolRun { + var runs []ToolRun + start := 0 + for i := 1; i <= len(calls); i++ { + if i < len(calls) && + calls[i].ToolName == calls[i-1].ToolName && calls[i].InputJSON == calls[i-1].InputJSON { - runLen++ - } else { - if runLen >= 3 { - total += runLen - 1 - } - runLen = 1 + continue } + if n := i - start; n >= retryRunMinLen { + runs = append(runs, ToolRun{ + ToolName: calls[start].ToolName, + Count: n, + First: toolCallPos(calls[start]), + Last: toolCallPos(calls[i-1]), + }) + } + start = i } - if runLen >= 3 { - total += runLen - 1 + return runs +} + +// countRetries counts retried calls: each run of 3+ consecutive calls +// with the same ToolName AND identical InputJSON adds (count - 1). +func countRetries(calls []ToolCallRow) int { + total := 0 + for _, r := range RetryRuns(calls) { + total += r.Count - 1 } return total } +func toolCallPos(c ToolCallRow) CallPos { + return CallPos{MessageOrdinal: c.MessageOrdinal, CallIndex: c.CallIndex} +} + // countEditChurn counts churn events for Edit/Write calls. // One churn event = 3+ edits to the same file within a 10-ordinal // span. From 53890c0dce9942964f43827caf3e7ad105bb77a6 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:23:33 +0000 Subject: [PATCH 03/62] feat(signals): expose edit churn files as spans --- internal/signals/spans_test.go | 89 +++++++++++++++++++++++++++++++ internal/signals/toolhealth.go | 95 +++++++++++++++++++++++++++++----- 2 files changed, 171 insertions(+), 13 deletions(-) diff --git a/internal/signals/spans_test.go b/internal/signals/spans_test.go index 69a9db2bf7..b25d31542c 100644 --- a/internal/signals/spans_test.go +++ b/internal/signals/spans_test.go @@ -1,9 +1,11 @@ package signals import ( + "math/rand/v2" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func bashLs(ord, idx int) ToolCallRow { @@ -100,3 +102,90 @@ func TestRetryRunsSumEqualsCountRetries(t *testing.T) { }) } } + +func edit(path string, ord, idx int) ToolCallRow { + return ToolCallRow{ + ToolName: "Edit", Category: "Edit", + InputJSON: `{"file_path":"` + path + `","old_string":"x"}`, + MessageOrdinal: ord, CallIndex: idx, + } +} + +func TestEditChurnFiles(t *testing.T) { + tests := []struct { + name string + calls []ToolCallRow + want []EditChurn + }{ + {"no edits", []ToolCallRow{bashLs(1, 0)}, nil}, + {"two edits no churn", []ToolCallRow{edit("a.go", 1, 0), edit("a.go", 5, 0)}, nil}, + { + "three edits within span", + []ToolCallRow{edit("a.go", 1, 0), edit("a.go", 5, 0), edit("a.go", 9, 0)}, + []EditChurn{{FilePath: "a.go", Count: 3, First: CallPos{MessageOrdinal: 1}, Last: CallPos{MessageOrdinal: 9}}}, + }, + { + "span of exactly ten is not churn", + []ToolCallRow{edit("a.go", 1, 0), edit("a.go", 5, 0), edit("a.go", 11, 0)}, + nil, + }, + { + "cluster extends while span stays under ten", + []ToolCallRow{ + edit("a.go", 1, 0), edit("a.go", 2, 0), edit("a.go", 3, 0), + edit("a.go", 9, 1), edit("a.go", 30, 0), + }, + []EditChurn{{FilePath: "a.go", Count: 4, First: CallPos{MessageOrdinal: 1}, Last: CallPos{MessageOrdinal: 9, CallIndex: 1}}}, + }, + { + "later cluster found when first edits are spread", + []ToolCallRow{ + edit("a.go", 1, 0), edit("a.go", 40, 0), + edit("a.go", 41, 0), edit("a.go", 42, 0), + }, + []EditChurn{{FilePath: "a.go", Count: 3, First: CallPos{MessageOrdinal: 40}, Last: CallPos{MessageOrdinal: 42}}}, + }, + { + "files reported in first-edit order", + []ToolCallRow{ + edit("b.go", 1, 0), edit("a.go", 2, 0), + edit("b.go", 3, 0), edit("a.go", 4, 0), + edit("b.go", 5, 0), edit("a.go", 6, 0), + }, + []EditChurn{ + {FilePath: "b.go", Count: 3, First: CallPos{MessageOrdinal: 1}, Last: CallPos{MessageOrdinal: 5}}, + {FilePath: "a.go", Count: 3, First: CallPos{MessageOrdinal: 2}, Last: CallPos{MessageOrdinal: 6}}, + }, + }, + { + "write category counts, read does not", + []ToolCallRow{ + {ToolName: "Write", Category: "Write", InputJSON: `{"file_path":"w.go"}`, MessageOrdinal: 1}, + {ToolName: "Read", Category: "Read", InputJSON: `{"file_path":"w.go"}`, MessageOrdinal: 2}, + {ToolName: "Write", Category: "Write", InputJSON: `{"file_path":"w.go"}`, MessageOrdinal: 3}, + {ToolName: "Write", Category: "Write", InputJSON: `{"file_path":"w.go"}`, MessageOrdinal: 4}, + }, + []EditChurn{{FilePath: "w.go", Count: 3, First: CallPos{MessageOrdinal: 1}, Last: CallPos{MessageOrdinal: 4}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, EditChurnFiles(tt.calls)) + }) + } +} + +// TestChurnClusterAgreesWithHasChurnWindow checks the span finder against the +// unchanged predicate over random ordinal sequences, including unsorted ones. +func TestChurnClusterAgreesWithHasChurnWindow(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 2)) + for i := range 2000 { + n := rng.IntN(8) + ords := make([]int, n) + for j := range ords { + ords[j] = rng.IntN(40) + } + _, _, ok := churnCluster(ords, 3, 10) + require.Equal(t, hasChurnWindow(ords, 3, 10), ok, "case %d ords=%v", i, ords) + } +} diff --git a/internal/signals/toolhealth.go b/internal/signals/toolhealth.go index 2f48fc7760..186ddd182c 100644 --- a/internal/signals/toolhealth.go +++ b/internal/signals/toolhealth.go @@ -218,12 +218,30 @@ func toolCallPos(c ToolCallRow) CallPos { return CallPos{MessageOrdinal: c.MessageOrdinal, CallIndex: c.CallIndex} } -// countEditChurn counts churn events for Edit/Write calls. -// One churn event = 3+ edits to the same file within a 10-ordinal -// span. -func countEditChurn(calls []ToolCallRow) int { - // Collect ordinals per file path. - fileOrdinals := map[string][]int{} +const ( + editChurnWindow = 3 + editChurnMaxSpan = 10 +) + +// EditChurn is one churned file: FilePath is the raw file_path from +// the tool input. Count and First/Last describe the file's first +// churn cluster (see churnCluster). +type EditChurn struct { + FilePath string + Count int + First, Last CallPos +} + +// EditChurnFiles returns one entry per churned file, in the order the +// files were first edited. It is the per-occurrence form of +// countEditChurn: countEditChurn == len(EditChurnFiles). +func EditChurnFiles(calls []ToolCallRow) []EditChurn { + type fileEdits struct { + ords []int + pos []CallPos + } + var order []string + byFile := map[string]*fileEdits{} for _, c := range calls { if c.Category != "Edit" && c.Category != "Write" { continue @@ -232,18 +250,69 @@ func countEditChurn(calls []ToolCallRow) int { if path == "" { continue } - fileOrdinals[path] = append( - fileOrdinals[path], c.MessageOrdinal, + fe, ok := byFile[path] + if !ok { + fe = &fileEdits{} + byFile[path] = fe + order = append(order, path) + } + fe.ords = append(fe.ords, c.MessageOrdinal) + fe.pos = append(fe.pos, toolCallPos(c)) + } + var out []EditChurn + for _, path := range order { + fe := byFile[path] + start, end, ok := churnCluster( + fe.ords, editChurnWindow, editChurnMaxSpan, ) + if !ok { + continue + } + out = append(out, EditChurn{ + FilePath: path, + Count: end - start + 1, + First: fe.pos[start], + Last: fe.pos[end], + }) } + return out +} - churn := 0 - for _, ords := range fileOrdinals { - if hasChurnWindow(ords, 3, 10) { - churn++ +// countEditChurn counts churn events for Edit/Write calls. +// One churn event = 3+ edits to the same file within a 10-ordinal +// span. +func countEditChurn(calls []ToolCallRow) int { + return len(EditChurnFiles(calls)) +} + +// churnCluster finds the first contiguous window of windowSize edits +// that hasChurnWindow accepts, then extends it over the following +// edits while the whole cluster still spans fewer than maxSpan +// ordinals. ok is true exactly when hasChurnWindow is. +func churnCluster( + ordinals []int, windowSize, maxSpan int, +) (start, end int, ok bool) { + n := len(ordinals) + for i := 0; i <= n-windowSize; i++ { + lo, hi := ordinals[i], ordinals[i] + for j := i + 1; j < i+windowSize; j++ { + lo, hi = min(lo, ordinals[j]), max(hi, ordinals[j]) + } + if hi-lo >= maxSpan { + continue + } + end := i + windowSize - 1 + for end+1 < n { + nlo := min(lo, ordinals[end+1]) + nhi := max(hi, ordinals[end+1]) + if nhi-nlo >= maxSpan { + break + } + lo, hi, end = nlo, nhi, end+1 } + return i, end, true } - return churn + return 0, 0, false } // extractFilePath extracts file_path from InputJSON using simple From 5dd6f4d8a6fad38ba5d27c8a26cee667adcfb27e Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:31:30 +0000 Subject: [PATCH 04/62] feat(signals): expose runaway loop span and high pressure threshold --- internal/signals/heuristics.go | 48 +++++++++++++----- internal/signals/incremental.go | 4 +- internal/signals/score.go | 6 ++- internal/signals/spans_test.go | 89 +++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 16 deletions(-) diff --git a/internal/signals/heuristics.go b/internal/signals/heuristics.go index 5627277180..df5da0ebbb 100644 --- a/internal/signals/heuristics.go +++ b/internal/signals/heuristics.go @@ -573,9 +573,16 @@ func isContextCommand(command string) bool { strings.Contains(command, " lint") } -func hasRunawayToolLoop(calls []ToolCallRow) bool { +// RunawayToolLoopSpan reports the calls that make a session a runaway +// tool loop. It returns the same verdict as hasRunawayToolLoop. The +// exact-signature path wins: its span is the whole run of identical +// calls containing the trigger. Otherwise the span is the first +// 12-call window that qualifies. n counts the calls in the span. +func RunawayToolLoopSpan( + calls []ToolCallRow, +) (first, last CallPos, n int, ok bool) { if len(calls) < 12 { - return false + return CallPos{}, CallPos{}, 0, false } facts := make([]toolLoopFact, len(calls)) for i, c := range calls { @@ -585,10 +592,20 @@ func hasRunawayToolLoop(calls []ToolCallRow) bool { commandClass: commandClass(c), } } - if hasRepeatedFailingExactToolRun(facts, 5, 3) { - return true + start, end, found := repeatedFailingExactToolRunSpan(facts, 5, 3) + if !found { + start, end, found = runawayToolWindowSpan(facts) + } + if !found { + return CallPos{}, CallPos{}, 0, false } - return hasRunawayToolWindow(facts) + return toolCallPos(calls[start]), toolCallPos(calls[end]), + end - start + 1, true +} + +func hasRunawayToolLoop(calls []ToolCallRow) bool { + _, _, _, ok := RunawayToolLoopSpan(calls) + return ok } type toolLoopFact struct { @@ -597,11 +614,11 @@ type toolLoopFact struct { commandClass string } -func hasRepeatedFailingExactToolRun( +func repeatedFailingExactToolRunSpan( facts []toolLoopFact, threshold int, failureThreshold int, -) bool { +) (start, end int, ok bool) { run := 1 failures := 0 if len(facts) > 0 && facts[0].failure { @@ -614,7 +631,12 @@ func hasRepeatedFailingExactToolRun( failures++ } if run >= threshold && failures >= failureThreshold { - return true + start, end = i-run+1, i + for end+1 < len(facts) && + facts[end+1].exactSignature == facts[end].exactSignature { + end++ + } + return start, end, true } } else { run = 1 @@ -624,10 +646,10 @@ func hasRepeatedFailingExactToolRun( } } } - return false + return 0, 0, false } -func hasRunawayToolWindow(facts []toolLoopFact) bool { +func runawayToolWindowSpan(facts []toolLoopFact) (start, end int, ok bool) { const windowSize = 12 failures := 0 classCounts := make(map[string]int, windowSize) @@ -638,7 +660,7 @@ func hasRunawayToolWindow(facts []toolLoopFact) bool { classCounts[fact.commandClass]++ } if isRunawayToolWindow(failures, classCounts) { - return true + return 0, windowSize - 1, true } for start := 1; start+windowSize <= len(facts); start++ { removed := facts[start-1] @@ -656,10 +678,10 @@ func hasRunawayToolWindow(facts []toolLoopFact) bool { } classCounts[added.commandClass]++ if isRunawayToolWindow(failures, classCounts) { - return true + return start, start + windowSize - 1, true } } - return false + return 0, 0, false } func isRunawayToolWindow(failures int, classCounts map[string]int) bool { diff --git a/internal/signals/incremental.go b/internal/signals/incremental.go index 3846fd9e83..c1d272ab8a 100644 --- a/internal/signals/incremental.go +++ b/internal/signals/incremental.go @@ -103,7 +103,7 @@ type IncrementalState struct { // counted latch. EditChurnCount itself lives on the sessions row. EditLast map[string]EditChurnState `json:"edit_last,omitempty"` - // Runaway loop: RunawayHistorical latches hasRunawayToolWindow over + // Runaway loop: RunawayHistorical latches runawayToolWindowSpan over // every 12-window that has fully left the mutable late-result region. RunawayHistorical bool `json:"runaway_historical"` @@ -798,7 +798,7 @@ func windowAt(facts []ToolFact, windowStart, start int) bool { return windowFactsQualify(facts[first : first+12]) } -// windowFactsQualify mirrors hasRunawayToolWindow's single-window test. +// windowFactsQualify mirrors runawayToolWindowSpan's single-window test. func windowFactsQualify(facts []ToolFact) bool { if len(facts) < 12 { return false diff --git a/internal/signals/score.go b/internal/signals/score.go index b86604b6ac..4a5605cab5 100644 --- a/internal/signals/score.go +++ b/internal/signals/score.go @@ -138,6 +138,10 @@ func applyToolPenalties( } } +// HighContextPressure is the peak context-window ratio above which a +// session is penalized and the Friction Log reports context pressure. +const HighContextPressure = 0.9 + func applyContextPenalties( in ScoreInput, penalties map[string]int, @@ -158,7 +162,7 @@ func applyContextPenalties( penalties["mid_task_compactions"] = p } } - if in.PressureMax != nil && *in.PressureMax > 0.9 { + if in.PressureMax != nil && *in.PressureMax > HighContextPressure { penalties["context_pressure_high"] = 10 } } diff --git a/internal/signals/spans_test.go b/internal/signals/spans_test.go index b25d31542c..ffb2194285 100644 --- a/internal/signals/spans_test.go +++ b/internal/signals/spans_test.go @@ -1,6 +1,7 @@ package signals import ( + "fmt" "math/rand/v2" "testing" @@ -15,6 +16,94 @@ func bashLs(ord, idx int) ToolCallRow { } } +func failingNpmTest(i int) ToolCallRow { + return ToolCallRow{ + Category: "Bash", ToolName: "Bash", + InputJSON: `{"command":"npm test"}`, + EventStatus: "errored", + ResultContent: "exit status 1\nFAIL", + MessageOrdinal: i * 2, CallIndex: 0, + } +} + +func TestRunawayToolLoopSpan(t *testing.T) { + stepCalls := func(n int, failing ...int) []ToolCallRow { + calls := make([]ToolCallRow, n) + for i := range calls { + calls[i] = ToolCallRow{ + Category: "Bash", ToolName: "Bash", + InputJSON: fmt.Sprintf(`{"command":"step-%c"}`, rune('a'+i)), + MessageOrdinal: i + 1, + } + } + for _, i := range failing { + calls[i].EventStatus = "errored" + } + return calls + } + exact := make([]ToolCallRow, 14) + for i := range exact { + exact[i] = failingNpmTest(i) + } + exact[13] = ToolCallRow{Category: "Read", ToolName: "Read", InputJSON: `{}`, MessageOrdinal: 26} + + tests := []struct { + name string + calls []ToolCallRow + wantOK bool + first, last CallPos + n int + }{ + {"under twelve calls", exact[:11], false, CallPos{}, CallPos{}, 0}, + { + "exact run spans the whole equal-signature run", + exact, true, + CallPos{MessageOrdinal: 0}, CallPos{MessageOrdinal: 24}, 13, + }, + { + "six failures in first twelve-call window", + stepCalls(13, 1, 3, 5, 7, 9, 11), true, + CallPos{MessageOrdinal: 1}, CallPos{MessageOrdinal: 12}, 12, + }, + { + "window found later in the session", + stepCalls(20, 9, 11, 13, 15, 17, 19), true, + CallPos{MessageOrdinal: 9}, CallPos{MessageOrdinal: 20}, 12, + }, + {"two failures is not runaway", stepCalls(12, 2, 5), false, CallPos{}, CallPos{}, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + first, last, n, ok := RunawayToolLoopSpan(tt.calls) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.first, first) + assert.Equal(t, tt.last, last) + assert.Equal(t, tt.n, n) + }) + } +} + +func TestHighContextPressureMatchesScorePenalty(t *testing.T) { + tests := []struct { + name string + pressure float64 + want bool + }{ + {"at threshold no penalty", HighContextPressure, false}, + {"just above threshold", HighContextPressure + 0.0001, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeHealthScore(ScoreInput{ + Outcome: "completed", OutcomeConfidence: "high", + HasContextData: true, PressureMax: new(tt.pressure), + }) + _, penalized := got.Penalties["context_pressure_high"] + assert.Equal(t, tt.want, penalized) + }) + } +} + func TestRetryRuns(t *testing.T) { tests := []struct { name string From 192b81bd7f826e1d021fa05f7aef3e6fc2c7057c Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:35:15 +0000 Subject: [PATCH 05/62] feat(friction): synthesize tool envelopes and noise names --- internal/friction/input.go | 113 ++++++++++++++++++++++++++++++++ internal/friction/input_test.go | 85 ++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 internal/friction/input.go create mode 100644 internal/friction/input_test.go diff --git a/internal/friction/input.go b/internal/friction/input.go new file mode 100644 index 0000000000..9d666ed91e --- /dev/null +++ b/internal/friction/input.go @@ -0,0 +1,113 @@ +package friction + +import ( + "strings" + "time" + + "go.kenn.io/agentsview/internal/serdejson" + "go.kenn.io/agentsview/internal/signals" +) + +// RawMessage is one archived message row, as the caller read it from +// the store. The adapter never reads the store itself. +type RawMessage struct { + Ordinal int + Role string + Content string + ThinkingText string + IsSystem bool + IsCompactBoundary bool + SourceSubtype string + Timestamp time.Time + ContextTokens int + HasContextTokens bool +} + +// RawToolCall is one archived tool call. LastEventContent and +// EventStatus come from the call's last tool_result_events row. +// Timestamp is the call's time when known; zero falls back to the +// owning message's timestamp. +type RawToolCall struct { + MessageOrdinal int + CallIndex int + ToolName string + Category string + InputJSON string + ResultContent string + LastEventContent string + EventStatus string + ContentFailure bool + ContentFailureKnown bool + Timestamp time.Time +} + +// BuildOptions tunes BuildSessionInput. +type BuildOptions struct { + // RedactedToolRenderings prefers the redacted rendering when + // stripping tool calls from assistant text (storage policies that + // drop tool inputs store the redacted form). + RedactedToolRenderings bool + // PressureMax is the session's peak context pressure as the + // quality signal pass computed it (sessions.context_pressure_max). + PressureMax *float64 +} + +// SessionInput is one subject mapped into the stream the detectors +// expect. +type SessionInput struct { + SubjectID string + Dims Dims + IsSubAgent bool + Excluded bool + Messages []Message + // Interruptions holds the rows with is_system=1 and + // source_subtype "interrupted" that Messages drops as system rows. + // It feeds DetectInterruptions (spec §6.8). + Interruptions []Message + Patterns PatternInput +} + +func (c RawToolCall) row() signals.ToolCallRow { + return signals.ToolCallRow{ + ToolName: c.ToolName, + Category: c.Category, + InputJSON: c.InputJSON, + ResultContent: c.ResultContent, + MessageOrdinal: c.MessageOrdinal, + CallIndex: c.CallIndex, + EventStatus: c.EventStatus, + ContentFailure: c.ContentFailure, + ContentFailureKnown: c.ContentFailureKnown, + } +} + +// ToolEnvelope synthesizes the {"error","success"} tool result envelope +// jilog's error detector reads (spec §6.3, D12). success is the +// negation of signals.IsFailure. A result event is present iff +// EventStatus != "" (the caller fills EventStatus and LastEventContent +// from the last tool_result_events row). error is that event's content +// when an event is present and its content is non-empty, else the +// call's result content, else null. +func ToolEnvelope(c RawToolCall) string { + var errVal any + switch { + case c.EventStatus != "" && c.LastEventContent != "": + errVal = c.LastEventContent + case c.ResultContent != "": + errVal = c.ResultContent + } + return serdejson.CompactString(map[string]any{ + "error": errVal, + "success": !signals.IsFailure(c.row()), + }) +} + +// NoiseToolName is the name the expected-noise allowlist matches +// (spec §6.3): "bash" for any Bash-category call, otherwise the +// lowercased tool name. +func NoiseToolName(toolName, category string) string { + if category == "Bash" { + return "bash" + } + return strings.ToLower(toolName) +} diff --git a/internal/friction/input_test.go b/internal/friction/input_test.go new file mode 100644 index 0000000000..4a4efee40e --- /dev/null +++ b/internal/friction/input_test.go @@ -0,0 +1,85 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToolEnvelope(t *testing.T) { + tests := []struct { + name string + call RawToolCall + want string + }{ + { + "errored status uses last event content", + RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "errored", + ResultContent: "summary", LastEventContent: "boom"}, + `{"error":"boom","success":false}`, + }, + { + "cancelled status is a failure", + RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "cancelled", + ResultContent: "interrupted"}, + `{"error":"interrupted","success":false}`, + }, + { + "event with empty content falls back to result content", + RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "errored", + ResultContent: "summary"}, + `{"error":"summary","success":false}`, + }, + { + "event content ignored without an event status", + RawToolCall{ToolName: "Bash", Category: "Bash", + ResultContent: "bash: x: command not found", LastEventContent: "stale"}, + `{"error":"bash: x: command not found","success":false}`, + }, + { + "empty content is null", + RawToolCall{ToolName: "Read", Category: "Read", EventStatus: "completed"}, + `{"error":null,"success":true}`, + }, + { + "content heuristic failure without status", + RawToolCall{ToolName: "Bash", Category: "Bash", + ResultContent: "bash: foo: command not found"}, + `{"error":"bash: foo: command not found","success":false}`, + }, + { + "precomputed content verdict wins over content scan", + RawToolCall{ToolName: "Bash", Category: "Bash", ResultContent: "ok", + ContentFailure: true, ContentFailureKnown: true}, + `{"error":"ok","success":false}`, + }, + { + "html and line separators are not escaped", + RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "errored", + ResultContent: "a&c\u2028d"}, + "{\"error\":\"a&c\u2028d\",\"success\":false}", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ToolEnvelope(tt.call)) + }) + } +} + +func TestNoiseToolName(t *testing.T) { + tests := []struct { + name, tool, category, want string + }{ + {"claude bash", "Bash", "Bash", "bash"}, + {"codex shell mapped by category", "exec_command", "Bash", "bash"}, + {"mode tool lowercased", "Mode", "Other", "mode"}, + {"other tool lowercased", "python_check", "Other", "python_check"}, + {"empty", "", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, NoiseToolName(tt.tool, tt.category)) + }) + } +} From ea89b64e3cebbea3992b44f2847789467f2b1fa6 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:40:28 +0000 Subject: [PATCH 06/62] feat(friction): extract text-only assistant content --- internal/friction/assistant_text.go | 130 +++++++++++++++++++++++ internal/friction/assistant_text_test.go | 107 +++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 internal/friction/assistant_text.go create mode 100644 internal/friction/assistant_text_test.go diff --git a/internal/friction/assistant_text.go b/internal/friction/assistant_text.go new file mode 100644 index 0000000000..272376b2e6 --- /dev/null +++ b/internal/friction/assistant_text.go @@ -0,0 +1,130 @@ +package friction + +import ( + "strings" + + "go.kenn.io/agentsview/internal/parser" +) + +const ( + thinkingOpen = "[Thinking]\n" + thinkingClose = "\n[/Thinking]" +) + +// AssistantText extracts text blocks from stored assistant content, which +// also contains inlined tool renderings and thinking blocks. +func AssistantText( + content, thinking string, calls []RawToolCall, redacted bool, +) string { + text := content + for _, call := range calls { + if rendering := renderingInText(text, call, redacted); rendering != "" { + text = removePart(text, strings.Index(text, rendering), len(rendering)) + } + } + return stripThinking(text, thinking) +} + +// renderingInText chooses the longest present rendering in the preferred +// form, falling back to the other form only when none is present. +func renderingInText(text string, call RawToolCall, redacted bool) string { + pairs := parser.ToolUseRenderingCandidates( + call.Category, call.ToolName, call.InputJSON, + ) + pick := func(form func(parser.ToolUseRenderingPair) string) string { + best := "" + for _, pair := range pairs { + rendering := form(pair) + if len(rendering) > len(best) && strings.Contains(text, rendering) { + best = rendering + } + } + return best + } + full := func(pair parser.ToolUseRenderingPair) string { return pair.Full } + red := func(pair parser.ToolUseRenderingPair) string { return pair.Redacted } + if redacted { + if rendering := pick(red); rendering != "" { + return rendering + } + return pick(full) + } + if rendering := pick(full); rendering != "" { + return rendering + } + return pick(red) +} + +// removePart deletes a block and one adjacent newline separator. The +// preceding separator takes priority when both sides have one. +func removePart(text string, at, n int) string { + if at < 0 { + return text + } + end := at + n + switch { + case at > 0 && text[at-1] == '\n': + at-- + case end < len(text) && text[end] == '\n': + end++ + } + return text[:at] + text[end:] +} + +// stripThinking removes line-start thinking blocks whose close marker ends +// a line. The inner text is matched against thinking when available. +func stripThinking(text, thinking string) string { + for from := 0; from < len(text); { + i := strings.Index(text[from:], thinkingOpen) + if i < 0 { + break + } + start := from + i + if start > 0 && text[start-1] != '\n' { + from = start + len(thinkingOpen) + continue + } + end := blockEnd(text, start, thinking) + if end < 0 { + from = start + len(thinkingOpen) + continue + } + text = removePart(text, start, end-start) + from = max(start-1, 0) + } + return text +} + +// blockEnd returns the end of the longest inner text found in thinking, +// falling back to the first line-ending close marker when none matches. +func blockEnd(text string, start int, thinking string) int { + innerStart := start + len(thinkingOpen) + first := -1 + matched := -1 + for search := innerStart - 1; search < len(text); { + j := strings.Index(text[search:], thinkingClose) + if j < 0 { + break + } + closeAt := search + j + end := closeAt + len(thinkingClose) + search = closeAt + 1 + if end < len(text) && text[end] != '\n' { + continue + } + if first < 0 { + first = end + } + inner := text[innerStart:max(closeAt, innerStart)] + if thinking == "" { + return end + } + if strings.Contains(thinking, inner) { + matched = end + } + } + if matched >= 0 { + return matched + } + return first +} diff --git a/internal/friction/assistant_text_test.go b/internal/friction/assistant_text_test.go new file mode 100644 index 0000000000..b9c178831f --- /dev/null +++ b/internal/friction/assistant_text_test.go @@ -0,0 +1,107 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.kenn.io/agentsview/internal/parser" +) + +func TestAssistantText(t *testing.T) { + todo := RawToolCall{ + ToolName: "TodoWrite", Category: "Other", + InputJSON: `{"todos":[{"content":"Fix the hack for now","status":"pending"}]}`, + } + bash := RawToolCall{ + ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"go test ./...","description":"Run tests"}`, + } + read := RawToolCall{ToolName: "Read", Category: "Read", InputJSON: `{"file_path":"/home/user/app/a.go"}`} + todoR := parser.ToolUseRendering(todo.ToolName, todo.InputJSON) + bashR := parser.ToolUseRendering(bash.ToolName, bash.InputJSON) + bashRedacted := parser.RedactedToolUseRendering(bash.ToolName, bash.InputJSON) + readR := parser.ToolUseRendering(read.ToolName, read.InputJSON) + + tests := []struct { + name string + content string + thinking string + calls []RawToolCall + redacted bool + want string + }{ + {"plain text unchanged", "hello\nworld", "", nil, false, "hello\nworld"}, + { + "tool rendering between text blocks", + "hello\n" + readR + "\nworld", "", []RawToolCall{read}, false, + "hello\nworld", + }, + { + "todo rendering removed", + "Let me update the plan.\n" + todoR, "", []RawToolCall{todo}, false, + "Let me update the plan.", + }, + { + "single thinking block removed", + "[Thinking]\nI will do this for now\n[/Thinking]\nDone.", + "I will do this for now", nil, false, + "Done.", + }, + { + "multiple thinking blocks removed", + "[Thinking]\nfirst for now\n[/Thinking]\nText one.\n" + + "[Thinking]\nsecond: I'll come back to this\n[/Thinking]\nText two.", + "first for now\n\nsecond: I'll come back to this", nil, false, + "Text one.\nText two.", + }, + { + "thinking containing close marker removed whole", + "[Thinking]\nquote:\n[/Thinking]\nstill thinking for now\n[/Thinking]\nAnswer.", + "quote:\n[/Thinking]\nstill thinking for now", nil, false, + "Answer.", + }, + { + "two identical calls both removed", + "Running twice.\n" + bashR + "\n" + bashR + "\nBoth passed.", + "", []RawToolCall{bash, bash}, false, + "Running twice.\nBoth passed.", + }, + { + "redacted rendering removed", + "Run it.\n" + bashRedacted, "", []RawToolCall{bash}, true, + "Run it.", + }, + { + "full rendering fallback under redacting policy", + "Run it.\n" + bashR, "", []RawToolCall{bash}, true, + "Run it.", + }, + { + "redacted preferred when both forms occur", + "Run it.\n" + bashRedacted + "\n" + bashR, "", []RawToolCall{bash}, true, + "Run it.\n" + bashR, + }, + { + "full preferred when both forms occur", + "Run it.\n" + bashRedacted + "\n" + bashR, "", []RawToolCall{bash}, false, + "Run it.\n" + bashRedacted, + }, + { + "inline marker kept", + "I wrote [Thinking] in the docs for now.", "", nil, false, + "I wrote [Thinking] in the docs for now.", + }, + { + "unclosed thinking marker kept", + "[Thinking]\nfor now\nAnswer.", "for now", nil, false, + "[Thinking]\nfor now\nAnswer.", + }, + {"only rendering leaves empty text", readR, "", []RawToolCall{read}, false, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, + AssistantText(tt.content, tt.thinking, tt.calls, tt.redacted)) + }) + } +} From d68298616d074bbb65df4c08e910d6958bf7f244 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:44:27 +0000 Subject: [PATCH 07/62] fix(friction): keep unmatched thinking blocks --- internal/friction/assistant_text.go | 13 +++---------- internal/friction/assistant_text_test.go | 6 ++++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/internal/friction/assistant_text.go b/internal/friction/assistant_text.go index 272376b2e6..80b3e607ce 100644 --- a/internal/friction/assistant_text.go +++ b/internal/friction/assistant_text.go @@ -95,11 +95,10 @@ func stripThinking(text, thinking string) string { return text } -// blockEnd returns the end of the longest inner text found in thinking, -// falling back to the first line-ending close marker when none matches. +// blockEnd returns the end of the longest inner text found in thinking. +// Without thinking text, it uses the first line-ending close marker. func blockEnd(text string, start int, thinking string) int { innerStart := start + len(thinkingOpen) - first := -1 matched := -1 for search := innerStart - 1; search < len(text); { j := strings.Index(text[search:], thinkingClose) @@ -112,9 +111,6 @@ func blockEnd(text string, start int, thinking string) int { if end < len(text) && text[end] != '\n' { continue } - if first < 0 { - first = end - } inner := text[innerStart:max(closeAt, innerStart)] if thinking == "" { return end @@ -123,8 +119,5 @@ func blockEnd(text string, start int, thinking string) int { matched = end } } - if matched >= 0 { - return matched - } - return first + return matched } diff --git a/internal/friction/assistant_text_test.go b/internal/friction/assistant_text_test.go index b9c178831f..13c4bd7535 100644 --- a/internal/friction/assistant_text_test.go +++ b/internal/friction/assistant_text_test.go @@ -96,6 +96,12 @@ func TestAssistantText(t *testing.T) { "[Thinking]\nfor now\nAnswer.", "for now", nil, false, "[Thinking]\nfor now\nAnswer.", }, + { + "thinking block with unmatched body kept", + "Before.\n[Thinking]\nprivate draft\n[/Thinking]\nAfter.", + "different thought", nil, false, + "Before.\n[Thinking]\nprivate draft\n[/Thinking]\nAfter.", + }, {"only rendering leaves empty text", readR, "", []RawToolCall{read}, false, ""}, } for _, tt := range tests { From 1c91707c9ea5fb89171d10208c6256d7ae802617 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:48:24 +0000 Subject: [PATCH 08/62] feat(friction): derive seats from configured path patterns --- .../task-6-report.md | 29 +++++ internal/friction/seat.go | 101 ++++++++++++++++++ internal/friction/seat_test.go | 79 ++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 .superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md create mode 100644 internal/friction/seat.go create mode 100644 internal/friction/seat_test.go diff --git a/.superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md b/.superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md new file mode 100644 index 0000000000..4e03909d24 --- /dev/null +++ b/.superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md @@ -0,0 +1,29 @@ +--- +last_edited: 2026-09-23 +--- + +# Task 6: seat pattern matching + +Implemented `CompileSeatPatterns` and `SeatFromPath` in `internal/friction/seat.go`. Patterns require exactly one whole-segment `{seat}`, validate all remaining segments with `path.Match`, and preserve the specified plain error strings. Matching supports rooted patterns, floating contiguous windows, first-pattern priority, leftmost-window priority, and either slash style in input paths. + +Added anonymized generic tests in `internal/friction/seat_test.go` for compilation errors, globs, rooted and floating matches, priority, Windows separators, and no-match cases. + +## TDD evidence + +- RED: `CGO_ENABLED=1 go test -tags fts5 ./internal/friction/ -run 'TestCompileSeatPatterns|TestSeatFromPath' -v` failed to compile with `undefined: CompileSeatPatterns`, `undefined: SeatPattern`, and `undefined: SeatFromPath`, as expected before implementation. +- GREEN: the same command passed all 19 subtests after implementation. + +## Checks + +- `go fmt ./...` completed successfully. +- `go vet ./...` completed successfully. +- `git diff --check` completed successfully. + +## Self-review + +The matcher scans each candidate window in path order and patterns in configuration order. Every capture is a non-empty segment because path splitting omits empty segments. No personal path layouts or worker names are encoded. + +## Files + +- `internal/friction/seat.go` +- `internal/friction/seat_test.go` diff --git a/internal/friction/seat.go b/internal/friction/seat.go new file mode 100644 index 0000000000..e5de295649 --- /dev/null +++ b/internal/friction/seat.go @@ -0,0 +1,101 @@ +package friction + +import ( + "fmt" + "path" + "strings" +) + +const seatPlaceholder = "{seat}" + +// SeatPattern is one compiled [friction] seat_patterns entry: a +// "/"-separated glob with exactly one whole-segment {seat} capture. +type SeatPattern struct { + raw string + anchored bool + segs []string + seatAt int +} + +// CompileSeatPatterns validates and compiles user seat patterns. The +// error names the offending entry; config validation adds the +// [friction] seat_patterns prefix. +func CompileSeatPatterns(patterns []string) ([]SeatPattern, error) { + var out []SeatPattern + for _, raw := range patterns { + p, err := compileSeatPattern(raw) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, nil +} + +func compileSeatPattern(raw string) (SeatPattern, error) { + if strings.Count(raw, seatPlaceholder) != 1 { + return SeatPattern{}, fmt.Errorf( + "seat pattern %q must contain exactly one %s", raw, seatPlaceholder) + } + p := SeatPattern{ + raw: raw, + anchored: strings.HasPrefix(raw, "/"), + segs: strings.FieldsFunc(raw, func(r rune) bool { return r == '/' }), + seatAt: -1, + } + for i, seg := range p.segs { + if seg == seatPlaceholder { + p.seatAt = i + continue + } + if strings.Contains(seg, seatPlaceholder) { + return SeatPattern{}, fmt.Errorf( + "seat pattern %q: %s must be a whole path segment", + raw, seatPlaceholder) + } + if _, err := path.Match(seg, ""); err != nil { + return SeatPattern{}, fmt.Errorf("seat pattern %q: %w", raw, err) + } + } + return p, nil +} + +// SeatFromPath returns the {seat} capture of the first pattern that +// matches filePath, or "" when none does. A seat is a display +// dimension only and never selects a detector (spec §11.3). +func SeatFromPath(filePath string, patterns []SeatPattern) string { + segs := strings.FieldsFunc(filePath, func(r rune) bool { + return r == '/' || r == '\\' + }) + for _, p := range patterns { + if seat, ok := p.match(segs); ok { + return seat + } + } + return "" +} + +func (p SeatPattern) match(segs []string) (string, bool) { + last := len(segs) - len(p.segs) + if p.anchored && last > 0 { + last = 0 + } + for start := 0; start <= last; start++ { + if seat, ok := p.matchAt(segs[start : start+len(p.segs)]); ok { + return seat, true + } + } + return "", false +} + +func (p SeatPattern) matchAt(window []string) (string, bool) { + for i, pat := range p.segs { + if i == p.seatAt { + continue + } + if ok, _ := path.Match(pat, window[i]); !ok { + return "", false + } + } + return window[p.seatAt], true +} diff --git a/internal/friction/seat_test.go b/internal/friction/seat_test.go new file mode 100644 index 0000000000..c29c69ece7 --- /dev/null +++ b/internal/friction/seat_test.go @@ -0,0 +1,79 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompileSeatPatterns(t *testing.T) { + tests := []struct { + name string + in []string + wantErr string + }{ + {"none", nil, ""}, + {"spec example", []string{"*/profiles/{seat}/projects/*"}, ""}, + {"no placeholder", []string{"*/profiles/*"}, `seat pattern "*/profiles/*" must contain exactly one {seat}`}, + {"two placeholders", []string{"{seat}/{seat}"}, `seat pattern "{seat}/{seat}" must contain exactly one {seat}`}, + {"partial segment", []string{"profiles/seat-{seat}"}, `seat pattern "profiles/seat-{seat}": {seat} must be a whole path segment`}, + {"bad glob", []string{"[profiles/{seat}"}, `seat pattern "[profiles/{seat}": syntax error in pattern`}, + {"one bad entry fails the list", []string{"a/{seat}/b", "nope"}, `seat pattern "nope" must contain exactly one {seat}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CompileSeatPatterns(tt.in) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + assert.Nil(t, got) + return + } + require.NoError(t, err) + assert.Len(t, got, len(tt.in)) + }) + } +} + +func TestSeatFromPath(t *testing.T) { + compile := func(t *testing.T, p ...string) []SeatPattern { + t.Helper() + out, err := CompileSeatPatterns(p) + require.NoError(t, err) + return out + } + tests := []struct { + name string + path string + patterns []string + want string + }{ + {"seat_from_path_no_patterns", "/home/user/agents/profiles/seat-01/projects/p/s.jsonl", nil, ""}, + {"spec example on absolute path", "/home/user/agents/profiles/seat-01/projects/app/s.jsonl", + []string{"*/profiles/{seat}/projects/*"}, "seat-01"}, + {"pool sessions layout", "/srv/pool/seat-02/sessions/2026/09/13/rollout-x.jsonl", + []string{"pool/{seat}/sessions"}, "seat-02"}, + {"star stays within one segment", "/home/user/a/b/profiles/seat-03/projects/p/s.jsonl", + []string{"/home/*/profiles/{seat}"}, ""}, + {"anchored pattern", "/home/user/profiles/seat-04/projects/p/s.jsonl", + []string{"/home/*/profiles/{seat}"}, "seat-04"}, + {"anchored pattern does not float", "/mnt/home/user/profiles/seat-05/s.jsonl", + []string{"/home/*/profiles/{seat}"}, ""}, + {"no match", "/home/user/.claude/projects/p/s.jsonl", + []string{"*/profiles/{seat}/projects/*"}, ""}, + {"first pattern wins", "/x/profiles/seat-06/pool/seat-07/s.jsonl", + []string{"pool/{seat}", "profiles/{seat}"}, "seat-07"}, + {"leftmost window wins", "/x/profiles/seat-08/profiles/seat-09/s.jsonl", + []string{"profiles/{seat}"}, "seat-08"}, + {"windows separators", `C:\Users\user\pool\seat-10\sessions\rollout-x.jsonl`, + []string{"pool/{seat}/sessions"}, "seat-10"}, + {"character class glob", "/srv/pool-b/seat-11/s.jsonl", + []string{"pool-[ab]/{seat}"}, "seat-11"}, + {"empty path", "", []string{"*/{seat}"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, SeatFromPath(tt.path, compile(t, tt.patterns...))) + }) + } +} From 9fa67ea2aec7fea6102a0a5161c4c97024700066 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:55:40 +0000 Subject: [PATCH 09/62] feat(friction): map archived rows into session input --- internal/friction/input.go | 106 +++++++++++++++++++++++++++++ internal/friction/input_test.go | 115 ++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/internal/friction/input.go b/internal/friction/input.go index 9d666ed91e..fd00a6c4f8 100644 --- a/internal/friction/input.go +++ b/internal/friction/input.go @@ -1,6 +1,8 @@ package friction import ( + "cmp" + "slices" "strings" "time" @@ -8,6 +10,9 @@ import ( "go.kenn.io/agentsview/internal/signals" ) +// SourceSubtypeInterrupted marks Claude's interrupted-request system rows. +const SourceSubtypeInterrupted = "interrupted" + // RawMessage is one archived message row, as the caller read it from // the store. The adapter never reads the store itself. type RawMessage struct { @@ -111,3 +116,104 @@ func NoiseToolName(toolName, category string) string { } return strings.ToLower(toolName) } + +// BuildSessionInput maps archived rows into the detector stream and pattern input. +// It does not mutate the caller's slices. +func BuildSessionInput(subjectID string, dims Dims, isSubAgent bool, msgs []RawMessage, calls []RawToolCall, opts BuildOptions) SessionInput { + msgs = slices.Clone(msgs) + slices.SortStableFunc(msgs, func(a, b RawMessage) int { return cmp.Compare(a.Ordinal, b.Ordinal) }) + calls = slices.Clone(calls) + slices.SortStableFunc(calls, func(a, b RawToolCall) int { + return cmp.Or(cmp.Compare(a.MessageOrdinal, b.MessageOrdinal), cmp.Compare(a.CallIndex, b.CallIndex)) + }) + + msgTime := make(map[int]time.Time, len(msgs)) + for _, m := range msgs { + if _, ok := msgTime[m.Ordinal]; !ok { + msgTime[m.Ordinal] = m.Timestamp + } + } + callTime := func(c RawToolCall) time.Time { + if !c.Timestamp.IsZero() { + return c.Timestamp + } + return msgTime[c.MessageOrdinal] + } + + in := SessionInput{SubjectID: subjectID, Dims: dims, IsSubAgent: isSubAgent} + emitCall := func(c RawToolCall) { + in.Messages = append(in.Messages, Message{ + Ordinal: c.MessageOrdinal, Role: "tool", Text: ToolEnvelope(c), + ToolName: c.ToolName, NoiseName: NoiseToolName(c.ToolName, c.Category), + CallIndex: c.CallIndex, Timestamp: callTime(c), + }) + } + + j := 0 + for _, m := range msgs { + for j < len(calls) && calls[j].MessageOrdinal < m.Ordinal { + emitCall(calls[j]) + j++ + } + k := j + for k < len(calls) && calls[k].MessageOrdinal == m.Ordinal { + k++ + } + own := calls[j:k] + switch { + case m.IsCompactBoundary: + in.Patterns.CompactBoundaries = append(in.Patterns.CompactBoundaries, m.Ordinal) + in.Patterns.BoundaryTimes = append(in.Patterns.BoundaryTimes, m.Timestamp) + case m.IsSystem && m.SourceSubtype == SourceSubtypeInterrupted: + in.Interruptions = append(in.Interruptions, Message{ + Ordinal: m.Ordinal, Role: m.Role, Text: m.Content, Timestamp: m.Timestamp, + }) + case m.IsSystem || m.SourceSubtype == "tool_result": + // System and tool-result rows do not enter the correction stream. + case m.Role == "user": + in.Messages = append(in.Messages, Message{ + Ordinal: m.Ordinal, Role: m.Role, Text: m.Content, Timestamp: m.Timestamp, + }) + in.Patterns.UserOrdinals = append(in.Patterns.UserOrdinals, m.Ordinal) + case m.Role == "assistant": + in.Messages = append(in.Messages, Message{ + Ordinal: m.Ordinal, Role: m.Role, + Text: AssistantText(m.Content, m.ThinkingText, own, opts.RedactedToolRenderings), + Timestamp: m.Timestamp, + }) + } + for _, c := range own { + emitCall(c) + } + j = k + } + for ; j < len(calls); j++ { + emitCall(calls[j]) + } + + ordinals := make([]signals.ToolCallOrdinal, 0, len(calls)) + for _, c := range calls { + in.Patterns.Calls = append(in.Patterns.Calls, c.row()) + in.Patterns.CallTimes = append(in.Patterns.CallTimes, callTime(c)) + ordinals = append(ordinals, signals.ToolCallOrdinal{MessageOrdinal: c.MessageOrdinal, ToolName: c.ToolName}) + } + in.Patterns.MidTaskCompactions = signals.CountMidTaskCompactions(in.Patterns.CompactBoundaries, ordinals) + in.Patterns.PressureMax = opts.PressureMax + if opts.PressureMax != nil { + in.Patterns.PressureAt = peakContextTime(msgs) + } + return in +} + +// peakContextTime returns the first assistant timestamp at the maximum +// recorded context size, including system rows. +func peakContextTime(msgs []RawMessage) time.Time { + var at time.Time + best := -1 + for _, m := range msgs { + if m.Role == "assistant" && m.HasContextTokens && m.ContextTokens > best { + best, at = m.ContextTokens, m.Timestamp + } + } + return at +} diff --git a/internal/friction/input_test.go b/internal/friction/input_test.go index 4a4efee40e..3de5d7cd67 100644 --- a/internal/friction/input_test.go +++ b/internal/friction/input_test.go @@ -2,10 +2,125 @@ package friction import ( "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/parser" + "go.kenn.io/agentsview/internal/signals" ) +var inputTestStart = time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC) + +func inputAt(min int) time.Time { return inputTestStart.Add(time.Duration(min) * time.Minute) } + +func TestBuildSessionInput(t *testing.T) { + bashFail := RawToolCall{MessageOrdinal: 1, CallIndex: 0, ToolName: "Bash", Category: "Bash", InputJSON: `{"command":"make"}`, EventStatus: "errored", LastEventContent: "boom"} + readOK := RawToolCall{MessageOrdinal: 1, CallIndex: 1, ToolName: "Read", Category: "Read", InputJSON: `{"file_path":"a.go"}`, EventStatus: "completed", ResultContent: "package a"} + + t.Run("filtered rows and sorted calls", func(t *testing.T) { + msgs := []RawMessage{ + {Ordinal: 5, Role: "user", Content: "correct it", Timestamp: inputAt(5)}, + {Ordinal: 0, Role: "user", Content: "build it", Timestamp: inputAt(0)}, + {Ordinal: 1, Role: "assistant", Content: "Building.", Timestamp: inputAt(1)}, + {Ordinal: 3, Role: "user", Content: "continuation", IsSystem: true, Timestamp: inputAt(3)}, + {Ordinal: 4, Role: "assistant", IsCompactBoundary: true, IsSystem: true, Timestamp: inputAt(4)}, + {Ordinal: 6, Role: "user", Content: "result", SourceSubtype: "tool_result", Timestamp: inputAt(6)}, + {Ordinal: 7, Role: "assistant", Content: "Fixed.", Timestamp: inputAt(7)}, + {Ordinal: 8, Role: "user", Content: "[Request interrupted by user]", IsSystem: true, SourceSubtype: "interrupted", Timestamp: inputAt(8)}, + } + calls := []RawToolCall{readOK, bashFail} + in := BuildSessionInput("s1", Dims{Agent: "claude"}, false, msgs, calls, BuildOptions{}) + assert.Equal(t, []Message{ + {Ordinal: 0, Role: "user", Text: "build it", Timestamp: inputAt(0)}, + {Ordinal: 1, Role: "assistant", Text: "Building.", Timestamp: inputAt(1)}, + {Ordinal: 1, Role: "tool", ToolName: "Bash", NoiseName: "bash", Text: `{"error":"boom","success":false}`, Timestamp: inputAt(1)}, + {Ordinal: 1, Role: "tool", ToolName: "Read", NoiseName: "read", Text: `{"error":"package a","success":true}`, CallIndex: 1, Timestamp: inputAt(1)}, + {Ordinal: 5, Role: "user", Text: "correct it", Timestamp: inputAt(5)}, + {Ordinal: 7, Role: "assistant", Text: "Fixed.", Timestamp: inputAt(7)}, + }, in.Messages) + assert.Equal(t, "s1", in.SubjectID) + assert.Equal(t, Dims{Agent: "claude"}, in.Dims) + assert.Equal(t, []int{0, 5}, in.Patterns.UserOrdinals) + assert.Equal(t, []Message{{Ordinal: 8, Role: "user", Text: "[Request interrupted by user]", Timestamp: inputAt(8)}}, in.Interruptions) + assert.Equal(t, []int{4}, in.Patterns.CompactBoundaries) + assert.Equal(t, []time.Time{inputAt(4)}, in.Patterns.BoundaryTimes) + assert.Equal(t, []signals.ToolCallRow{bashFail.row(), readOK.row()}, in.Patterns.Calls) + assert.Equal(t, []time.Time{inputAt(1), inputAt(1)}, in.Patterns.CallTimes) + assert.Equal(t, 5, msgs[0].Ordinal) + assert.Equal(t, "Read", calls[0].ToolName) + }) + + t.Run("call timestamps and dropped owners", func(t *testing.T) { + withTime := bashFail + withTime.Timestamp = inputAt(2) + orphan := RawToolCall{MessageOrdinal: 2, ToolName: "Bash", Category: "Bash", EventStatus: "errored", ResultContent: "fatal: bad"} + systemOwned := RawToolCall{MessageOrdinal: 3, ToolName: "Bash", Category: "Bash", EventStatus: "cancelled"} + in := BuildSessionInput("s1", Dims{}, false, []RawMessage{ + {Ordinal: 1, Role: "assistant", Content: "a", Timestamp: inputAt(1)}, + {Ordinal: 3, Role: "assistant", IsSystem: true, Timestamp: inputAt(3)}, + {Ordinal: 4, Role: "user", Content: "u", Timestamp: inputAt(4)}, + }, []RawToolCall{systemOwned, orphan, readOK, withTime}, BuildOptions{}) + assert.Equal(t, []int{1, 1, 1, 2, 3, 4}, messageOrdinals(in.Messages)) + assert.Equal(t, []time.Time{inputAt(2), inputAt(1), time.Time{}, inputAt(3)}, in.Patterns.CallTimes) + require.Len(t, in.Messages, 6) + assert.Equal(t, inputAt(1), in.Messages[2].Timestamp) + assert.Equal(t, "tool", in.Messages[3].Role) + assert.True(t, in.Messages[3].Timestamp.IsZero()) + assert.Equal(t, inputAt(3), in.Messages[4].Timestamp) + }) + + t.Run("assistant text uses own calls", func(t *testing.T) { + call := RawToolCall{MessageOrdinal: 1, ToolName: "TodoWrite", Category: "Other", InputJSON: `{"todos":[{"content":"x","status":"pending"}]}`} + content := "[Thinking]\nleave it for now\n[/Thinking]\nDone.\n" + parser.ToolUseRendering(call.ToolName, call.InputJSON) + in := BuildSessionInput("s1", Dims{}, false, []RawMessage{{Ordinal: 1, Role: "assistant", Content: content, ThinkingText: "leave it for now"}}, []RawToolCall{call}, BuildOptions{}) + require.Len(t, in.Messages, 2) + assert.Equal(t, "Done.", in.Messages[0].Text) + }) + + t.Run("compaction and pressure", func(t *testing.T) { + p := 0.95 + calls := []RawToolCall{ + {MessageOrdinal: 1, ToolName: "Read"}, {MessageOrdinal: 1, ToolName: "Edit"}, {MessageOrdinal: 1, ToolName: "Bash"}, + {MessageOrdinal: 3, ToolName: "Read"}, {MessageOrdinal: 3, ToolName: "Edit"}, + } + in := BuildSessionInput("s1", Dims{}, false, []RawMessage{ + {Ordinal: 3, Role: "assistant", ContextTokens: 900, HasContextTokens: true, Timestamp: inputAt(3)}, + {Ordinal: 2, Role: "assistant", IsSystem: true, IsCompactBoundary: true, ContextTokens: 900, HasContextTokens: true, Timestamp: inputAt(2)}, + {Ordinal: 1, Role: "assistant", ContextTokens: 100, HasContextTokens: true, Timestamp: inputAt(1)}, + {Ordinal: 4, Role: "user", ContextTokens: 5000, HasContextTokens: true, Timestamp: inputAt(4)}, + }, calls, BuildOptions{PressureMax: &p}) + assert.Equal(t, 1, in.Patterns.MidTaskCompactions) + assert.Equal(t, &p, in.Patterns.PressureMax) + assert.Equal(t, inputAt(2), in.Patterns.PressureAt) + }) + + t.Run("empty and non-system interruption subtype", func(t *testing.T) { + in := BuildSessionInput("s1", Dims{}, true, []RawMessage{{Ordinal: 1, Role: "user", Content: "keep", SourceSubtype: "interrupted"}}, nil, BuildOptions{}) + assert.Equal(t, []Message{{Ordinal: 1, Role: "user", Text: "keep"}}, in.Messages) + assert.Empty(t, in.Interruptions) + assert.True(t, in.IsSubAgent) + assert.Nil(t, in.Patterns.PressureMax) + assert.True(t, in.Patterns.PressureAt.IsZero()) + }) + + t.Run("no rows and trailing calls", func(t *testing.T) { + call := RawToolCall{MessageOrdinal: 9, ToolName: "Read", Category: "Read", EventStatus: "completed"} + in := BuildSessionInput("s1", Dims{}, false, nil, []RawToolCall{call}, BuildOptions{}) + assert.Equal(t, []Message{{Ordinal: 9, Role: "tool", ToolName: "Read", NoiseName: "read", Text: `{"error":null,"success":true}`}}, in.Messages) + assert.Equal(t, []time.Time{{}}, in.Patterns.CallTimes) + assert.Empty(t, in.Patterns.UserOrdinals) + }) +} + +func messageOrdinals(msgs []Message) []int { + ordinals := make([]int, 0, len(msgs)) + for _, m := range msgs { + ordinals = append(ordinals, m.Ordinal) + } + return ordinals +} + func TestToolEnvelope(t *testing.T) { tests := []struct { name string From b3359f9aae184b294dfd14e6c6277aa8acaec6f4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:01:48 +0000 Subject: [PATCH 10/62] feat(friction): detect pattern kinds from reused signals --- internal/friction/patterns.go | 101 ++++++++++++++++++++ internal/friction/patterns_test.go | 148 +++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 internal/friction/patterns.go create mode 100644 internal/friction/patterns_test.go diff --git a/internal/friction/patterns.go b/internal/friction/patterns.go new file mode 100644 index 0000000000..255f35c942 --- /dev/null +++ b/internal/friction/patterns.go @@ -0,0 +1,101 @@ +package friction + +import ( + "fmt" + "math" + "strings" + "time" + + "go.kenn.io/agentsview/internal/signals" +) + +// DetectPatterns turns the reused quality signals into per-occurrence +// pattern signals (spec §6.6, D14) and appends the ported +// iteration_runaway last, as jilog's detect_health_patterns does +// (health.rs:59-66). +func DetectPatterns( + subjectID string, isSubAgent bool, in PatternInput, +) []Signal { + times := make(map[signals.CallPos]time.Time, len(in.Calls)) + for i, c := range in.Calls { + if i < len(in.CallTimes) { + times[signals.CallPos{ + MessageOrdinal: c.MessageOrdinal, CallIndex: c.CallIndex, + }] = in.CallTimes[i] + } + } + span := func(first, last signals.CallPos) (string, time.Time) { + return formatRange(times[first], times[last]), times[first] + } + var out []Signal + add := func(kind, description, evidence string, ordinal *int, at time.Time) { + out = append(out, Signal{ + Kind: KindPattern, SubjectID: subjectID, SubjectKind: SubjectSession, + Detector: PatternDetector(kind), Label: kind, + Text: description, Evidence: evidence, + Ordinal: ordinal, OccurredAt: at, + }) + } + + for _, r := range signals.RetryRuns(in.Calls) { + tool := r.ToolName + if tool == "" { + tool = "unknown" + } + rng, at := span(r.First, r.Last) + add(PatternRetryLoop, + fmt.Sprintf("retry loop: `%s` called %d times with identical arguments", tool, r.Count), + fmt.Sprintf("`%s` x%d identical arguments %s", tool, r.Count, rng), + new(r.First.MessageOrdinal), at) + } + if first, last, n, ok := signals.RunawayToolLoopSpan(in.Calls); ok { + rng, at := span(first, last) + add(PatternRunawayLoop, + fmt.Sprintf("runaway tool loop: %d tool calls with repeated failures", n), + fmt.Sprintf("%d tool calls %s", n, rng), + new(first.MessageOrdinal), at) + } + for _, c := range signals.EditChurnFiles(in.Calls) { + file := baseName(c.FilePath) + rng, at := span(c.First, c.Last) + add(PatternEditChurn, + fmt.Sprintf("edit churn: `%s` edited %d times within 10 messages", file, c.Count), + fmt.Sprintf("`%s` x%d edits %s", file, c.Count, rng), + new(c.First.MessageOrdinal), at) + } + if n := in.MidTaskCompactions; n > 0 && len(in.CompactBoundaries) > 0 { + var first, last time.Time + if k := len(in.BoundaryTimes); k > 0 { + first, last = in.BoundaryTimes[0], in.BoundaryTimes[k-1] + } + add(PatternMidTaskCompaction, + fmt.Sprintf("mid-task compaction: %d compactions during active work", n), + fmt.Sprintf("%d compactions %s", n, formatRange(first, last)), + new(in.CompactBoundaries[0]), first) + } + if p := in.PressureMax; p != nil && *p > signals.HighContextPressure { + pct := int(math.Round(*p * 100)) + evidence := fmt.Sprintf("peak %d%%", pct) + if !in.PressureAt.IsZero() { + evidence += " at " + in.PressureAt.UTC().Format("15:04") + } + add(PatternContextPressure, + fmt.Sprintf("context pressure: peak %d%% of the model window", pct), + evidence, nil, in.PressureAt) + } + if s, ok := detectIterationRunaway(subjectID, isSubAgent, in); ok { + s.SubjectKind = SubjectSession + out = append(out, s) + } + return out +} + +// baseName keeps only the last path element, for either separator, so +// no directory reaches titles or issue bodies (spec §6.6, §21). +func baseName(p string) string { + p = strings.TrimRight(p, `/\`) + if i := strings.LastIndexAny(p, `/\`); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/internal/friction/patterns_test.go b/internal/friction/patterns_test.go new file mode 100644 index 0000000000..0ab43e1db3 --- /dev/null +++ b/internal/friction/patterns_test.go @@ -0,0 +1,148 @@ +package friction + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/signals" +) + +func patternInput(calls []signals.ToolCallRow, start time.Time) PatternInput { + in := PatternInput{Calls: calls} + for i := range calls { + in.CallTimes = append(in.CallTimes, start.Add(time.Duration(i)*time.Minute)) + } + return in +} + +func TestDetectPatterns(t *testing.T) { + base := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC) + ord := func(i int) *int { return &i } + + t.Run("retry loop per run", func(t *testing.T) { + var calls []signals.ToolCallRow + for i := range 4 { + calls = append(calls, signals.ToolCallRow{ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"cargo build"}`, MessageOrdinal: 10 + 2*i}) + } + got := DetectPatterns("s1", false, patternInput(calls, base)) + require.Len(t, got, 1) + assert.Equal(t, Signal{ + Kind: KindPattern, SubjectID: "s1", SubjectKind: SubjectSession, + Detector: "pattern.retry_loop", + Label: "retry_loop", + Text: "retry loop: `Bash` called 4 times with identical arguments", + Evidence: "`Bash` x4 identical arguments 09:00-09:03", + Ordinal: ord(10), OccurredAt: base, + }, got[0]) + }) + + t.Run("retry loop with empty tool name says unknown", func(t *testing.T) { + calls := []signals.ToolCallRow{{InputJSON: "{}"}, {InputJSON: "{}"}, {InputJSON: "{}"}} + got := DetectPatterns("s1", false, patternInput(calls, base)) + require.Len(t, got, 1) + assert.Equal(t, "retry loop: `unknown` called 3 times with identical arguments", got[0].Text) + }) + + t.Run("runaway loop", func(t *testing.T) { + var calls []signals.ToolCallRow + for i := range 12 { + calls = append(calls, signals.ToolCallRow{ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"npm run step-` + string(rune('a'+i)) + `"}`, + EventStatus: "errored", MessageOrdinal: i + 1}) + } + got := DetectPatterns("s1", false, patternInput(calls, base)) + require.Len(t, got, 1) + assert.Equal(t, "pattern.runaway_loop", got[0].Detector) + assert.Equal(t, "runaway tool loop: 12 tool calls with repeated failures", got[0].Text) + assert.Equal(t, "12 tool calls 09:00-09:11", got[0].Evidence) + assert.Equal(t, ord(1), got[0].Ordinal) + }) + + t.Run("edit_churn_uses_base_name_for_posix_and_windows_paths", func(t *testing.T) { + mk := func(path string, o int) signals.ToolCallRow { + return signals.ToolCallRow{ToolName: "Edit", Category: "Edit", + InputJSON: `{"file_path":"` + path + `","old_string":"` + string(rune('a'+o)) + `"}`, + MessageOrdinal: o} + } + win := `C:\\Users\\user\\app\\main.go` + calls := []signals.ToolCallRow{ + mk("/home/user/app/internal/db/store.go", 1), mk(win, 2), + mk("/home/user/app/internal/db/store.go", 3), mk(win, 4), + mk("/home/user/app/internal/db/store.go", 5), mk(win, 6), + } + got := DetectPatterns("s1", false, patternInput(calls, base)) + require.Len(t, got, 2) + assert.Equal(t, "edit churn: `store.go` edited 3 times within 10 messages", got[0].Text) + assert.Equal(t, "`store.go` x3 edits 09:00-09:04", got[0].Evidence) + assert.Equal(t, "edit churn: `main.go` edited 3 times within 10 messages", got[1].Text) + for _, s := range got { + assert.NotContains(t, s.Text+s.Evidence, "/home/") + assert.NotContains(t, s.Text+s.Evidence, `Users`) + } + }) + + t.Run("mid task compaction", func(t *testing.T) { + in := PatternInput{ + CompactBoundaries: []int{7, 20}, + BoundaryTimes: []time.Time{base.Add(time.Minute), base.Add(8 * time.Minute)}, + MidTaskCompactions: 2, + } + got := DetectPatterns("s1", false, in) + require.Len(t, got, 1) + assert.Equal(t, "mid-task compaction: 2 compactions during active work", got[0].Text) + assert.Equal(t, "2 compactions 09:01-09:08", got[0].Evidence) + assert.Equal(t, ord(7), got[0].Ordinal) + }) + + t.Run("no mid task compaction when count is zero", func(t *testing.T) { + in := PatternInput{CompactBoundaries: []int{7}, BoundaryTimes: []time.Time{base}} + assert.Empty(t, DetectPatterns("s1", false, in)) + }) + + t.Run("context pressure above threshold only", func(t *testing.T) { + at := signals.HighContextPressure + above := 0.934 + assert.Empty(t, DetectPatterns("s1", false, PatternInput{PressureMax: &at})) + got := DetectPatterns("s1", false, PatternInput{ + PressureMax: &above, PressureAt: base.Add(125 * time.Minute), + }) + require.Len(t, got, 1) + assert.Equal(t, "context pressure: peak 93% of the model window", got[0].Text) + assert.Equal(t, "peak 93% at 11:05", got[0].Evidence) + assert.Nil(t, got[0].Ordinal) + assert.Equal(t, base.Add(125*time.Minute), got[0].OccurredAt) + }) + + t.Run("context pressure without a peak time", func(t *testing.T) { + p := 1.2 + got := DetectPatterns("s1", false, PatternInput{PressureMax: &p}) + require.Len(t, got, 1) + assert.Equal(t, "peak 120%", got[0].Evidence) + }) + + t.Run("iteration runaway is last and sub-agents are exempt", func(t *testing.T) { + var calls []signals.ToolCallRow + for i := range 150 { + calls = append(calls, signals.ToolCallRow{ToolName: "Read", Category: "Read", + InputJSON: `{"file_path":"f` + string(rune('0'+i%10)) + `"}`, MessageOrdinal: i + 1}) + } + p := 0.99 + in := patternInput(calls, base) + in.PressureMax, in.PressureAt = &p, base + root := DetectPatterns("s1", false, in) + require.NotEmpty(t, root) + assert.Equal(t, "pattern.iteration_runaway", root[len(root)-1].Detector) + assert.Equal(t, "pattern.context_pressure", root[len(root)-2].Detector) + for _, s := range DetectPatterns("s1", true, in) { + assert.NotEqual(t, "pattern.iteration_runaway", s.Detector) + } + }) + + t.Run("empty input", func(t *testing.T) { + assert.Empty(t, DetectPatterns("s1", false, PatternInput{})) + }) +} From a111e2eb833c74a240ce8a559c3e1eee5688ecfb Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:10:07 +0000 Subject: [PATCH 11/62] fix(friction): omit unknown pattern time ranges --- internal/friction/iteration.go | 10 +++- internal/friction/iteration_test.go | 2 +- internal/friction/patterns.go | 17 ++++--- internal/friction/patterns_test.go | 74 +++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 9 deletions(-) diff --git a/internal/friction/iteration.go b/internal/friction/iteration.go index 61743e9ca1..0bb57d377a 100644 --- a/internal/friction/iteration.go +++ b/internal/friction/iteration.go @@ -12,6 +12,14 @@ func formatRange(first, last time.Time) string { return first.UTC().Format("15:04") + "-" + last.UTC().Format("15:04") } +// rangeSuffix omits the span when either endpoint time is unknown. +func rangeSuffix(first, last time.Time) string { + if first.IsZero() || last.IsZero() { + return "" + } + return " " + formatRange(first, last) +} + type iterationEvent struct { ordinal int user bool @@ -86,7 +94,7 @@ func detectIterationRunaway(subjectID string, isSubAgent bool, in PatternInput) Detector: PatternDetector(PatternIterationRunaway), Label: PatternIterationRunaway, Text: fmt.Sprintf("iteration runaway: %d tool calls with no intervening user message", bestCount), - Evidence: fmt.Sprintf("%d tool calls without a user message %s", bestCount, formatRange(start, end)), + Evidence: fmt.Sprintf("%d tool calls without a user message%s", bestCount, rangeSuffix(start, end)), Ordinal: new(in.Calls[bestFirst].MessageOrdinal), OccurredAt: start, }, true diff --git a/internal/friction/iteration_test.go b/internal/friction/iteration_test.go index 36630dccac..31577e5733 100644 --- a/internal/friction/iteration_test.go +++ b/internal/friction/iteration_test.go @@ -145,6 +145,6 @@ func TestDetectIterationRunawayMissingTimes(t *testing.T) { in.CallTimes = nil sig, ok := detectIterationRunaway("s1", false, in) require.True(t, ok) - assert.Equal(t, "150 tool calls without a user message 00:00-00:00", sig.Evidence) + assert.Equal(t, "150 tool calls without a user message", sig.Evidence) assert.True(t, sig.OccurredAt.IsZero()) } diff --git a/internal/friction/patterns.go b/internal/friction/patterns.go index 255f35c942..c36717bc9f 100644 --- a/internal/friction/patterns.go +++ b/internal/friction/patterns.go @@ -25,7 +25,7 @@ func DetectPatterns( } } span := func(first, last signals.CallPos) (string, time.Time) { - return formatRange(times[first], times[last]), times[first] + return rangeSuffix(times[first], times[last]), times[first] } var out []Signal add := func(kind, description, evidence string, ordinal *int, at time.Time) { @@ -45,14 +45,14 @@ func DetectPatterns( rng, at := span(r.First, r.Last) add(PatternRetryLoop, fmt.Sprintf("retry loop: `%s` called %d times with identical arguments", tool, r.Count), - fmt.Sprintf("`%s` x%d identical arguments %s", tool, r.Count, rng), + fmt.Sprintf("`%s` x%d identical arguments%s", tool, r.Count, rng), new(r.First.MessageOrdinal), at) } if first, last, n, ok := signals.RunawayToolLoopSpan(in.Calls); ok { rng, at := span(first, last) add(PatternRunawayLoop, fmt.Sprintf("runaway tool loop: %d tool calls with repeated failures", n), - fmt.Sprintf("%d tool calls %s", n, rng), + fmt.Sprintf("%d tool calls%s", n, rng), new(first.MessageOrdinal), at) } for _, c := range signals.EditChurnFiles(in.Calls) { @@ -60,17 +60,20 @@ func DetectPatterns( rng, at := span(c.First, c.Last) add(PatternEditChurn, fmt.Sprintf("edit churn: `%s` edited %d times within 10 messages", file, c.Count), - fmt.Sprintf("`%s` x%d edits %s", file, c.Count, rng), + fmt.Sprintf("`%s` x%d edits%s", file, c.Count, rng), new(c.First.MessageOrdinal), at) } if n := in.MidTaskCompactions; n > 0 && len(in.CompactBoundaries) > 0 { var first, last time.Time - if k := len(in.BoundaryTimes); k > 0 { - first, last = in.BoundaryTimes[0], in.BoundaryTimes[k-1] + if len(in.BoundaryTimes) > 0 { + first = in.BoundaryTimes[0] + } + if lastIndex := len(in.CompactBoundaries) - 1; lastIndex < len(in.BoundaryTimes) { + last = in.BoundaryTimes[lastIndex] } add(PatternMidTaskCompaction, fmt.Sprintf("mid-task compaction: %d compactions during active work", n), - fmt.Sprintf("%d compactions %s", n, formatRange(first, last)), + fmt.Sprintf("%d compactions%s", n, rangeSuffix(first, last)), new(in.CompactBoundaries[0]), first) } if p := in.PressureMax; p != nil && *p > signals.HighContextPressure { diff --git a/internal/friction/patterns_test.go b/internal/friction/patterns_test.go index 0ab43e1db3..65ccf560ff 100644 --- a/internal/friction/patterns_test.go +++ b/internal/friction/patterns_test.go @@ -47,6 +47,31 @@ func TestDetectPatterns(t *testing.T) { assert.Equal(t, "retry loop: `unknown` called 3 times with identical arguments", got[0].Text) }) + t.Run("retry loop omits unknown call-time ranges", func(t *testing.T) { + calls := []signals.ToolCallRow{ + {ToolName: "Bash", InputJSON: "{}", MessageOrdinal: 2}, + {ToolName: "Bash", InputJSON: "{}", MessageOrdinal: 3}, + {ToolName: "Bash", InputJSON: "{}", MessageOrdinal: 4}, + } + for _, tt := range []struct { + name string + times []time.Time + at time.Time + }{ + {"missing", nil, time.Time{}}, + {"short", []time.Time{base}, base}, + {"zero first", []time.Time{{}, base.Add(time.Minute), base.Add(2 * time.Minute)}, time.Time{}}, + {"zero last", []time.Time{base, base.Add(time.Minute), {}}, base}, + } { + t.Run(tt.name, func(t *testing.T) { + got := DetectPatterns("s1", false, PatternInput{Calls: calls, CallTimes: tt.times}) + require.Len(t, got, 1) + assert.Equal(t, "`Bash` x3 identical arguments", got[0].Evidence) + assert.Equal(t, tt.at, got[0].OccurredAt) + }) + } + }) + t.Run("runaway loop", func(t *testing.T) { var calls []signals.ToolCallRow for i := range 12 { @@ -98,6 +123,29 @@ func TestDetectPatterns(t *testing.T) { assert.Equal(t, ord(7), got[0].Ordinal) }) + t.Run("mid task compaction omits unknown boundary-time ranges", func(t *testing.T) { + for _, tt := range []struct { + name string + times []time.Time + at time.Time + }{ + {"missing", nil, time.Time{}}, + {"short", []time.Time{base}, base}, + {"short with zero", []time.Time{{}}, time.Time{}}, + {"zero first", []time.Time{{}, base.Add(8 * time.Minute)}, time.Time{}}, + {"zero last", []time.Time{base, {}}, base}, + } { + t.Run(tt.name, func(t *testing.T) { + got := DetectPatterns("s1", false, PatternInput{ + CompactBoundaries: []int{7, 20}, BoundaryTimes: tt.times, MidTaskCompactions: 2, + }) + require.Len(t, got, 1) + assert.Equal(t, "2 compactions", got[0].Evidence) + assert.Equal(t, tt.at, got[0].OccurredAt) + }) + } + }) + t.Run("no mid task compaction when count is zero", func(t *testing.T) { in := PatternInput{CompactBoundaries: []int{7}, BoundaryTimes: []time.Time{base}} assert.Empty(t, DetectPatterns("s1", false, in)) @@ -142,6 +190,32 @@ func TestDetectPatterns(t *testing.T) { } }) + t.Run("iteration runaway omits unknown call-time ranges", func(t *testing.T) { + var calls []signals.ToolCallRow + for i := range 150 { + calls = append(calls, signals.ToolCallRow{ + ToolName: "Read", InputJSON: string(rune('a' + i%10)), MessageOrdinal: i + 1, + }) + } + for _, tt := range []struct { + name string + times []time.Time + at time.Time + }{ + {"missing", nil, time.Time{}}, + {"short", []time.Time{base}, base}, + {"zero first", append([]time.Time{{}}, patternInput(calls[1:], base).CallTimes...), time.Time{}}, + {"zero last", append(patternInput(calls[:149], base).CallTimes, time.Time{}), base}, + } { + t.Run(tt.name, func(t *testing.T) { + got := DetectPatterns("s1", false, PatternInput{Calls: calls, CallTimes: tt.times}) + require.Len(t, got, 1) + assert.Equal(t, "150 tool calls without a user message", got[0].Evidence) + assert.Equal(t, tt.at, got[0].OccurredAt) + }) + } + }) + t.Run("empty input", func(t *testing.T) { assert.Empty(t, DetectPatterns("s1", false, PatternInput{})) }) From 7c8b3721a085e88e3c375fee492d3a56e31595c2 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:28:57 +0000 Subject: [PATCH 12/62] feat(friction): add frustration and interruption kinds and review a session --- internal/friction/frustration.go | 29 ++++++++++ internal/friction/interruptions.go | 21 +++++++ internal/friction/kinds_test.go | 92 ++++++++++++++++++++++++++++++ internal/friction/review.go | 38 ++++++++++++ internal/friction/review_test.go | 83 +++++++++++++++++++++++++++ 5 files changed, 263 insertions(+) create mode 100644 internal/friction/frustration.go create mode 100644 internal/friction/interruptions.go create mode 100644 internal/friction/kinds_test.go create mode 100644 internal/friction/review.go create mode 100644 internal/friction/review_test.go diff --git a/internal/friction/frustration.go b/internal/friction/frustration.go new file mode 100644 index 0000000000..ebae7635cb --- /dev/null +++ b/internal/friction/frustration.go @@ -0,0 +1,29 @@ +package friction + +import "go.kenn.io/agentsview/internal/signals" + +// frustrationTextRunes bounds the stored frustration text (spec §6.8). +const frustrationTextRunes = 200 + +// DetectFrustration persists agentsview's frustration markers as +// friction (spec §6.8, D36): one signal per user message for which +// signals.IsFrustrationMarker is true. The analytics rescan keeps its +// own count; this only records occurrences. +func DetectFrustration(msgs []Message, subjectID string) []Signal { + var out []Signal + for _, m := range msgs { + if m.Role != "user" || !signals.IsFrustrationMarker(m.Text) { + continue + } + out = append(out, Signal{ + Kind: KindFrustration, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: DetectorFrustration, + Text: TruncateRunes(m.Text, frustrationTextRunes), + Ordinal: new(m.Ordinal), + OccurredAt: m.Timestamp, + }) + } + return out +} diff --git a/internal/friction/interruptions.go b/internal/friction/interruptions.go new file mode 100644 index 0000000000..e2d89726a3 --- /dev/null +++ b/internal/friction/interruptions.go @@ -0,0 +1,21 @@ +package friction + +// DetectInterruptions emits one signal per interrupted row (spec §6.8, +// D36). BuildSessionInput collects those rows in +// SessionInput.Interruptions because they are system rows the +// correction stream drops (D10). Text stays empty: the row is a fixed +// marker, and the finding's title is one identity per session. +func DetectInterruptions(marks []Message, subjectID string) []Signal { + var out []Signal + for _, m := range marks { + out = append(out, Signal{ + Kind: KindInterruption, + SubjectID: subjectID, + SubjectKind: SubjectSession, + Detector: DetectorInterruption, + Ordinal: new(m.Ordinal), + OccurredAt: m.Timestamp, + }) + } + return out +} diff --git a/internal/friction/kinds_test.go b/internal/friction/kinds_test.go new file mode 100644 index 0000000000..ca38a57deb --- /dev/null +++ b/internal/friction/kinds_test.go @@ -0,0 +1,92 @@ +package friction + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var kindsBase = time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC) + +func TestDetectFrustration(t *testing.T) { + tests := []struct { + name string + text string + want bool + }{ + {"phrase !!!", "why is this failing!!!", true}, + {"phrase ???", "what happened to the build???", true}, + {"phrase wtf", "wtf is this output", true}, + {"phrase come on", "come on, just run the tests", true}, + {"phrase why won't", "why won't the build pass", true}, + {"phrase this is broken", "this is broken again", true}, + {"phrase doesn't work", "the fix doesn't work here", true}, + {"phrase does not work", "the fix does not work here", true}, + {"phrase still broken", "the login page is still broken", true}, + {"phrase same error", "I get the same error now", true}, + {"phrase you broke", "you broke the login page", true}, + {"phrase fucking", "this fucking test again", true}, + {"phrase fuck", "fuck, the test failed", true}, + {"caps case", "WHY IS THE BUILD FAILING AGAIN", true}, + {"calm prompt", "please run the tests again", false}, + {"too short after normalizing", "wtf", false}, + {"phrase only inside a code fence", "please look at this log:\n```\nsame error\n```", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgs := []Message{ + {Ordinal: 3, Role: "user", Text: tt.text, Timestamp: kindsBase}, + {Ordinal: 4, Role: "assistant", Text: tt.text, Timestamp: kindsBase}, + } + got := DetectFrustration(msgs, "s1") + if !tt.want { + assert.Empty(t, got) + return + } + assert.Equal(t, []Signal{{ + Kind: KindFrustration, SubjectID: "s1", SubjectKind: SubjectSession, + Detector: DetectorFrustration, Text: tt.text, + Ordinal: new(3), OccurredAt: kindsBase, + }}, got, "assistant text never counts") + }) + } + + t.Run("text truncated to 200 runes", func(t *testing.T) { + long := "this is broken " + strings.Repeat("é", 300) + got := DetectFrustration([]Message{{Ordinal: 1, Role: "user", Text: long}}, "s1") + require.Len(t, got, 1) + assert.Equal(t, 200, len([]rune(got[0].Text))) + }) +} + +func TestDetectInterruptions(t *testing.T) { + tests := []struct { + name string + marks []Message + want []Signal + }{ + {"none", nil, nil}, + { + "each interruption counts and carries no text", + []Message{ + {Ordinal: 6, Role: "user", Text: "[Request interrupted by user]", Timestamp: kindsBase}, + {Ordinal: 9, Role: "user", Text: "[Request interrupted by user for tool use]", + Timestamp: kindsBase.Add(time.Minute)}, + }, + []Signal{ + {Kind: KindInterruption, SubjectID: "s1", SubjectKind: SubjectSession, + Detector: DetectorInterruption, Ordinal: new(6), OccurredAt: kindsBase}, + {Kind: KindInterruption, SubjectID: "s1", SubjectKind: SubjectSession, + Detector: DetectorInterruption, Ordinal: new(9), OccurredAt: kindsBase.Add(time.Minute)}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, DetectInterruptions(tt.marks, "s1")) + }) + } +} diff --git a/internal/friction/review.go b/internal/friction/review.go new file mode 100644 index 0000000000..52cfef42b4 --- /dev/null +++ b/internal/friction/review.go @@ -0,0 +1,38 @@ +package friction + +// Review runs every detector over one session: corrections, errors, +// workarounds, deferrals, patterns (jilog's run order, +// digest.rs:425-437), then frustration and interruptions (spec §6.8). +// A persona selects the chat correction detector and nothing else does +// (spec §6.2). Dims are stamped on every signal, with persona and +// channel only when a persona is set (digest.rs:385-463), SubjectKind +// is SubjectSession, and Seq records the run order. Excluded sessions +// yield nil. +func Review(in SessionInput) []Signal { + if in.Excluded { + return nil + } + var out []Signal + if in.Dims.Persona != "" { + out = append(out, DetectCorrectionsChat(in.Messages, in.SubjectID)...) + } else { + out = append(out, DetectCorrections(in.Messages, in.SubjectID)...) + } + out = append(out, DetectErrors(in.Messages, in.SubjectID)...) + out = append(out, DetectWorkarounds(in.Messages, in.SubjectID)...) + out = append(out, DetectDeferrals(in.Messages, in.SubjectID)...) + out = append(out, DetectPatterns(in.SubjectID, in.IsSubAgent, in.Patterns)...) + out = append(out, DetectFrustration(in.Messages, in.SubjectID)...) + out = append(out, DetectInterruptions(in.Interruptions, in.SubjectID)...) + + dims := in.Dims + if dims.Persona == "" { + dims.Channel = "" + } + for i := range out { + out[i].Dims = dims + out[i].SubjectKind = SubjectSession + out[i].Seq = i + } + return out +} diff --git a/internal/friction/review_test.go b/internal/friction/review_test.go new file mode 100644 index 0000000000..922632c61b --- /dev/null +++ b/internal/friction/review_test.go @@ -0,0 +1,83 @@ +package friction + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var reviewBase = time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC) + +func rmin(m int) time.Time { return reviewBase.Add(time.Duration(m) * time.Minute) } + +func reviewFixture(dims Dims) SessionInput { + msgs := []RawMessage{ + {Ordinal: 0, Role: "user", Content: "please run the tests", Timestamp: rmin(0)}, + {Ordinal: 1, Role: "assistant", Content: "Skipping for now, I'll come back to this.", Timestamp: rmin(1)}, + {Ordinal: 2, Role: "user", Content: "thanks, that looks really great", Timestamp: rmin(2)}, + {Ordinal: 3, Role: "assistant", Content: "Ran them.", Timestamp: rmin(3)}, + {Ordinal: 4, Role: "user", Content: "this is broken again, same error", Timestamp: rmin(4)}, + {Ordinal: 5, Role: "assistant", Content: "Looking.", Timestamp: rmin(5)}, + {Ordinal: 6, Role: "user", Content: "[Request interrupted by user]", IsSystem: true, + SourceSubtype: SourceSubtypeInterrupted, Timestamp: rmin(6)}, + } + // The failing call belongs to the last assistant turn, so its tool + // message does not break either (assistant, user, assistant) triple. + calls := []RawToolCall{{ + MessageOrdinal: 5, ToolName: "Bash", Category: "Bash", + EventStatus: "errored", LastEventContent: "error[E0308]: mismatched types", + }} + return BuildSessionInput("s1", dims, false, msgs, calls, BuildOptions{}) +} + +func TestReview(t *testing.T) { + t.Run("coding session order dims subject kind and seq", func(t *testing.T) { + dims := Dims{Seat: "seat-01", Agent: "claude", Machine: "laptop", Channel: "ignored"} + got := Review(reviewFixture(dims)) + var detectors []string + for i, s := range got { + detectors = append(detectors, s.Detector) + assert.Equal(t, i, s.Seq) + assert.Equal(t, SubjectSession, s.SubjectKind) + assert.Equal(t, Dims{Seat: "seat-01", Agent: "claude", Machine: "laptop"}, s.Dims, + "channel is stamped only with a persona") + } + assert.Equal(t, []string{ + DetectorCorrectionCoding, DetectorCorrectionCoding, + DetectorError, DetectorWorkaround, DetectorDeferral, + DetectorFrustration, DetectorInterruption, + }, detectors) + }) + + t.Run("persona selects chat corrections", func(t *testing.T) { + dims := Dims{Persona: "helper", Channel: "general"} + got := Review(reviewFixture(dims)) + require.NotEmpty(t, got) + for _, s := range got { + assert.NotEqual(t, DetectorCorrectionCoding, s.Detector) + assert.Equal(t, dims, s.Dims) + } + assert.Equal(t, DetectorError, got[0].Detector, + "plain short chat is not a chat correction") + }) + + t.Run("excluded session yields nil", func(t *testing.T) { + in := reviewFixture(Dims{}) + in.Excluded = true + assert.Nil(t, Review(in)) + }) + + t.Run("patterns precede the agentsview kinds", func(t *testing.T) { + in := reviewFixture(Dims{}) + p := 0.97 + in.Patterns.PressureMax = &p + got := Review(in) + n := len(got) + assert.Equal(t, "pattern.context_pressure", got[n-3].Detector) + assert.Equal(t, DetectorFrustration, got[n-2].Detector) + assert.Equal(t, DetectorInterruption, got[n-1].Detector) + assert.Equal(t, n-1, got[n-1].Seq) + }) +} From 3d8da47f2826589bb463d171a209b24000b4d3c9 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:12:37 +0000 Subject: [PATCH 13/62] test(friction): cover the adapter on archived Claude rows --- docs/internal/jilog-adaptation.md | 77 ++++++-- internal/friction/adapter_archive_test.go | 219 ++++++++++++++++++++++ 2 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 internal/friction/adapter_archive_test.go diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md index e07deb9307..d7c2f8a864 100644 --- a/docs/internal/jilog-adaptation.md +++ b/docs/internal/jilog-adaptation.md @@ -10,10 +10,10 @@ AgentsView's Friction Log adapts the session review from behavioral reference. This page records the source, the changes, and the MIT notice. Source paths below are relative to that jilog commit. -PR 1 provides pure detection, signal, formatting, and JSON packages. The -archive adapter, persisted review, digest rendering, scheduling, NanoClaw -integration, and Kata filing are planned for later PRs. Descriptions of those -parts below record the approved mapping; they do not describe PR 1 behavior. +PR 1 provides pure detection, signal, formatting, and JSON packages. PR 2 adds +the pure archived-row adapter, pattern detection, and session review. +Persistence, digest rendering, scheduling, NanoClaw integration, and Kata filing +remain planned for later PRs. ## Kept in PR 1 @@ -41,12 +41,6 @@ parts below record the approved mapping; they do not describe PR 1 behavior. ## Planned replacements -- AgentsView parsers and its archive replace jilog's transcript readers. An - adapter will build detector input from archived sessions in PR 2. -- Existing retry, runaway-loop, edit-churn, mid-task-compaction, and - context-pressure signals replace jilog's `stuck_loop` and - `compaction_storm`. The review will expose them as pattern kinds. -- Session parent relationships replace jilog's 16-zero sub-agent ID prefix. - Database tables replace the processed-sessions file and retry sidecar. A catch-up job will build each completed local day once, replacing the nightly run. @@ -58,6 +52,15 @@ parts below record the approved mapping; they do not describe PR 1 behavior. message-envelope cleanup will retain the public NanoClaw behavior. They will be inactive until a NanoClaw data directory is configured. +## Kept in PR 2 + +- AgentsView parser and archive rows replace jilog's transcript readers. + `BuildSessionInput` prepares detector input from those rows. +- Existing retry, runaway-loop, edit-churn, mid-task-compaction, and + context-pressure signals replace jilog's `stuck_loop` and + `compaction_storm` as pattern kinds. +- Session parent relationships replace jilog's 16-zero sub-agent ID prefix. + ## Dropped - `resume_storm`, whose only source is Amplifier `session:resume` events. @@ -70,27 +73,65 @@ parts below record the approved mapping; they do not describe PR 1 behavior. - Migration or matching of existing `[jilog/…]` issues. Friction Log starts with its own issue history. -## Planned additions +## Additions -- `frustration` and `interruption` kinds will use AgentsView's existing +- `frustration` and `interruption` kinds use AgentsView's existing frustration markers and interrupted-turn rows. They do not change a jilog kind. -- Archived tool calls will supply error, P0, and pattern findings. jilog's - AgentsView reader cannot produce those findings from its session rows. -- Detection and digests will be on by default, as running jilog makes them. +- Archived tool calls supply error and pattern findings. jilog's AgentsView + reader cannot produce those findings from its session rows. +- Detection and digests are planned to be on by default, as running jilog + makes them. P0 filing awaits the archive and Kata integration. ## Deliberate differences - Titles use `[friction/]`, labels use `friction`, and the planned digest heading is `# Friction Log — `. -- The planned adapter removes system, compact-boundary, and tool-result rows +- The adapter removes system, compact-boundary, and tool-result rows from every correction stream, extending jilog's NanoClaw rule to all sessions. -- The planned adapter drops thinking blocks and tool renderings from stored +- The adapter drops thinking blocks and tool renderings from stored assistant content, matching jilog's text-block-only extraction. -- The planned adapter synthesizes error envelopes from tool rows and maps the +- The adapter synthesizes error envelopes from tool rows and maps the `Bash` tool category to `bash` for the noise allowlist. +## Session input adapter + +agentsview does not port jilog's file readers. `friction.BuildSessionInput` +maps archived rows into the message stream jilog's detectors read: + +- System rows, compact-boundary rows and `tool_result` fallback rows are + dropped from the stream. This applies jilog's NanoClaw rule to every + session (D10) and closes jilog's Claude Code reader gap, which kept + `isMeta` and compact-summary lines. +- Assistant text drops inline `[Thinking]` blocks and tool-call renderings, + reproducing jilog's text-blocks-only extraction (D11). +- Every tool call becomes a `tool` message whose envelope is + `{"error","success"}`, with success from `signals.IsFailure` (D12). The + noise allowlist keys on `bash` for any Bash-category call. +- Pattern kinds reuse `internal/signals`: `retry_loop`, `runaway_loop`, + `edit_churn`, `mid_task_compaction` and `context_pressure`. + `iteration_runaway` is ported. `resume_storm` is dropped because no + agentsview source records resumes (D14). + +Architecture-forced deltas from jilog: + +- jilog's `stuck_loop` fires at 4 identical calls. `retry_loop` fires at 3, + because it is the same predicate as the Quality page's retry count. +- Compaction storms (3 compactions within 10 minutes) are replaced by + mid-task compactions, the agentsview signal. The evidence range spans all + compact boundaries in the session. +- A user message that mixes text and tool results keeps its text, because + the echo part was removed at parse. jilog would skip it. + +Additions beyond jilog: + +- Frustration markers (`signals.IsFrustrationMarker`) and user interruptions + (rows the Claude parser tags `interrupted`) are friction kinds of their + own. They run after jilog's five kinds. +- Seats come only from `[friction] seat_patterns` globs the user configures. + jilog's built-in pool-directory conventions are not carried over. + ## Parity notes - Go's RE2 `\b` is ASCII-only; Rust's word boundary is Unicode-aware. A diff --git a/internal/friction/adapter_archive_test.go b/internal/friction/adapter_archive_test.go new file mode 100644 index 0000000000..ee20339105 --- /dev/null +++ b/internal/friction/adapter_archive_test.go @@ -0,0 +1,219 @@ +package friction_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/parser" +) + +const sid = "7d3f0e91-2c4b-4e2a-9c1b-44a7f3d8a1e2" + +var base = time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC) + +func ts(minutes int) string { + return base.Add(time.Duration(minutes) * time.Minute).Format(time.RFC3339) +} + +// claudeAssistant builds an assistant row exactly as the Claude parser +// stores it: content, thinking text and tool calls from +// parser.ExtractTextContent over Anthropic content blocks. +func claudeAssistant(t *testing.T, ord int, blocks string, results ...db.ToolResultEvent) db.Message { + t.Helper() + content, thinking, hasThinking, hasToolUse, calls, _ := parser.ExtractTextContent(t.Context(), gjson.Parse(blocks)) + m := dbtest.AsstMsg(sid, ord, content) + m.ThinkingText, m.HasThinking, m.HasToolUse = thinking, hasThinking, hasToolUse + m.Timestamp = ts(ord) + for i, c := range calls { + tc := db.ToolCall{ + SessionID: sid, ToolName: c.ToolName, Category: c.Category, + ToolUseID: c.ToolUseID, InputJSON: c.InputJSON, CallIndex: i, + } + if i < len(results) { + tc.ResultContent = results[i].Content + tc.ResultEvents = []db.ToolResultEvent{results[i]} + } + m.ToolCalls = append(m.ToolCalls, tc) + } + return m +} + +func userRow(ord int, content string) db.Message { + m := dbtest.UserMsg(sid, ord, content) + m.Timestamp = ts(ord) + return m +} + +func systemUserRow(ord int, subtype, content string) db.Message { + m := userRow(ord, content) + m.IsSystem, m.SourceType, m.SourceSubtype = true, "system", subtype + return m +} + +func compactRow(ord int, summary string) db.Message { + m := dbtest.AsstMsg(sid, ord, summary) + m.Timestamp = ts(ord) + m.IsSystem, m.IsCompactBoundary = true, true + m.SourceType, m.SourceSubtype = "system", "compact_boundary" + return m +} + +// rawFromDB is the row mapping PR 3's friction_compute performs. +func rawFromDB(t *testing.T, msgs []db.Message) ([]friction.RawMessage, []friction.RawToolCall) { + t.Helper() + var rm []friction.RawMessage + var rc []friction.RawToolCall + for _, m := range msgs { + at, err := time.Parse(time.RFC3339Nano, m.Timestamp) + require.NoError(t, err) + rm = append(rm, friction.RawMessage{ + Ordinal: m.Ordinal, Role: m.Role, Content: m.Content, + ThinkingText: m.ThinkingText, IsSystem: m.IsSystem, + IsCompactBoundary: m.IsCompactBoundary, SourceSubtype: m.SourceSubtype, + Timestamp: at, ContextTokens: m.ContextTokens, HasContextTokens: m.HasContextTokens, + }) + for i, tc := range m.ToolCalls { + c := friction.RawToolCall{ + MessageOrdinal: m.Ordinal, CallIndex: i, + ToolName: tc.ToolName, Category: tc.Category, + InputJSON: tc.InputJSON, ResultContent: tc.ResultContent, + Timestamp: at, + } + if n := len(tc.ResultEvents); n > 0 { + c.LastEventContent = tc.ResultEvents[n-1].Content + c.EventStatus = tc.ResultEvents[n-1].Status + } + rc = append(rc, c) + } + } + return rm, rc +} + +func reviewArchived(t *testing.T, rows ...db.Message) []friction.Signal { + t.Helper() + d := dbtest.OpenTestDB(t) + dbtest.SeedSession(t, d, sid, "app", dbtest.WithMessageCount(len(rows))) + dbtest.SeedMessages(t, d, rows...) + stored, err := d.GetAllMessages(t.Context(), sid) + require.NoError(t, err) + msgs, calls := rawFromDB(t, stored) + in := friction.BuildSessionInput(sid, friction.Dims{Agent: "claude"}, false, + msgs, calls, friction.BuildOptions{}) + return friction.Review(in) +} + +func detectors(sigs []friction.Signal) []string { + var out []string + for _, s := range sigs { + out = append(out, s.Detector) + } + return out +} + +func TestArchivedClaudeSession(t *testing.T) { + text := func(s string) string { return `[{"type":"text","text":"` + s + `"}]` } + + t.Run("real correction fires as control", func(t *testing.T) { + got := reviewArchived(t, + claudeAssistant(t, 0, text("I changed the config.")), + userRow(1, "no, revert the config change first"), + claudeAssistant(t, 2, text("Reverted.")), + ) + require.Len(t, got, 1) + assert.Equal(t, friction.DetectorCorrectionCoding, got[0].Detector) + assert.Equal(t, "no, revert the config change first", got[0].Text) + require.NotNil(t, got[0].Ordinal) + assert.Equal(t, 1, *got[0].Ordinal) + }) + + t.Run("no_correction_from_system_compact_or_echo_rows", func(t *testing.T) { + got := reviewArchived(t, + claudeAssistant(t, 0, text("Working on it.")), + systemUserRow(1, "continuation", "This session is being continued from a previous conversation."), + claudeAssistant(t, 2, text("Continuing.")), + systemUserRow(3, "interrupted", "[Request interrupted by user for tool use]"), + claudeAssistant(t, 4, text("Stopped.")), + compactRow(5, "Summary: the user asked to stop doing that and fix it"), + userRow(6, "short"), + claudeAssistant(t, 7, text("Next.")), + systemUserRow(8, "stop_hook", "Stop hook feedback: tests must pass first"), + claudeAssistant(t, 9, text("Done.")), + ) + // interrupted_row_one_finding_never_in_correction_window: the + // interrupted row at ordinal 3 sits between assistant turns 2 and 4 + // and is long enough to be a correction, but it yields exactly one + // interruption finding (spec §6.8) and no correction. + assert.Equal(t, []string{friction.DetectorInterruption}, detectors(got)) + require.NotNil(t, got[0].Ordinal) + assert.Equal(t, 3, *got[0].Ordinal) + + // tool_result-subtype rows are the provider fallback echo shape. + echo := userRow(11, "total 12 files listed in the output here") + echo.SourceSubtype = parser.SourceSubtypeToolResult + assert.Empty(t, reviewArchived(t, + claudeAssistant(t, 10, text("Listing.")), echo, + claudeAssistant(t, 12, text("Listed.")))) + }) + + t.Run("no_workaround_or_deferral_from_thinking_or_todowrite", func(t *testing.T) { + blocks := `[ + {"type":"thinking","thinking":"I'll leave this for now and come back to it next session"}, + {"type":"text","text":"Updated the plan."}, + {"type":"tool_use","id":"toolu_01","name":"TodoWrite","input":{"todos":[ + {"content":"Remove the temporary hack","status":"pending","activeForm":"Removing"}]}} + ]` + got := reviewArchived(t, + userRow(0, "plan the refactor"), + claudeAssistant(t, 1, blocks, db.ToolResultEvent{Status: "completed", Content: "Todos updated"}), + ) + assert.Empty(t, got, "workarounds_skip_tool_use_blocks extended with TodoWrite and thinking") + }) + + t.Run("errors_for_errored_and_cancelled_calls", func(t *testing.T) { + blocks := `[ + {"type":"text","text":"Running both."}, + {"type":"tool_use","id":"toolu_a","name":"Bash","input":{"command":"cargo test"}}, + {"type":"tool_use","id":"toolu_b","name":"Read","input":{"file_path":"/home/user/app/a.rs"}} + ]` + got := reviewArchived(t, + userRow(0, "run the tests"), + claudeAssistant(t, 1, blocks, + db.ToolResultEvent{Status: "errored", Content: "error[E0308]: mismatched types"}, + db.ToolResultEvent{Status: "cancelled", Content: "The user doesn't want to proceed"}, + ), + ) + require.Len(t, got, 2) + assert.Equal(t, []string{friction.DetectorError, friction.DetectorError}, detectors(got)) + assert.Equal(t, "Bash", got[0].ToolName) + assert.Equal(t, "error[E0308]: mismatched types", got[0].Text) + assert.Equal(t, "Read", got[1].ToolName) + require.NotNil(t, got[1].CallIndex) + assert.Equal(t, 1, *got[1].CallIndex) + }) + + t.Run("bash_bare_timeout_suppressed", func(t *testing.T) { + blocks := `[{"type":"tool_use","id":"toolu_t","name":"Bash","input":{"command":"sleep 999"}}]` + got := reviewArchived(t, + userRow(0, "wait for it"), + claudeAssistant(t, 1, blocks, + db.ToolResultEvent{Status: "errored", Content: "Command timed out after 120 seconds"}), + ) + assert.Empty(t, got) + }) + + t.Run("workaround in real text still fires", func(t *testing.T) { + got := reviewArchived(t, + userRow(0, "make it work"), + claudeAssistant(t, 1, text("Using a hardcoded value for now.")), + ) + assert.Equal(t, []string{friction.DetectorWorkaround}, detectors(got)) + assert.Equal(t, "for now", got[0].Label) + }) +} From 50a9c2e8b36223e88b2ca447d5b03deb1913b4cc Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:21:01 +0000 Subject: [PATCH 14/62] test(friction): distinguish compact boundaries in archived review --- docs/internal/jilog-adaptation.md | 5 ++-- internal/friction/adapter_archive_test.go | 30 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md index d7c2f8a864..1fd434cce1 100644 --- a/docs/internal/jilog-adaptation.md +++ b/docs/internal/jilog-adaptation.md @@ -129,8 +129,9 @@ Additions beyond jilog: - Frustration markers (`signals.IsFrustrationMarker`) and user interruptions (rows the Claude parser tags `interrupted`) are friction kinds of their own. They run after jilog's five kinds. -- Seats come only from `[friction] seat_patterns` globs the user configures. - jilog's built-in pool-directory conventions are not carried over. +- `SeatFromPath` uses only the patterns its caller provides. Wiring those + patterns to `[friction] seat_patterns` configuration is planned for a later + PR. jilog's built-in pool-directory conventions are not carried over. ## Parity notes diff --git a/internal/friction/adapter_archive_test.go b/internal/friction/adapter_archive_test.go index ee20339105..d062664876 100644 --- a/internal/friction/adapter_archive_test.go +++ b/internal/friction/adapter_archive_test.go @@ -151,6 +151,7 @@ func TestArchivedClaudeSession(t *testing.T) { // and is long enough to be a correction, but it yields exactly one // interruption finding (spec §6.8) and no correction. assert.Equal(t, []string{friction.DetectorInterruption}, detectors(got)) + require.Len(t, got, 1) require.NotNil(t, got[0].Ordinal) assert.Equal(t, 3, *got[0].Ordinal) @@ -162,6 +163,34 @@ func TestArchivedClaudeSession(t *testing.T) { claudeAssistant(t, 12, text("Listed.")))) }) + t.Run("compact_boundary_cannot_create_correction_window", func(t *testing.T) { + // If the compact summary survives as an assistant turn, it makes + // ordinal 3 look like a correction between two assistant turns. + // Dropping it leaves two adjacent user turns and no correction. + got := reviewArchived(t, + claudeAssistant(t, 0, text("Working on the config.")), + userRow(1, "ok"), + compactRow(2, "Summary: the config work continues"), + userRow(3, "no, revert the config change first"), + claudeAssistant(t, 4, text("Reverted.")), + ) + assert.Empty(t, got) + + // A regular assistant row in the same position creates the + // correction, so this case is sensitive to the boundary filter. + retained := reviewArchived(t, + claudeAssistant(t, 0, text("Working on the config.")), + userRow(1, "ok"), + claudeAssistant(t, 2, text("Summary: the config work continues")), + userRow(3, "no, revert the config change first"), + claudeAssistant(t, 4, text("Reverted.")), + ) + require.Len(t, retained, 1) + assert.Equal(t, friction.DetectorCorrectionCoding, retained[0].Detector) + require.NotNil(t, retained[0].Ordinal) + assert.Equal(t, 3, *retained[0].Ordinal) + }) + t.Run("no_workaround_or_deferral_from_thinking_or_todowrite", func(t *testing.T) { blocks := `[ {"type":"thinking","thinking":"I'll leave this for now and come back to it next session"}, @@ -214,6 +243,7 @@ func TestArchivedClaudeSession(t *testing.T) { claudeAssistant(t, 1, text("Using a hardcoded value for now.")), ) assert.Equal(t, []string{friction.DetectorWorkaround}, detectors(got)) + require.Len(t, got, 1) assert.Equal(t, "for now", got[0].Label) }) } From b41270622b6ed2e8657c2fd19e0efead8743a0f4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:40:59 +0000 Subject: [PATCH 15/62] fix(signals): avoid nullable edit churn map values --- internal/signals/toolhealth.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/signals/toolhealth.go b/internal/signals/toolhealth.go index 186ddd182c..ff9c3a84dc 100644 --- a/internal/signals/toolhealth.go +++ b/internal/signals/toolhealth.go @@ -241,7 +241,7 @@ func EditChurnFiles(calls []ToolCallRow) []EditChurn { pos []CallPos } var order []string - byFile := map[string]*fileEdits{} + byFile := map[string]fileEdits{} for _, c := range calls { if c.Category != "Edit" && c.Category != "Write" { continue @@ -252,12 +252,11 @@ func EditChurnFiles(calls []ToolCallRow) []EditChurn { } fe, ok := byFile[path] if !ok { - fe = &fileEdits{} - byFile[path] = fe order = append(order, path) } fe.ords = append(fe.ords, c.MessageOrdinal) fe.pos = append(fe.pos, toolCallPos(c)) + byFile[path] = fe } var out []EditChurn for _, path := range order { From d7cc450d21cfc0bd012946a8770a53091e024ad4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:43:02 +0000 Subject: [PATCH 16/62] style(tests): apply lint autofixes --- internal/friction/assistant_text_test.go | 28 +++++++--- internal/friction/input_test.go | 53 ++++++++++++------ internal/friction/kinds_test.go | 20 ++++--- internal/friction/patterns_test.go | 24 +++++--- internal/friction/review_test.go | 6 +- internal/friction/seat_test.go | 70 +++++++++++++++++------- internal/signals/spans_test.go | 12 +++- 7 files changed, 148 insertions(+), 65 deletions(-) diff --git a/internal/friction/assistant_text_test.go b/internal/friction/assistant_text_test.go index 13c4bd7535..9ac643fbc5 100644 --- a/internal/friction/assistant_text_test.go +++ b/internal/friction/assistant_text_test.go @@ -33,12 +33,16 @@ func TestAssistantText(t *testing.T) { {"plain text unchanged", "hello\nworld", "", nil, false, "hello\nworld"}, { "tool rendering between text blocks", - "hello\n" + readR + "\nworld", "", []RawToolCall{read}, false, + "hello\n" + readR + "\nworld", "", + []RawToolCall{read}, + false, "hello\nworld", }, { "todo rendering removed", - "Let me update the plan.\n" + todoR, "", []RawToolCall{todo}, false, + "Let me update the plan.\n" + todoR, "", + []RawToolCall{todo}, + false, "Let me update the plan.", }, { @@ -63,27 +67,37 @@ func TestAssistantText(t *testing.T) { { "two identical calls both removed", "Running twice.\n" + bashR + "\n" + bashR + "\nBoth passed.", - "", []RawToolCall{bash, bash}, false, + "", + []RawToolCall{bash, bash}, + false, "Running twice.\nBoth passed.", }, { "redacted rendering removed", - "Run it.\n" + bashRedacted, "", []RawToolCall{bash}, true, + "Run it.\n" + bashRedacted, "", + []RawToolCall{bash}, + true, "Run it.", }, { "full rendering fallback under redacting policy", - "Run it.\n" + bashR, "", []RawToolCall{bash}, true, + "Run it.\n" + bashR, "", + []RawToolCall{bash}, + true, "Run it.", }, { "redacted preferred when both forms occur", - "Run it.\n" + bashRedacted + "\n" + bashR, "", []RawToolCall{bash}, true, + "Run it.\n" + bashRedacted + "\n" + bashR, "", + []RawToolCall{bash}, + true, "Run it.\n" + bashR, }, { "full preferred when both forms occur", - "Run it.\n" + bashRedacted + "\n" + bashR, "", []RawToolCall{bash}, false, + "Run it.\n" + bashRedacted + "\n" + bashR, "", + []RawToolCall{bash}, + false, "Run it.\n" + bashRedacted, }, { diff --git a/internal/friction/input_test.go b/internal/friction/input_test.go index 3de5d7cd67..5343938ee3 100644 --- a/internal/friction/input_test.go +++ b/internal/friction/input_test.go @@ -12,7 +12,7 @@ import ( var inputTestStart = time.Date(2026, 9, 16, 9, 0, 0, 0, time.UTC) -func inputAt(min int) time.Time { return inputTestStart.Add(time.Duration(min) * time.Minute) } +func inputAt(minutes int) time.Time { return inputTestStart.Add(time.Duration(minutes) * time.Minute) } func TestBuildSessionInput(t *testing.T) { bashFail := RawToolCall{MessageOrdinal: 1, CallIndex: 0, ToolName: "Bash", Category: "Bash", InputJSON: `{"command":"make"}`, EventStatus: "errored", LastEventContent: "boom"} @@ -62,7 +62,7 @@ func TestBuildSessionInput(t *testing.T) { {Ordinal: 4, Role: "user", Content: "u", Timestamp: inputAt(4)}, }, []RawToolCall{systemOwned, orphan, readOK, withTime}, BuildOptions{}) assert.Equal(t, []int{1, 1, 1, 2, 3, 4}, messageOrdinals(in.Messages)) - assert.Equal(t, []time.Time{inputAt(2), inputAt(1), time.Time{}, inputAt(3)}, in.Patterns.CallTimes) + assert.Equal(t, []time.Time{inputAt(2), inputAt(1), {}, inputAt(3)}, in.Patterns.CallTimes) require.Len(t, in.Messages, 6) assert.Equal(t, inputAt(1), in.Messages[2].Timestamp) assert.Equal(t, "tool", in.Messages[3].Role) @@ -81,8 +81,11 @@ func TestBuildSessionInput(t *testing.T) { t.Run("compaction and pressure", func(t *testing.T) { p := 0.95 calls := []RawToolCall{ - {MessageOrdinal: 1, ToolName: "Read"}, {MessageOrdinal: 1, ToolName: "Edit"}, {MessageOrdinal: 1, ToolName: "Bash"}, - {MessageOrdinal: 3, ToolName: "Read"}, {MessageOrdinal: 3, ToolName: "Edit"}, + {MessageOrdinal: 1, ToolName: "Read"}, + {MessageOrdinal: 1, ToolName: "Edit"}, + {MessageOrdinal: 1, ToolName: "Bash"}, + {MessageOrdinal: 3, ToolName: "Read"}, + {MessageOrdinal: 3, ToolName: "Edit"}, } in := BuildSessionInput("s1", Dims{}, false, []RawMessage{ {Ordinal: 3, Role: "assistant", ContextTokens: 900, HasContextTokens: true, Timestamp: inputAt(3)}, @@ -129,26 +132,34 @@ func TestToolEnvelope(t *testing.T) { }{ { "errored status uses last event content", - RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "errored", - ResultContent: "summary", LastEventContent: "boom"}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", EventStatus: "errored", + ResultContent: "summary", LastEventContent: "boom", + }, `{"error":"boom","success":false}`, }, { "cancelled status is a failure", - RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "cancelled", - ResultContent: "interrupted"}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", EventStatus: "cancelled", + ResultContent: "interrupted", + }, `{"error":"interrupted","success":false}`, }, { "event with empty content falls back to result content", - RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "errored", - ResultContent: "summary"}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", EventStatus: "errored", + ResultContent: "summary", + }, `{"error":"summary","success":false}`, }, { "event content ignored without an event status", - RawToolCall{ToolName: "Bash", Category: "Bash", - ResultContent: "bash: x: command not found", LastEventContent: "stale"}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", + ResultContent: "bash: x: command not found", LastEventContent: "stale", + }, `{"error":"bash: x: command not found","success":false}`, }, { @@ -158,20 +169,26 @@ func TestToolEnvelope(t *testing.T) { }, { "content heuristic failure without status", - RawToolCall{ToolName: "Bash", Category: "Bash", - ResultContent: "bash: foo: command not found"}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", + ResultContent: "bash: foo: command not found", + }, `{"error":"bash: foo: command not found","success":false}`, }, { "precomputed content verdict wins over content scan", - RawToolCall{ToolName: "Bash", Category: "Bash", ResultContent: "ok", - ContentFailure: true, ContentFailureKnown: true}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", ResultContent: "ok", + ContentFailure: true, ContentFailureKnown: true, + }, `{"error":"ok","success":false}`, }, { "html and line separators are not escaped", - RawToolCall{ToolName: "Bash", Category: "Bash", EventStatus: "errored", - ResultContent: "a&c\u2028d"}, + RawToolCall{ + ToolName: "Bash", Category: "Bash", EventStatus: "errored", + ResultContent: "a&c\u2028d", + }, "{\"error\":\"a&c\u2028d\",\"success\":false}", }, } diff --git a/internal/friction/kinds_test.go b/internal/friction/kinds_test.go index ca38a57deb..b81cf4f68c 100644 --- a/internal/friction/kinds_test.go +++ b/internal/friction/kinds_test.go @@ -58,7 +58,7 @@ func TestDetectFrustration(t *testing.T) { long := "this is broken " + strings.Repeat("é", 300) got := DetectFrustration([]Message{{Ordinal: 1, Role: "user", Text: long}}, "s1") require.Len(t, got, 1) - assert.Equal(t, 200, len([]rune(got[0].Text))) + assert.Len(t, []rune(got[0].Text), 200) }) } @@ -73,14 +73,20 @@ func TestDetectInterruptions(t *testing.T) { "each interruption counts and carries no text", []Message{ {Ordinal: 6, Role: "user", Text: "[Request interrupted by user]", Timestamp: kindsBase}, - {Ordinal: 9, Role: "user", Text: "[Request interrupted by user for tool use]", - Timestamp: kindsBase.Add(time.Minute)}, + { + Ordinal: 9, Role: "user", Text: "[Request interrupted by user for tool use]", + Timestamp: kindsBase.Add(time.Minute), + }, }, []Signal{ - {Kind: KindInterruption, SubjectID: "s1", SubjectKind: SubjectSession, - Detector: DetectorInterruption, Ordinal: new(6), OccurredAt: kindsBase}, - {Kind: KindInterruption, SubjectID: "s1", SubjectKind: SubjectSession, - Detector: DetectorInterruption, Ordinal: new(9), OccurredAt: kindsBase.Add(time.Minute)}, + { + Kind: KindInterruption, SubjectID: "s1", SubjectKind: SubjectSession, + Detector: DetectorInterruption, Ordinal: new(6), OccurredAt: kindsBase, + }, + { + Kind: KindInterruption, SubjectID: "s1", SubjectKind: SubjectSession, + Detector: DetectorInterruption, Ordinal: new(9), OccurredAt: kindsBase.Add(time.Minute), + }, }, }, } diff --git a/internal/friction/patterns_test.go b/internal/friction/patterns_test.go index 65ccf560ff..ba5d351caa 100644 --- a/internal/friction/patterns_test.go +++ b/internal/friction/patterns_test.go @@ -25,8 +25,10 @@ func TestDetectPatterns(t *testing.T) { t.Run("retry loop per run", func(t *testing.T) { var calls []signals.ToolCallRow for i := range 4 { - calls = append(calls, signals.ToolCallRow{ToolName: "Bash", Category: "Bash", - InputJSON: `{"command":"cargo build"}`, MessageOrdinal: 10 + 2*i}) + calls = append(calls, signals.ToolCallRow{ + ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"cargo build"}`, MessageOrdinal: 10 + 2*i, + }) } got := DetectPatterns("s1", false, patternInput(calls, base)) require.Len(t, got, 1) @@ -75,9 +77,11 @@ func TestDetectPatterns(t *testing.T) { t.Run("runaway loop", func(t *testing.T) { var calls []signals.ToolCallRow for i := range 12 { - calls = append(calls, signals.ToolCallRow{ToolName: "Bash", Category: "Bash", + calls = append(calls, signals.ToolCallRow{ + ToolName: "Bash", Category: "Bash", InputJSON: `{"command":"npm run step-` + string(rune('a'+i)) + `"}`, - EventStatus: "errored", MessageOrdinal: i + 1}) + EventStatus: "errored", MessageOrdinal: i + 1, + }) } got := DetectPatterns("s1", false, patternInput(calls, base)) require.Len(t, got, 1) @@ -89,9 +93,11 @@ func TestDetectPatterns(t *testing.T) { t.Run("edit_churn_uses_base_name_for_posix_and_windows_paths", func(t *testing.T) { mk := func(path string, o int) signals.ToolCallRow { - return signals.ToolCallRow{ToolName: "Edit", Category: "Edit", + return signals.ToolCallRow{ + ToolName: "Edit", Category: "Edit", InputJSON: `{"file_path":"` + path + `","old_string":"` + string(rune('a'+o)) + `"}`, - MessageOrdinal: o} + MessageOrdinal: o, + } } win := `C:\\Users\\user\\app\\main.go` calls := []signals.ToolCallRow{ @@ -175,8 +181,10 @@ func TestDetectPatterns(t *testing.T) { t.Run("iteration runaway is last and sub-agents are exempt", func(t *testing.T) { var calls []signals.ToolCallRow for i := range 150 { - calls = append(calls, signals.ToolCallRow{ToolName: "Read", Category: "Read", - InputJSON: `{"file_path":"f` + string(rune('0'+i%10)) + `"}`, MessageOrdinal: i + 1}) + calls = append(calls, signals.ToolCallRow{ + ToolName: "Read", Category: "Read", + InputJSON: `{"file_path":"f` + string(rune('0'+i%10)) + `"}`, MessageOrdinal: i + 1, + }) } p := 0.99 in := patternInput(calls, base) diff --git a/internal/friction/review_test.go b/internal/friction/review_test.go index 922632c61b..e23b670ec3 100644 --- a/internal/friction/review_test.go +++ b/internal/friction/review_test.go @@ -20,8 +20,10 @@ func reviewFixture(dims Dims) SessionInput { {Ordinal: 3, Role: "assistant", Content: "Ran them.", Timestamp: rmin(3)}, {Ordinal: 4, Role: "user", Content: "this is broken again, same error", Timestamp: rmin(4)}, {Ordinal: 5, Role: "assistant", Content: "Looking.", Timestamp: rmin(5)}, - {Ordinal: 6, Role: "user", Content: "[Request interrupted by user]", IsSystem: true, - SourceSubtype: SourceSubtypeInterrupted, Timestamp: rmin(6)}, + { + Ordinal: 6, Role: "user", Content: "[Request interrupted by user]", IsSystem: true, + SourceSubtype: SourceSubtypeInterrupted, Timestamp: rmin(6), + }, } // The failing call belongs to the last assistant turn, so its tool // message does not break either (assistant, user, assistant) triple. diff --git a/internal/friction/seat_test.go b/internal/friction/seat_test.go index c29c69ece7..8eb99a8ba6 100644 --- a/internal/friction/seat_test.go +++ b/internal/friction/seat_test.go @@ -49,26 +49,56 @@ func TestSeatFromPath(t *testing.T) { want string }{ {"seat_from_path_no_patterns", "/home/user/agents/profiles/seat-01/projects/p/s.jsonl", nil, ""}, - {"spec example on absolute path", "/home/user/agents/profiles/seat-01/projects/app/s.jsonl", - []string{"*/profiles/{seat}/projects/*"}, "seat-01"}, - {"pool sessions layout", "/srv/pool/seat-02/sessions/2026/09/13/rollout-x.jsonl", - []string{"pool/{seat}/sessions"}, "seat-02"}, - {"star stays within one segment", "/home/user/a/b/profiles/seat-03/projects/p/s.jsonl", - []string{"/home/*/profiles/{seat}"}, ""}, - {"anchored pattern", "/home/user/profiles/seat-04/projects/p/s.jsonl", - []string{"/home/*/profiles/{seat}"}, "seat-04"}, - {"anchored pattern does not float", "/mnt/home/user/profiles/seat-05/s.jsonl", - []string{"/home/*/profiles/{seat}"}, ""}, - {"no match", "/home/user/.claude/projects/p/s.jsonl", - []string{"*/profiles/{seat}/projects/*"}, ""}, - {"first pattern wins", "/x/profiles/seat-06/pool/seat-07/s.jsonl", - []string{"pool/{seat}", "profiles/{seat}"}, "seat-07"}, - {"leftmost window wins", "/x/profiles/seat-08/profiles/seat-09/s.jsonl", - []string{"profiles/{seat}"}, "seat-08"}, - {"windows separators", `C:\Users\user\pool\seat-10\sessions\rollout-x.jsonl`, - []string{"pool/{seat}/sessions"}, "seat-10"}, - {"character class glob", "/srv/pool-b/seat-11/s.jsonl", - []string{"pool-[ab]/{seat}"}, "seat-11"}, + { + "spec example on absolute path", "/home/user/agents/profiles/seat-01/projects/app/s.jsonl", + []string{"*/profiles/{seat}/projects/*"}, + "seat-01", + }, + { + "pool sessions layout", "/srv/pool/seat-02/sessions/2026/09/13/rollout-x.jsonl", + []string{"pool/{seat}/sessions"}, + "seat-02", + }, + { + "star stays within one segment", "/home/user/a/b/profiles/seat-03/projects/p/s.jsonl", + []string{"/home/*/profiles/{seat}"}, + "", + }, + { + "anchored pattern", "/home/user/profiles/seat-04/projects/p/s.jsonl", + []string{"/home/*/profiles/{seat}"}, + "seat-04", + }, + { + "anchored pattern does not float", "/mnt/home/user/profiles/seat-05/s.jsonl", + []string{"/home/*/profiles/{seat}"}, + "", + }, + { + "no match", "/home/user/.claude/projects/p/s.jsonl", + []string{"*/profiles/{seat}/projects/*"}, + "", + }, + { + "first pattern wins", "/x/profiles/seat-06/pool/seat-07/s.jsonl", + []string{"pool/{seat}", "profiles/{seat}"}, + "seat-07", + }, + { + "leftmost window wins", "/x/profiles/seat-08/profiles/seat-09/s.jsonl", + []string{"profiles/{seat}"}, + "seat-08", + }, + { + "windows separators", `C:\Users\user\pool\seat-10\sessions\rollout-x.jsonl`, + []string{"pool/{seat}/sessions"}, + "seat-10", + }, + { + "character class glob", "/srv/pool-b/seat-11/s.jsonl", + []string{"pool-[ab]/{seat}"}, + "seat-11", + }, {"empty path", "", []string{"*/{seat}"}, ""}, } for _, tt := range tests { diff --git a/internal/signals/spans_test.go b/internal/signals/spans_test.go index ffb2194285..f3ba048686 100644 --- a/internal/signals/spans_test.go +++ b/internal/signals/spans_test.go @@ -58,17 +58,23 @@ func TestRunawayToolLoopSpan(t *testing.T) { { "exact run spans the whole equal-signature run", exact, true, - CallPos{MessageOrdinal: 0}, CallPos{MessageOrdinal: 24}, 13, + CallPos{MessageOrdinal: 0}, + CallPos{MessageOrdinal: 24}, + 13, }, { "six failures in first twelve-call window", stepCalls(13, 1, 3, 5, 7, 9, 11), true, - CallPos{MessageOrdinal: 1}, CallPos{MessageOrdinal: 12}, 12, + CallPos{MessageOrdinal: 1}, + CallPos{MessageOrdinal: 12}, + 12, }, { "window found later in the session", stepCalls(20, 9, 11, 13, 15, 17, 19), true, - CallPos{MessageOrdinal: 9}, CallPos{MessageOrdinal: 20}, 12, + CallPos{MessageOrdinal: 9}, + CallPos{MessageOrdinal: 20}, + 12, }, {"two failures is not runaway", stepCalls(12, 2, 5), false, CallPos{}, CallPos{}, 0}, } From ee65689d88bea69c4bfd0f96de413943661c878d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:46:14 +0000 Subject: [PATCH 17/62] chore: keep local SDD report out of branch --- .../task-6-report.md | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 .superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md diff --git a/.superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md b/.superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md deleted file mode 100644 index 4e03909d24..0000000000 --- a/.superpowers/sdd/2026-09-22-pr02-signal-spans-and-session-input/task-6-report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -last_edited: 2026-09-23 ---- - -# Task 6: seat pattern matching - -Implemented `CompileSeatPatterns` and `SeatFromPath` in `internal/friction/seat.go`. Patterns require exactly one whole-segment `{seat}`, validate all remaining segments with `path.Match`, and preserve the specified plain error strings. Matching supports rooted patterns, floating contiguous windows, first-pattern priority, leftmost-window priority, and either slash style in input paths. - -Added anonymized generic tests in `internal/friction/seat_test.go` for compilation errors, globs, rooted and floating matches, priority, Windows separators, and no-match cases. - -## TDD evidence - -- RED: `CGO_ENABLED=1 go test -tags fts5 ./internal/friction/ -run 'TestCompileSeatPatterns|TestSeatFromPath' -v` failed to compile with `undefined: CompileSeatPatterns`, `undefined: SeatPattern`, and `undefined: SeatFromPath`, as expected before implementation. -- GREEN: the same command passed all 19 subtests after implementation. - -## Checks - -- `go fmt ./...` completed successfully. -- `go vet ./...` completed successfully. -- `git diff --check` completed successfully. - -## Self-review - -The matcher scans each candidate window in path order and patterns in configuration order. Every capture is a non-empty segment because path splitting omits empty segments. No personal path layouts or worker names are encoded. - -## Files - -- `internal/friction/seat.go` -- `internal/friction/seat_test.go` From 3ac41b91ffdd5728129d884f7884147225033689 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:05:52 +0000 Subject: [PATCH 18/62] fix(friction): strip thinking before tool renderings --- internal/friction/assistant_text.go | 49 ++++++++++++++++++++---- internal/friction/assistant_text_test.go | 43 +++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/internal/friction/assistant_text.go b/internal/friction/assistant_text.go index 80b3e607ce..6a617858c4 100644 --- a/internal/friction/assistant_text.go +++ b/internal/friction/assistant_text.go @@ -16,13 +16,13 @@ const ( func AssistantText( content, thinking string, calls []RawToolCall, redacted bool, ) string { - text := content + text := stripThinking(content, thinking) for _, call := range calls { if rendering := renderingInText(text, call, redacted); rendering != "" { text = removePart(text, strings.Index(text, rendering), len(rendering)) } } - return stripThinking(text, thinking) + return text } // renderingInText chooses the longest present rendering in the preferred @@ -31,12 +31,30 @@ func renderingInText(text string, call RawToolCall, redacted bool) string { pairs := parser.ToolUseRenderingCandidates( call.Category, call.ToolName, call.InputJSON, ) - pick := func(form func(parser.ToolUseRenderingPair) string) string { + pick := func( + form func(parser.ToolUseRenderingPair) string, + skipFullRenderingPrefixes bool, + ) string { best := "" for _, pair := range pairs { rendering := form(pair) - if len(rendering) > len(best) && strings.Contains(text, rendering) { + if len(rendering) <= len(best) { + continue + } + for from := 0; from < len(text); { + i := strings.Index(text[from:], rendering) + if i < 0 { + break + } + at := from + i + if skipFullRenderingPrefixes && prefixesFullRendering( + text[at:], rendering, pairs, + ) { + from = at + len(rendering) + continue + } best = rendering + break } } return best @@ -44,15 +62,30 @@ func renderingInText(text string, call RawToolCall, redacted bool) string { full := func(pair parser.ToolUseRenderingPair) string { return pair.Full } red := func(pair parser.ToolUseRenderingPair) string { return pair.Redacted } if redacted { - if rendering := pick(red); rendering != "" { + if rendering := pick(red, true); rendering != "" { return rendering } - return pick(full) + return pick(full, false) } - if rendering := pick(full); rendering != "" { + if rendering := pick(full, false); rendering != "" { return rendering } - return pick(red) + return pick(red, false) +} + +// prefixesFullRendering reports whether a redacted candidate at the start of +// text is only the header of a longer rendering for the same call. +func prefixesFullRendering( + text, redacted string, pairs []parser.ToolUseRenderingPair, +) bool { + for _, pair := range pairs { + if len(pair.Full) > len(redacted) && + strings.HasPrefix(pair.Full, redacted) && + strings.HasPrefix(text, pair.Full) { + return true + } + } + return false } // removePart deletes a block and one adjacent newline separator. The diff --git a/internal/friction/assistant_text_test.go b/internal/friction/assistant_text_test.go index 9ac643fbc5..5aac0bb971 100644 --- a/internal/friction/assistant_text_test.go +++ b/internal/friction/assistant_text_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" "go.kenn.io/agentsview/internal/parser" ) @@ -21,6 +23,15 @@ func TestAssistantText(t *testing.T) { bashR := parser.ToolUseRendering(bash.ToolName, bash.InputJSON) bashRedacted := parser.RedactedToolUseRendering(bash.ToolName, bash.InputJSON) readR := parser.ToolUseRendering(read.ToolName, read.InputJSON) + const quotedThinking = "for now\n[Bash]\n$ echo done\nmore thought" + parserText, _, _, _, parserCalls, _ := parser.ExtractTextContent( + t.Context(), gjson.Parse(`[ + {"type":"thinking","thinking":"for now\n[Bash]\n$ echo done\nmore thought"}, + {"type":"tool_use","name":"Bash","input":{"command":"echo done"}}, + {"type":"text","text":"Answer."} + ]`), + ) + require.Len(t, parserCalls, 1) tests := []struct { name string @@ -64,6 +75,16 @@ func TestAssistantText(t *testing.T) { "quote:\n[/Thinking]\nstill thinking for now", nil, false, "Answer.", }, + { + "quoted tool rendering in parser thinking is removed", + parserText, quotedThinking, + []RawToolCall{{ + ToolName: parserCalls[0].ToolName, + Category: parserCalls[0].Category, + InputJSON: parserCalls[0].InputJSON, + }}, + false, "Answer.", + }, { "two identical calls both removed", "Running twice.\n" + bashR + "\n" + bashR + "\nBoth passed.", @@ -86,6 +107,28 @@ func TestAssistantText(t *testing.T) { true, "Run it.", }, + { + "redacted prefix does not leave full rendering arguments", + parser.ToolUseRendering( + "Bash", `{"command":"echo for now"}`, + ), "", + []RawToolCall{{ + ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"echo for now"}`, + }}, + true, "", + }, + { + "standalone redacted header is removed", + parser.RedactedToolUseRendering( + "Bash", `{"command":"echo for now"}`, + ), "", + []RawToolCall{{ + ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"echo for now"}`, + }}, + true, "", + }, { "redacted preferred when both forms occur", "Run it.\n" + bashRedacted + "\n" + bashR, "", From b833b2fe48ed5b226742087775f7aba2d2252e90 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:34:28 +0000 Subject: [PATCH 19/62] feat(db): add friction findings schema and session columns --- internal/db/db.go | 12 ++++ internal/db/friction_test.go | 115 +++++++++++++++++++++++++++++++++++ internal/db/messages.go | 16 ++--- internal/db/schema.sql | 46 ++++++++++++++ internal/db/sessions.go | 56 ++++++++++------- 5 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 internal/db/friction_test.go diff --git a/internal/db/db.go b/internal/db/db.go index 7c45a57d55..1b6d3a8e2f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -2542,6 +2542,18 @@ func schemaColumnMigrations() []schemaColumnMigration { "sessions", "secrets_rules_version", "ALTER TABLE sessions ADD COLUMN secrets_rules_version TEXT NOT NULL DEFAULT ''", }, + { + "sessions", "friction_count", + "ALTER TABLE sessions ADD COLUMN friction_count INTEGER NOT NULL DEFAULT 0", + }, + { + "sessions", "friction_rules_version", + "ALTER TABLE sessions ADD COLUMN friction_rules_version TEXT NOT NULL DEFAULT ''", + }, + { + "sessions", "friction_hash", + "ALTER TABLE sessions ADD COLUMN friction_hash TEXT NOT NULL DEFAULT ''", + }, { "recall_extract_progress", "content_stamped_at", "ALTER TABLE recall_extract_progress ADD COLUMN content_stamped_at TEXT NOT NULL DEFAULT ''", diff --git a/internal/db/friction_test.go b/internal/db/friction_test.go new file mode 100644 index 0000000000..077f846652 --- /dev/null +++ b/internal/db/friction_test.go @@ -0,0 +1,115 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFrictionSchemaExists(t *testing.T) { + d := testDB(t) + ctx := t.Context() + tests := []struct { + table string + columns []string + }{ + {"friction_findings", []string{ + "id", "session_id", "kind", "detector", "message_ordinal", + "call_index", "tool_name", "label", "text", "evidence", + "title", "fingerprint", "occurred_at", "seq", + "rules_version", "created_at", + }}, + {"friction_session_dims", []string{ + "session_id", "seat", "persona", "channel", "dims_source", + "review_excluded", + }}, + {"sessions", []string{ + "friction_count", "friction_rules_version", "friction_hash", + }}, + } + for _, tt := range tests { + t.Run(tt.table, func(t *testing.T) { + rows, err := d.getReader().QueryContext(ctx, + "SELECT name FROM pragma_table_info(?)", tt.table) + require.NoError(t, err) + defer rows.Close() + have := map[string]bool{} + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + have[name] = true + } + require.NoError(t, rows.Err()) + for _, c := range tt.columns { + assert.True(t, have[c], "%s.%s missing", tt.table, c) + } + }) + } + for _, index := range []string{ + "idx_friction_findings_session", + "idx_friction_findings_fingerprint", + "idx_friction_findings_kind", + } { + var present bool + require.NoError(t, d.getReader().QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM sqlite_master + WHERE type = 'index' AND name = ?)`, index, + ).Scan(&present)) + assert.True(t, present, index) + } +} + +func TestFrictionSessionColumnsMigrateOnOldArchive(t *testing.T) { + path := createClosedTestDB(t, tempDBPath(t, "sessions.db"), nil) + for _, c := range []string{ + "friction_count", "friction_rules_version", "friction_hash", + } { + execRawSQLite(t, path, "ALTER TABLE sessions DROP COLUMN "+c) + } + execRawSQLite(t, path, "DROP TABLE friction_findings") + execRawSQLite(t, path, "DROP TABLE friction_session_dims") + + d, err := Open(t.Context(), path) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, d.Close()) }) + insertSession(t, d, "s1", "proj") + + s, err := d.GetSessionFull(t.Context(), "s1") + require.NoError(t, err) + require.NotNil(t, s) + assert.Zero(t, s.FrictionCount) + assert.Empty(t, s.FrictionRulesVersion) + assert.Empty(t, s.FrictionHash) + for _, table := range []string{"friction_findings", "friction_session_dims"} { + var present bool + require.NoError(t, d.getReader().QueryRow(t.Context(), + `SELECT EXISTS(SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = ?)`, table, + ).Scan(&present)) + assert.True(t, present, "%s table missing after writable open", table) + } +} + +func TestReplaceSessionMessagesRevokesFrictionVersion(t *testing.T) { + d := testDB(t) + ctx := t.Context() + insertSession(t, d, "s1", "proj") + require.NoError(t, d.ReplaceSessionMessages(ctx, "s1", []Message{ + {SessionID: "s1", Ordinal: 0, Role: "user", Content: "first"}, + })) + _, err := d.getWriter().Exec(ctx, + `UPDATE sessions SET friction_rules_version = 'friction-v1', + friction_hash = 'h' WHERE id = 's1'`) + require.NoError(t, err) + + require.NoError(t, d.ReplaceSessionMessages(ctx, "s1", []Message{ + {SessionID: "s1", Ordinal: 0, Role: "user", Content: "changed"}, + })) + + var version string + require.NoError(t, d.getReader().QueryRow(ctx, + "SELECT friction_rules_version FROM sessions WHERE id = 's1'", + ).Scan(&version)) + assert.Empty(t, version, "a transcript change must force a friction recompute") +} diff --git a/internal/db/messages.go b/internal/db/messages.go index 9f8f855d41..d1f512b5ea 100644 --- a/internal/db/messages.go +++ b/internal/db/messages.go @@ -2074,25 +2074,25 @@ func bumpInsertedTranscriptRevisionTx(tx transactionQueries, sessionID string) e func bumpTranscriptRevision( tx transactionQueries, sessionID string, touchModified bool, ) error { - // Advancing the revision also revokes secret-scan freshness in the same - // transaction: the mutated transcript has content the recorded scan - // never saw, and consumers that require a current scan (extraction's - // privacy boundary) must fail closed until a rescan re-stamps it. The - // incremental sync path re-scans in a separate later write; the atomic - // replace path re-stamps inside this same transaction. + // Advancing the revision revokes secret-scan and friction freshness in + // the same transaction. The mutated transcript has content those + // derived results never saw. Incremental sync recomputes later; the + // atomic replace path re-stamps inside this transaction. query := `UPDATE sessions SET transcript_revision = CAST( CAST(transcript_revision AS INTEGER) + 1 AS TEXT ), local_modified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), - secrets_rules_version = '' + secrets_rules_version = '', + friction_rules_version = '' WHERE id = ?` if !touchModified { query = `UPDATE sessions SET transcript_revision = CAST( CAST(transcript_revision AS INTEGER) + 1 AS TEXT ), - secrets_rules_version = '' + secrets_rules_version = '', + friction_rules_version = '' WHERE id = ?` } result, err := tx.Exec(query, sessionID) diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 0f2fb5f2fb..31c3b91584 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -91,6 +91,9 @@ CREATE TABLE IF NOT EXISTS sessions ( termination_status TEXT, secret_leak_count INTEGER NOT NULL DEFAULT 0, secrets_rules_version TEXT NOT NULL DEFAULT '', + friction_count INTEGER NOT NULL DEFAULT 0, + friction_rules_version TEXT NOT NULL DEFAULT '', + friction_hash TEXT NOT NULL DEFAULT '', sync_marker TEXT ); @@ -1289,6 +1292,49 @@ CREATE INDEX IF NOT EXISTS idx_secret_findings_session CREATE INDEX IF NOT EXISTS idx_secret_findings_rule ON secret_findings(rule_name); +-- Friction findings: per-occurrence detections from internal/friction. +-- Derived from stored messages at sync time and replaced per session. +-- Natural coordinates let findings survive the full-resync orphan copy. +CREATE TABLE IF NOT EXISTS friction_findings ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL + REFERENCES sessions(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + detector TEXT NOT NULL, + message_ordinal INTEGER, + call_index INTEGER, + tool_name TEXT NOT NULL DEFAULT '', + label TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + fingerprint TEXT NOT NULL, + occurred_at TEXT, + seq INTEGER NOT NULL, + rules_version TEXT NOT NULL, + created_at TEXT NOT NULL + DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE INDEX IF NOT EXISTS idx_friction_findings_session + ON friction_findings(session_id); +CREATE INDEX IF NOT EXISTS idx_friction_findings_fingerprint + ON friction_findings(fingerprint); +CREATE INDEX IF NOT EXISTS idx_friction_findings_kind + ON friction_findings(kind); + +-- Friction dimensions per session. A row exists only when at least one +-- field differs from its default. Agent and machine live on sessions. +CREATE TABLE IF NOT EXISTS friction_session_dims ( + session_id TEXT PRIMARY KEY + REFERENCES sessions(id) ON DELETE CASCADE, + seat TEXT NOT NULL DEFAULT '', + persona TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL DEFAULT '', + dims_source TEXT NOT NULL DEFAULT '', + review_excluded INTEGER NOT NULL DEFAULT 0 +); + -- Durable normalized-artifact import claims. Artifact kinds evolve -- independently, so each claim retains a separate version gate. CREATE TABLE IF NOT EXISTS artifact_import_queue ( diff --git a/internal/db/sessions.go b/internal/db/sessions.go index 41cee50f2a..b1019ff4b2 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -61,6 +61,7 @@ const sessionBaseCols = `id, project, machine, agent, health_score, health_grade, has_tool_calls, has_context_data, secret_leak_count, secrets_rules_version, + friction_count, friction_rules_version, friction_hash, quality_signal_version, short_prompt_count, unstructured_start, missing_success_criteria_count, @@ -96,6 +97,7 @@ const sessionPruneCols = `id, project, machine, agent, health_score, health_grade, has_tool_calls, has_context_data, secret_leak_count, secrets_rules_version, + friction_count, friction_rules_version, friction_hash, quality_signal_version, short_prompt_count, unstructured_start, missing_success_criteria_count, @@ -127,6 +129,7 @@ const sessionFullCols = `id, project, machine, agent, health_score, health_grade, has_tool_calls, has_context_data, secret_leak_count, secrets_rules_version, + friction_count, friction_rules_version, friction_hash, quality_signal_version, short_prompt_count, unstructured_start, missing_success_criteria_count, @@ -188,6 +191,7 @@ func scanSessionRowWithSource(rs rowScanner, includeSource bool) (Session, error &s.HealthScore, &s.HealthGrade, &s.HasToolCalls, &s.HasContextData, &s.SecretLeakCount, &s.SecretsRulesVersion, + &s.FrictionCount, &s.FrictionRulesVersion, &s.FrictionHash, &s.QualitySignalVersion, &s.ShortPromptCount, &s.UnstructuredStart, &s.MissingSuccessCriteriaCount, @@ -346,28 +350,32 @@ type Session struct { HealthGrade *string `json:"health_grade,omitempty"` // QualitySignals mirrors the scalar persistence fields below for API // schema and JSON transport. - QualitySignals *QualitySignals `json:"quality_signals,omitempty"` - HasToolCalls bool `json:"-"` - HasContextData bool `json:"-"` - SecretLeakCount int `json:"secret_leak_count"` - SecretsRulesVersion string `json:"-"` - QualitySignalVersion int `json:"-"` - ShortPromptCount int `json:"-"` - UnstructuredStart bool `json:"-"` - MissingSuccessCriteriaCount int `json:"-"` - MissingVerificationCount int `json:"-"` - DuplicatePromptCount int `json:"-"` - NoCodeContextCount int `json:"-"` - RunawayToolLoopCount int `json:"-"` - DataVersion int `json:"-"` - Cwd string `json:"cwd,omitempty"` - GitBranch string `json:"git_branch,omitempty"` - ProjectAssigned bool `json:"project_assigned,omitempty"` - SourceSessionID string `json:"source_session_id,omitempty"` - SourceVersion string `json:"source_version,omitempty"` - TranscriptFidelity string `json:"transcript_fidelity,omitempty"` - ParserMalformedLines int `json:"parser_malformed_lines,omitzero"` - IsTruncated bool `json:"is_truncated,omitzero"` + QualitySignals *QualitySignals `json:"quality_signals,omitempty"` + HasToolCalls bool `json:"-"` + HasContextData bool `json:"-"` + SecretLeakCount int `json:"secret_leak_count"` + SecretsRulesVersion string `json:"-"` + // Friction summary columns stay out of the session API payload. + FrictionCount int `json:"-"` + FrictionRulesVersion string `json:"-"` + FrictionHash string `json:"-"` + QualitySignalVersion int `json:"-"` + ShortPromptCount int `json:"-"` + UnstructuredStart bool `json:"-"` + MissingSuccessCriteriaCount int `json:"-"` + MissingVerificationCount int `json:"-"` + DuplicatePromptCount int `json:"-"` + NoCodeContextCount int `json:"-"` + RunawayToolLoopCount int `json:"-"` + DataVersion int `json:"-"` + Cwd string `json:"cwd,omitempty"` + GitBranch string `json:"git_branch,omitempty"` + ProjectAssigned bool `json:"project_assigned,omitempty"` + SourceSessionID string `json:"source_session_id,omitempty"` + SourceVersion string `json:"source_version,omitempty"` + TranscriptFidelity string `json:"transcript_fidelity,omitempty"` + ParserMalformedLines int `json:"parser_malformed_lines,omitzero"` + IsTruncated bool `json:"is_truncated,omitzero"` DeletedAt *string `json:"deleted_at,omitempty"` DeletionCause *string `json:"-"` @@ -1225,6 +1233,7 @@ func (db *DB) getSessionFullUncoalesced( &s.HealthScore, &s.HealthGrade, &s.HasToolCalls, &s.HasContextData, &s.SecretLeakCount, &s.SecretsRulesVersion, + &s.FrictionCount, &s.FrictionRulesVersion, &s.FrictionHash, &s.QualitySignalVersion, &s.ShortPromptCount, &s.UnstructuredStart, &s.MissingSuccessCriteriaCount, @@ -5508,6 +5517,7 @@ func (db *DB) FindPruneCandidates(ctx context.Context, &s.HealthScore, &s.HealthGrade, &s.HasToolCalls, &s.HasContextData, &s.SecretLeakCount, &s.SecretsRulesVersion, + &s.FrictionCount, &s.FrictionRulesVersion, &s.FrictionHash, &s.QualitySignalVersion, &s.ShortPromptCount, &s.UnstructuredStart, &s.MissingSuccessCriteriaCount, @@ -5954,6 +5964,7 @@ func (db *DB) ListSessionsModifiedBetween( &s.HealthScore, &s.HealthGrade, &s.HasToolCalls, &s.HasContextData, &s.SecretLeakCount, &s.SecretsRulesVersion, + &s.FrictionCount, &s.FrictionRulesVersion, &s.FrictionHash, &s.QualitySignalVersion, &s.ShortPromptCount, &s.UnstructuredStart, &s.MissingSuccessCriteriaCount, @@ -6063,6 +6074,7 @@ func (db *DB) ListSessionsForMirrorWindow( &s.HealthScore, &s.HealthGrade, &s.HasToolCalls, &s.HasContextData, &s.SecretLeakCount, &s.SecretsRulesVersion, + &s.FrictionCount, &s.FrictionRulesVersion, &s.FrictionHash, &s.QualitySignalVersion, &s.ShortPromptCount, &s.UnstructuredStart, &s.MissingSuccessCriteriaCount, From 6b549db919f2838a35123aa36ff12cc9863ec776 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:53:27 +0000 Subject: [PATCH 20/62] feat(db): store friction findings with the session signal write --- internal/db/friction.go | 361 +++++++++++++++++++++++++++++++++++ internal/db/friction_test.go | 245 ++++++++++++++++++++++++ internal/db/signals.go | 14 +- 3 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 internal/db/friction.go diff --git a/internal/db/friction.go b/internal/db/friction.go new file mode 100644 index 0000000000..c4ab8356fc --- /dev/null +++ b/internal/db/friction.go @@ -0,0 +1,361 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "hash" + "strconv" + "time" + + "go.kenn.io/agentsview/internal/friction" +) + +// FrictionFinding is one persisted friction detection (spec §5.2). Rows +// are derived from the session's stored messages and replaced per +// session; natural coordinates let them survive the resync orphan copy. +type FrictionFinding struct { + SessionID string + Kind string + Detector string + MessageOrdinal *int + CallIndex *int + ToolName string + Label string + Text string + Evidence string + Title string + Fingerprint string + OccurredAt *time.Time + Seq int + RulesVersion string +} + +// FrictionSessionDims holds per-session friction dimensions (spec §5.3). +type FrictionSessionDims struct { + SessionID string + Seat string + Persona string + Channel string + DimsSource string + ReviewExcluded bool +} + +// SessionFrictionUpdate is a session's complete friction output. It rides +// on SessionSignalUpdate so every signal write path persists it in the +// same transaction. A nil *SessionFrictionUpdate leaves stored friction +// untouched. +type SessionFrictionUpdate struct { + Findings []FrictionFinding + Dims *FrictionSessionDims + RulesVersion string + Hash string +} + +// FrictionHash is the canonical change fingerprint stored in +// sessions.friction_hash and folded into the PG push fingerprint. +// Findings must be in seq order. See the plan for the field order; it is +// part of the storage contract, so changing it re-pushes every session. +func FrictionHash( + findings []FrictionFinding, dims *FrictionSessionDims, rulesVersion string, +) string { + h := sha256.New() + write := func(f string) { writeFrictionField(h, f) } + write("friction-hash-v1") + write(rulesVersion) + write(strconv.Itoa(len(findings))) + for _, f := range findings { + write(f.Kind) + write(f.Detector) + write(optFrictionInt(f.MessageOrdinal)) + write(optFrictionInt(f.CallIndex)) + write(f.ToolName) + write(f.Label) + write(f.Text) + write(f.Evidence) + write(f.Title) + write(f.Fingerprint) + write(formatFrictionTime(f.OccurredAt)) + write(strconv.Itoa(f.Seq)) + } + if dims == nil { + write("nodims") + } else { + write("dims") + write(dims.Seat) + write(dims.Persona) + write(dims.Channel) + write(dims.DimsSource) + write(strconv.FormatBool(dims.ReviewExcluded)) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func writeFrictionField(h hash.Hash, f string) { + fmt.Fprintf(h, "%d:%s", len(f), f) +} + +func optFrictionInt(v *int) string { + if v == nil { + return "" + } + return strconv.Itoa(*v) +} + +// formatFrictionTime is the single text form used for occurred_at in +// SQLite and in FrictionHash, so a recompute from stored rows matches. +func formatFrictionTime(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} + +func frictionTimeArg(t *time.Time) any { + if t == nil { + return nil + } + return formatFrictionTime(t) +} + +// replaceSessionFrictionTx deletes a session's findings and dims, inserts +// the new set, and stamps the sessions summary columns. Caller owns the +// lock and transaction lifecycle. +func replaceSessionFrictionTx( + tx transactionQueries, sessionID string, u SessionFrictionUpdate, +) error { + if _, err := tx.Exec( + "DELETE FROM friction_findings WHERE session_id = ?", sessionID, + ); err != nil { + return fmt.Errorf("deleting friction findings for %s: %w", sessionID, err) + } + if _, err := tx.Exec( + "DELETE FROM friction_session_dims WHERE session_id = ?", sessionID, + ); err != nil { + return fmt.Errorf("deleting friction dims for %s: %w", sessionID, err) + } + for i := range u.Findings { + f := &u.Findings[i] + if _, err := tx.Exec(` + INSERT INTO friction_findings ( + session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sessionID, f.Kind, f.Detector, f.MessageOrdinal, f.CallIndex, + f.ToolName, f.Label, f.Text, f.Evidence, f.Title, f.Fingerprint, + frictionTimeArg(f.OccurredAt), f.Seq, u.RulesVersion, + ); err != nil { + return fmt.Errorf("inserting friction finding for %s: %w", sessionID, err) + } + } + if u.Dims != nil { + if _, err := tx.Exec(` + INSERT INTO friction_session_dims ( + session_id, seat, persona, channel, dims_source, + review_excluded + ) VALUES (?, ?, ?, ?, ?, ?)`, + sessionID, u.Dims.Seat, u.Dims.Persona, u.Dims.Channel, + u.Dims.DimsSource, u.Dims.ReviewExcluded, + ); err != nil { + return fmt.Errorf("inserting friction dims for %s: %w", sessionID, err) + } + } + if _, err := tx.Exec(` + UPDATE sessions + SET friction_count = ?, + friction_rules_version = ?, + friction_hash = ?, + local_modified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ?`, + len(u.Findings), u.RulesVersion, u.Hash, sessionID, + ); err != nil { + return fmt.Errorf("updating session friction columns %s: %w", sessionID, err) + } + return nil +} + +// settledUsageOnlyFriction is the terminal friction state for an archive +// that stores no transcript text: no rows, current version, so the +// backfill does not revisit the session on every tick. +func settledUsageOnlyFriction() SessionFrictionUpdate { + return SessionFrictionUpdate{ + RulesVersion: friction.RulesVersion, + Hash: FrictionHash(nil, nil, friction.RulesVersion), + } +} + +// ReplaceSessionFriction atomically replaces a session's friction findings +// and dims and updates friction_count, friction_rules_version and +// friction_hash. Under a usage-only archive policy it stores the settled +// empty state instead. +func (db *DB) ReplaceSessionFriction( + ctx context.Context, sessionID string, + findings []FrictionFinding, dims *FrictionSessionDims, + rulesVersion, hash string, +) error { + db.mu.Lock() + defer db.mu.Unlock() + tx, err := db.getWriter().Begin(ctx) + if err != nil { + return fmt.Errorf("beginning friction tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + u := SessionFrictionUpdate{ + Findings: findings, Dims: dims, + RulesVersion: rulesVersion, Hash: hash, + } + if db.usageOnlyStorage() { + u = settledUsageOnlyFriction() + } + if err := replaceSessionFrictionTx(tx, sessionID, u); err != nil { + return err + } + return tx.Commit() +} + +// ReplaceSessionFrictionAtRevision publishes a friction snapshot only when +// the session's transcript_revision still equals the revision the snapshot +// was computed from. It reports whether the write applied. Recompute paths +// that load stored rows outside the content transaction use it so a +// concurrent transcript write is never overwritten with stale findings. +func (db *DB) ReplaceSessionFrictionAtRevision( + ctx context.Context, sessionID, transcriptRevision string, + u SessionFrictionUpdate, +) (bool, error) { + db.mu.Lock() + defer db.mu.Unlock() + tx, err := db.getWriter().Begin(ctx) + if err != nil { + return false, fmt.Errorf("beginning friction tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + var current sql.NullString + err = tx.QueryRowContext(ctx, + "SELECT transcript_revision FROM sessions WHERE id = ?", sessionID, + ).Scan(¤t) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("reading transcript revision %s: %w", sessionID, err) + } + if current.String != transcriptRevision { + return false, nil + } + if db.usageOnlyStorage() { + u = settledUsageOnlyFriction() + } + if err := replaceSessionFrictionTx(tx, sessionID, u); err != nil { + return false, err + } + return true, tx.Commit() +} + +// hasFrictionTables reports whether this archive has the friction tables. +// A read-only archive that predates them opens anyway (they are not in +// readOnlyRequiredTables), and its friction reads return empty results. +func (db *DB) hasFrictionTables(ctx context.Context) (bool, error) { + var n int + err := db.getReader().QueryRow(ctx, + `SELECT count(*) FROM sqlite_master WHERE type = 'table' + AND name IN ('friction_findings', 'friction_session_dims')`, + ).Scan(&n) + if err != nil { + return false, fmt.Errorf("checking friction tables: %w", err) + } + return n == 2, nil +} + +// SessionFrictionFindings returns a session's findings in seq order. It is +// the PG push source. +func (db *DB) SessionFrictionFindings( + ctx context.Context, sessionID string, +) ([]FrictionFinding, error) { + out := make([]FrictionFinding, 0) + present, err := db.hasFrictionTables(ctx) + if err != nil { + return nil, err + } + if !present { + return out, nil + } + rows, err := db.getReader().QueryContext(ctx, ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings + WHERE session_id = ? + ORDER BY seq, id`, sessionID) + if err != nil { + return nil, fmt.Errorf("querying friction findings for %s: %w", sessionID, err) + } + defer rows.Close() + for rows.Next() { + var f FrictionFinding + var occurred sql.NullString + if err := rows.Scan( + &f.SessionID, &f.Kind, &f.Detector, &f.MessageOrdinal, + &f.CallIndex, &f.ToolName, &f.Label, &f.Text, &f.Evidence, + &f.Title, &f.Fingerprint, &occurred, &f.Seq, &f.RulesVersion, + ); err != nil { + return nil, fmt.Errorf("scanning friction finding: %w", err) + } + if occurred.Valid && occurred.String != "" { + t, err := time.Parse(time.RFC3339Nano, occurred.String) + if err != nil { + return nil, fmt.Errorf("parsing friction occurred_at %q: %w", occurred.String, err) + } + f.OccurredAt = &t + } + out = append(out, f) + } + return out, rows.Err() +} + +// SessionFrictionDims returns a session's dims row, or nil when none. +func (db *DB) SessionFrictionDims( + ctx context.Context, sessionID string, +) (*FrictionSessionDims, error) { + present, err := db.hasFrictionTables(ctx) + if err != nil { + return nil, err + } + if !present { + return nil, nil + } + var d FrictionSessionDims + err = db.getReader().QueryRow(ctx, ` + SELECT session_id, seat, persona, channel, dims_source, + review_excluded + FROM friction_session_dims WHERE session_id = ?`, sessionID, + ).Scan(&d.SessionID, &d.Seat, &d.Persona, &d.Channel, + &d.DimsSource, &d.ReviewExcluded) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("querying friction dims for %s: %w", sessionID, err) + } + return &d, nil +} + +// StaleFrictionSessions returns up to limit session ids whose stored +// friction_rules_version differs from rulesVersion, in id order. Empty +// sessions are included so they settle once at the current version. +func (db *DB) StaleFrictionSessions( + ctx context.Context, rulesVersion string, limit int, +) ([]string, error) { + rows, err := db.getReader().QueryContext(ctx, ` + SELECT id FROM sessions + WHERE friction_rules_version <> ? + ORDER BY id + LIMIT ?`, rulesVersion, limit) + if err != nil { + return nil, fmt.Errorf("querying stale friction sessions: %w", err) + } + return scanStrings(rows) +} diff --git a/internal/db/friction_test.go b/internal/db/friction_test.go index 077f846652..dded5277b2 100644 --- a/internal/db/friction_test.go +++ b/internal/db/friction_test.go @@ -1,10 +1,14 @@ package db import ( + "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/friction" ) func TestFrictionSchemaExists(t *testing.T) { @@ -113,3 +117,244 @@ func TestReplaceSessionMessagesRevokesFrictionVersion(t *testing.T) { ).Scan(&version)) assert.Empty(t, version, "a transcript change must force a friction recompute") } + +func frictionFixture(sessionID string) ([]FrictionFinding, *FrictionSessionDims) { + ord, call := 3, 0 + at := time.Date(2026, 9, 16, 1, 35, 0, 120000000, time.UTC) + return []FrictionFinding{ + { + SessionID: sessionID, Kind: "correction", + Detector: "correction.coding", MessageOrdinal: &ord, + Text: "no, use the other config file", OccurredAt: &at, + Title: "[friction/correction] " + sessionID + ": no, use the other config file", + Fingerprint: "fl1:aa", Seq: 0, + }, + { + SessionID: sessionID, Kind: "error", Detector: "error", + MessageOrdinal: &ord, CallIndex: &call, ToolName: "Bash", + Text: `{"error":"boom","success":false}`, + Title: "[friction/error] Bash: boom", Fingerprint: "fl1:bb", + Seq: 1, + }, + { + SessionID: sessionID, Kind: "pattern", + Detector: "pattern.iteration_runaway", + Label: "iteration_runaway", + Text: "iteration runaway: 150 tool calls with no intervening user message", + Evidence: "150 tool calls without a user message 09:00-09:40", + Title: "[friction/pattern] " + sessionID + ": iteration runaway", + Fingerprint: "fl1:cc", Seq: 2, + }, + }, &FrictionSessionDims{ + SessionID: sessionID, Seat: "seat-02", DimsSource: "seat_pattern", + } +} + +func TestReplaceSessionFrictionRoundTrip(t *testing.T) { + d := testDB(t) + ctx := t.Context() + insertSession(t, d, "s1", "proj") + findings, dims := frictionFixture("s1") + hash := FrictionHash(findings, dims, friction.RulesVersion) + + require.NoError(t, d.ReplaceSessionFriction(ctx, "s1", findings, dims, + friction.RulesVersion, hash)) + + got, err := d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + for i := range findings { + findings[i].RulesVersion = friction.RulesVersion + } + assert.Equal(t, findings, got) + gotDims, err := d.SessionFrictionDims(ctx, "s1") + require.NoError(t, err) + assert.Equal(t, dims, gotDims) + assert.Equal(t, hash, FrictionHash(got, gotDims, friction.RulesVersion), + "a hash recomputed from stored rows must equal the written hash") + + s, err := d.GetSessionFull(ctx, "s1") + require.NoError(t, err) + assert.Equal(t, 3, s.FrictionCount) + assert.Equal(t, friction.RulesVersion, s.FrictionRulesVersion) + assert.Equal(t, hash, s.FrictionHash) + + // Replace with nothing: rows and dims go away, version stays current. + emptyHash := FrictionHash(nil, nil, friction.RulesVersion) + require.NoError(t, d.ReplaceSessionFriction(ctx, "s1", nil, nil, + friction.RulesVersion, emptyHash)) + got, err = d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + assert.Empty(t, got) + gotDims, err = d.SessionFrictionDims(ctx, "s1") + require.NoError(t, err) + assert.Nil(t, gotDims) +} + +func TestFrictionHash(t *testing.T) { + findings, dims := frictionFixture("s1") + base := FrictionHash(findings, dims, friction.RulesVersion) + require.Len(t, base, 64) + tests := []struct { + name string + mutate func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) + }{ + {"text", func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + f[0].Text += "!" + return f, d, friction.RulesVersion + }}, + {"ordinal nil vs zero", func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + zero := 0 + f[2].MessageOrdinal = &zero + return f, d, friction.RulesVersion + }}, + {"order", func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + f[0], f[1] = f[1], f[0] + return f, d, friction.RulesVersion + }}, + {"dims seat", func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + d.Seat = "seat-03" + return f, d, friction.RulesVersion + }}, + {"dims removed", func(f []FrictionFinding, _ *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + return f, nil, friction.RulesVersion + }}, + {"rules version", func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + return f, d, "friction-v2" + }}, + {"field boundary", func(f []FrictionFinding, d *FrictionSessionDims) ([]FrictionFinding, *FrictionSessionDims, string) { + f[1].ToolName, f[1].Label = "Bas", "h" + return f, d, friction.RulesVersion + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, d := frictionFixture("s1") + f, d2, rv := tt.mutate(f, d) + assert.NotEqual(t, base, FrictionHash(f, d2, rv)) + }) + } + t.Run("session id and row rules version ignored", func(t *testing.T) { + f, d := frictionFixture("other") + for i := range f { + f[i].Title = findings[i].Title + f[i].RulesVersion = "anything" + } + d.SessionID = "other" + assert.Equal(t, base, FrictionHash(f, d, friction.RulesVersion)) + }) +} + +func TestUpdateSessionSignalsPersistsFriction(t *testing.T) { + d := testDB(t) + ctx := t.Context() + insertSession(t, d, "s1", "proj") + findings, dims := frictionFixture("s1") + hash := FrictionHash(findings, dims, friction.RulesVersion) + + // Friction nil leaves stored friction alone. + require.NoError(t, d.ReplaceSessionFriction(ctx, "s1", findings, dims, + friction.RulesVersion, hash)) + require.NoError(t, d.UpdateSessionSignals(ctx, "s1", SessionSignalUpdate{})) + got, err := d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + assert.Len(t, got, 3) + + // Friction set replaces it in the signal transaction. + require.NoError(t, d.UpdateSessionSignals(ctx, "s1", SessionSignalUpdate{ + Friction: &SessionFrictionUpdate{ + Findings: findings[:1], RulesVersion: friction.RulesVersion, + Hash: FrictionHash(findings[:1], nil, friction.RulesVersion), + }, + })) + got, err = d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + assert.Len(t, got, 1) + gotDims, err := d.SessionFrictionDims(ctx, "s1") + require.NoError(t, err) + assert.Nil(t, gotDims) +} + +func TestReplaceSessionFrictionUsageOnlySettlesEmpty(t *testing.T) { + d := testDB(t) + ctx := t.Context() + insertSession(t, d, "s1", "proj") + d.SetArchiveContent(config.ArchiveContentUsage) + findings, dims := frictionFixture("s1") + + require.NoError(t, d.ReplaceSessionFriction(ctx, "s1", findings, dims, + friction.RulesVersion, "ignored")) + + got, err := d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + assert.Empty(t, got, "usage-only archives store no transcript-derived text") + s, err := d.GetSessionFull(ctx, "s1") + require.NoError(t, err) + assert.Zero(t, s.FrictionCount) + assert.Equal(t, friction.RulesVersion, s.FrictionRulesVersion) + assert.Equal(t, FrictionHash(nil, nil, friction.RulesVersion), s.FrictionHash) +} + +func TestReplaceSessionFrictionAtRevision(t *testing.T) { + d := testDB(t) + ctx := t.Context() + insertSession(t, d, "s1", "proj") + require.NoError(t, d.ReplaceSessionMessages(ctx, "s1", []Message{ + {SessionID: "s1", Ordinal: 0, Role: "user", Content: "hello"}, + })) + s, err := d.GetSessionFull(ctx, "s1") + require.NoError(t, err) + require.NotNil(t, s.TranscriptRevision) + findings, _ := frictionFixture("s1") + u := SessionFrictionUpdate{ + Findings: findings, RulesVersion: friction.RulesVersion, + Hash: FrictionHash(findings, nil, friction.RulesVersion), + } + + applied, err := d.ReplaceSessionFrictionAtRevision(ctx, "s1", "stale-rev", u) + require.NoError(t, err) + assert.False(t, applied, "a changed transcript must reject the snapshot") + got, err := d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + assert.Empty(t, got) + + applied, err = d.ReplaceSessionFrictionAtRevision(ctx, "s1", *s.TranscriptRevision, u) + require.NoError(t, err) + assert.True(t, applied) + got, err = d.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + assert.Len(t, got, 3) +} + +func TestStaleFrictionSessions(t *testing.T) { + d := testDB(t) + ctx := t.Context() + for _, id := range []string{"a", "b", "c", "empty"} { + insertSession(t, d, id, "proj") + } + _, err := d.getWriter().Exec(ctx, `UPDATE sessions SET message_count = 0 WHERE id = 'empty'`) + require.NoError(t, err) + require.NoError(t, d.ReplaceSessionFriction(ctx, "b", nil, nil, + friction.RulesVersion, FrictionHash(nil, nil, friction.RulesVersion))) + require.NoError(t, d.ReplaceSessionFriction(ctx, "c", nil, nil, + "friction-v0", FrictionHash(nil, nil, "friction-v0"))) + + ids, err := d.StaleFrictionSessions(ctx, friction.RulesVersion, 10) + require.NoError(t, err) + assert.Equal(t, []string{"a", "c", "empty"}, ids, + "stale means version differs; empty sessions still settle once") + + ids, err = d.StaleFrictionSessions(ctx, friction.RulesVersion, 1) + require.NoError(t, err) + assert.Equal(t, []string{"a"}, ids) +} + +func TestFrictionReadsPropagateTableProbeError(t *testing.T) { + d := testDB(t) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := d.SessionFrictionFindings(ctx, "s1") + require.ErrorIs(t, err, context.Canceled) + _, err = d.SessionFrictionDims(ctx, "s1") + require.ErrorIs(t, err, context.Canceled) +} diff --git a/internal/db/signals.go b/internal/db/signals.go index 197e8a30cf..c01bbebbb5 100644 --- a/internal/db/signals.go +++ b/internal/db/signals.go @@ -33,7 +33,11 @@ type SessionSignalUpdate struct { HasContextData bool SecretLeakCount int SecretsRulesVersion string - QualitySignals QualitySignals + // Friction, when non-nil, replaces the session's friction findings, + // dims and summary columns in the same transaction as the signals. + // Nil leaves stored friction untouched for incremental maintenance. + Friction *SessionFrictionUpdate + QualitySignals QualitySignals } // usageOnlySignalUpdate is the canonical derived-signal state for an archive @@ -59,6 +63,9 @@ func settleUsageOnlySignalsTx( if _, err := tx.Exec(`DELETE FROM session_signal_state WHERE session_id = ?`, sessionID); err != nil { return fmt.Errorf("clearing usage-only signal state: %w", err) } + if err := replaceSessionFrictionTx(tx, sessionID, settledUsageOnlyFriction()); err != nil { + return err + } return replaceSecretFindingsTx(tx, sessionID, nil, 0, "") } @@ -204,6 +211,11 @@ func updateSessionSignalsTx( return fmt.Errorf("writing full signal state for %s: %w", sessionID, err) } } + if u.Friction != nil { + if err := replaceSessionFrictionTx(tx, sessionID, *u.Friction); err != nil { + return err + } + } return nil } From 5b8d8877682af1d53e37e596eb62c034d403e2ac Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:56:51 +0000 Subject: [PATCH 21/62] test(db): keep friction tables optional for read-only opens --- internal/db/read_only_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/db/read_only_test.go b/internal/db/read_only_test.go index e6add4396f..9b66344035 100644 --- a/internal/db/read_only_test.go +++ b/internal/db/read_only_test.go @@ -535,3 +535,22 @@ func TestReadOnlySchemaAfterInitialCancellation(t *testing.T) { require.NoError(t, err) assert.Contains(t, required["sessions"], "id") } + +func TestOpenReadOnlyAllowsMissingFrictionTables(t *testing.T) { + path := createClosedTestDB(t, tempDBPath(t, "sessions.db"), func(d *DB) { + insertSession(t, d, "s1", "proj") + }) + execRawSQLite(t, path, "DROP TABLE friction_findings") + execRawSQLite(t, path, "DROP TABLE friction_session_dims") + + readonly, err := OpenReadOnly(t.Context(), path) + require.NoError(t, err, "friction tables are optional for read-only opens") + t.Cleanup(func() { require.NoError(t, readonly.Close()) }) + + findings, err := readonly.SessionFrictionFindings(t.Context(), "s1") + require.NoError(t, err) + assert.Empty(t, findings) + dims, err := readonly.SessionFrictionDims(t.Context(), "s1") + require.NoError(t, err) + assert.Nil(t, dims) +} From a1edce934ca9940a507028fab0531eba816542ad Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:11:04 +0000 Subject: [PATCH 22/62] feat(db): carry friction findings through resync copies --- internal/db/archive_content.go | 12 ++-- internal/db/archive_content_test.go | 16 +++++ internal/db/friction.go | 6 ++ internal/db/friction_test.go | 99 ++++++++++++++++++++++++++++- internal/db/orphaned.go | 35 ++++++++++ 5 files changed, 161 insertions(+), 7 deletions(-) diff --git a/internal/db/archive_content.go b/internal/db/archive_content.go index de51ed367a..f293241090 100644 --- a/internal/db/archive_content.go +++ b/internal/db/archive_content.go @@ -573,11 +573,10 @@ func dropCopiedToolContentTx( return fmt.Errorf("dropping copied %s: %w", statement.label, err) } } - // The source archive computed signals and secret findings from payloads - // this archive does not keep. Clearing the counters and their version - // markers hides the stale values now and lets the startup backfill - // recompute both from the projected rows, matching what the write path - // stores for freshly parsed sessions. + // The source archive computed signals, secret findings, and friction from + // payloads this archive does not keep. Clearing the counters and version + // markers hides stale values and lets startup backfill recompute all three + // from the projected rows, matching freshly parsed sessions. rows, err := tx.QueryContext(ctx, "SELECT id FROM "+tempIDsTable) if err != nil { return fmt.Errorf("listing copied sessions: %w", err) @@ -595,6 +594,9 @@ func dropCopiedToolContentTx( if err := replaceSecretFindingsTx(tx, id, nil, 0, ""); err != nil { return fmt.Errorf("clearing copied findings for %s: %w", id, err) } + if err := clearSessionFrictionTx(tx, id); err != nil { + return fmt.Errorf("clearing copied friction for %s: %w", id, err) + } } return nil } diff --git a/internal/db/archive_content_test.go b/internal/db/archive_content_test.go index bc85c0098b..b830d5709a 100644 --- a/internal/db/archive_content_test.go +++ b/internal/db/archive_content_test.go @@ -11,6 +11,7 @@ import ( "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/export" + "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/parser" ) @@ -826,6 +827,10 @@ func TestCopyOrphanedDataProjectsArchiveContent(t *testing.T) { []SecretFinding{{SessionID: "archived", RuleName: "aws-access-key"}}, 1, "rules-v1", )) + frictionRows, dims := frictionFixture("archived") + require.NoError(t, source.ReplaceSessionFriction(t.Context(), "archived", + frictionRows, dims, friction.RulesVersion, + FrictionHash(frictionRows, dims, friction.RulesVersion))) archivedRows, err := source.GetAllMessages(t.Context(), "archived") require.NoError(t, err) pinNote := "quoted the secret here" @@ -863,6 +868,11 @@ func TestCopyOrphanedDataProjectsArchiveContent(t *testing.T) { ) require.NoError(t, err) assert.Empty(t, findings) + frictionRows, err := destination.SessionFrictionFindings(t.Context(), "archived") + require.NoError(t, err) + assert.Empty(t, frictionRows, "findings from dropped tool payloads are cleared") + assert.Empty(t, stored.FrictionRulesVersion, + "the projected transcript must be recomputed") messages, err := destination.GetAllMessages(t.Context(), "archived") require.NoError(t, err) @@ -934,6 +944,12 @@ func TestCopyOrphanedDataProjectsArchiveContent(t *testing.T) { ) require.NoError(t, err) assert.Empty(t, findings) + frictionRows, err := destination.SessionFrictionFindings(t.Context(), "archived") + require.NoError(t, err) + assert.Empty(t, frictionRows) + assert.Zero(t, stored.FrictionCount) + assert.Equal(t, friction.RulesVersion, stored.FrictionRulesVersion) + assert.Equal(t, FrictionHash(nil, nil, friction.RulesVersion), stored.FrictionHash) messages, err := destination.GetAllMessages(t.Context(), "archived") require.NoError(t, err) diff --git a/internal/db/friction.go b/internal/db/friction.go index c4ab8356fc..f46de1ed2f 100644 --- a/internal/db/friction.go +++ b/internal/db/friction.go @@ -188,6 +188,12 @@ func settledUsageOnlyFriction() SessionFrictionUpdate { } } +// clearSessionFrictionTx drops a session's friction and marks it for +// recompute with an empty rules version after copied content is projected. +func clearSessionFrictionTx(tx transactionQueries, sessionID string) error { + return replaceSessionFrictionTx(tx, sessionID, SessionFrictionUpdate{}) +} + // ReplaceSessionFriction atomically replaces a session's friction findings // and dims and updates friction_count, friction_rules_version and // friction_hash. Under a usage-only archive policy it stores the settled diff --git a/internal/db/friction_test.go b/internal/db/friction_test.go index dded5277b2..f5c944c3f8 100644 --- a/internal/db/friction_test.go +++ b/internal/db/friction_test.go @@ -2,6 +2,7 @@ package db import ( "context" + "path/filepath" "testing" "time" @@ -121,7 +122,7 @@ func TestReplaceSessionMessagesRevokesFrictionVersion(t *testing.T) { func frictionFixture(sessionID string) ([]FrictionFinding, *FrictionSessionDims) { ord, call := 3, 0 at := time.Date(2026, 9, 16, 1, 35, 0, 120000000, time.UTC) - return []FrictionFinding{ + findings := []FrictionFinding{ { SessionID: sessionID, Kind: "correction", Detector: "correction.coding", MessageOrdinal: &ord, @@ -145,9 +146,11 @@ func frictionFixture(sessionID string) ([]FrictionFinding, *FrictionSessionDims) Title: "[friction/pattern] " + sessionID + ": iteration runaway", Fingerprint: "fl1:cc", Seq: 2, }, - }, &FrictionSessionDims{ + } + dims := &FrictionSessionDims{ SessionID: sessionID, Seat: "seat-02", DimsSource: "seat_pattern", } + return findings, dims } func TestReplaceSessionFrictionRoundTrip(t *testing.T) { @@ -358,3 +361,95 @@ func TestFrictionReadsPropagateTableProbeError(t *testing.T) { _, err = d.SessionFrictionDims(ctx, "s1") require.ErrorIs(t, err, context.Canceled) } + +func TestOrphanCopyPreservesFriction(t *testing.T) { + dir := t.TempDir() + ctx := t.Context() + srcPath := filepath.Join(dir, "old.db") + src, err := Open(ctx, srcPath) + require.NoError(t, err) + insertSession(t, src, "s1", "proj") + insertMessages(t, src, userMsg("s1", 0, "hello"), asstMsg("s1", 1, "reply")) + findings, dims := frictionFixture("s1") + hash := FrictionHash(findings, dims, friction.RulesVersion) + require.NoError(t, src.ReplaceSessionFriction(ctx, "s1", findings, dims, + friction.RulesVersion, hash)) + _, err = src.getWriter().Exec(ctx, + "UPDATE friction_findings SET created_at = '2020-01-01T00:00:00.000Z'") + require.NoError(t, err) + require.NoError(t, src.Close()) + + dst, err := Open(ctx, filepath.Join(dir, "new.db")) + require.NoError(t, err) + defer dst.Close() + count, err := dst.CopyOrphanedDataFrom(srcPath) + require.NoError(t, err) + require.Equal(t, 1, count) + + got, err := dst.SessionFrictionFindings(ctx, "s1") + require.NoError(t, err) + require.Len(t, got, 3) + gotDims, err := dst.SessionFrictionDims(ctx, "s1") + require.NoError(t, err) + assert.Equal(t, dims, gotDims) + s, err := dst.GetSessionFull(ctx, "s1") + require.NoError(t, err) + assert.Equal(t, 3, s.FrictionCount) + assert.Equal(t, friction.RulesVersion, s.FrictionRulesVersion) + assert.Equal(t, hash, s.FrictionHash) + var createdAt string + require.NoError(t, dst.getReader().QueryRow(ctx, + "SELECT created_at FROM friction_findings LIMIT 1").Scan(&createdAt)) + assert.Equal(t, "2020-01-01T00:00:00.000Z", createdAt) +} + +func TestOrphanCopyFromArchiveWithoutFrictionTables(t *testing.T) { + dir := t.TempDir() + ctx := t.Context() + srcPath := filepath.Join(dir, "old.db") + src, err := Open(ctx, srcPath) + require.NoError(t, err) + insertSession(t, src, "s1", "proj") + insertMessages(t, src, userMsg("s1", 0, "hello")) + require.NoError(t, src.Close()) + execRawSQLite(t, srcPath, "DROP TABLE friction_findings") + execRawSQLite(t, srcPath, "DROP TABLE friction_session_dims") + for _, c := range []string{"friction_count", "friction_rules_version", "friction_hash"} { + execRawSQLite(t, srcPath, "ALTER TABLE sessions DROP COLUMN "+c) + } + + dst, err := Open(ctx, filepath.Join(dir, "new.db")) + require.NoError(t, err) + defer dst.Close() + count, err := dst.CopyOrphanedDataFrom(srcPath) + require.NoError(t, err) + require.Equal(t, 1, count) + s, err := dst.GetSessionFull(ctx, "s1") + require.NoError(t, err) + assert.Empty(t, s.FrictionRulesVersion, "an old source leaves the session stale for backfill") +} + +func TestTrashedCopyPreservesFriction(t *testing.T) { + src := testDB(t) + insertSession(t, src, "s1", "proj") + findings, dims := frictionFixture("s1") + hash := FrictionHash(findings, dims, friction.RulesVersion) + require.NoError(t, src.ReplaceSessionFriction(t.Context(), "s1", findings, dims, + friction.RulesVersion, hash)) + require.NoError(t, src.SoftDeleteSession(t.Context(), "s1")) + + dst := testDB(t) + ids, err := dst.CopyTrashedDataFrom(src.Path()) + require.NoError(t, err) + assert.Equal(t, []string{"s1"}, ids) + got, err := dst.SessionFrictionFindings(t.Context(), "s1") + require.NoError(t, err) + require.Len(t, got, len(findings)) + gotDims, err := dst.SessionFrictionDims(t.Context(), "s1") + require.NoError(t, err) + assert.Equal(t, dims, gotDims) + s, err := dst.GetSessionFull(t.Context(), "s1") + require.NoError(t, err) + assert.Equal(t, len(findings), s.FrictionCount) + assert.Equal(t, hash, s.FrictionHash) +} diff --git a/internal/db/orphaned.go b/internal/db/orphaned.go index 9a9b4f7076..fbffde71dc 100644 --- a/internal/db/orphaned.go +++ b/internal/db/orphaned.go @@ -1851,6 +1851,7 @@ func orphanSessionCols(ctx context.Context, tx *sql.Tx) string { "is_truncated", "last_write_incremental", "transcript_revision", "secret_leak_count", "secrets_rules_version", + "friction_count", "friction_rules_version", "friction_hash", } { if oldDBHasColumn(ctx, tx, "sessions", c) { cols = append(cols, c) @@ -2182,6 +2183,40 @@ func copySessionDataForIDs( return fmt.Errorf("copying secret_findings: %w", err) } } + if oldDBHasTable(ctx, tx, "friction_findings") { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_findings + (session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version, created_at) + SELECT + session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version, created_at + FROM old_db.friction_findings + WHERE session_id IN ( + SELECT id FROM `+tempIDsTable+` + )`, + ); err != nil { + return fmt.Errorf("copying friction_findings: %w", err) + } + } + if oldDBHasTable(ctx, tx, "friction_session_dims") { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_session_dims + (session_id, seat, persona, channel, dims_source, + review_excluded) + SELECT + session_id, seat, persona, channel, dims_source, + review_excluded + FROM old_db.friction_session_dims + WHERE session_id IN ( + SELECT id FROM `+tempIDsTable+` + )`, + ); err != nil { + return fmt.Errorf("copying friction_session_dims: %w", err) + } + } if err := copyPinnedMessagesForIDs(ctx, tx, tempIDsTable); err != nil { return err From 747cf9a6b581dd6bb35cdc47690b3e287aeb37a1 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:20:04 +0000 Subject: [PATCH 23/62] feat(sync): map stored sessions onto the friction detectors --- internal/sync/friction_compute.go | 158 ++++++++++++++++++ internal/sync/friction_compute_test.go | 219 +++++++++++++++++++++++++ 2 files changed, 377 insertions(+) create mode 100644 internal/sync/friction_compute.go create mode 100644 internal/sync/friction_compute_test.go diff --git a/internal/sync/friction_compute.go b/internal/sync/friction_compute.go new file mode 100644 index 0000000000..c01464d1da --- /dev/null +++ b/internal/sync/friction_compute.go @@ -0,0 +1,158 @@ +package sync + +import ( + "context" + "fmt" + "time" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/parser" +) + +// FrictionDimsFunc supplies a session's friction dims (seat from +// user-configured seat patterns, NanoClaw persona/channel, and the trust +// filter's exclusion). PR 19 installs it; nil means no dims row and the +// coding correction detector. ok=false means "no dims for this session". +type FrictionDimsFunc func(ctx context.Context, s db.Session) (dims db.FrictionSessionDims, ok bool) + +type frictionOptions struct { + dims FrictionDimsFunc + redacted bool + // review is a test seam; nil means friction.Review. + review func(friction.SessionInput) []friction.Signal +} + +func parseFrictionTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{} + } + return t +} + +// frictionRawInput maps every stored message row to the friction adapter's +// raw rows; the adapter decides which rows the detectors see (spec §6.1, +// §6.8). CallIndex is the call's slice position, matching +// extractToolCallRows, so finding coordinates agree with signal and +// secret-finding coordinates. tool_calls carries no timestamp, so a call +// takes its message's. +func frictionRawInput( + msgs []db.Message, +) ([]friction.RawMessage, []friction.RawToolCall) { + raw := make([]friction.RawMessage, 0, len(msgs)) + var calls []friction.RawToolCall + for _, m := range msgs { + ts := parseFrictionTime(m.Timestamp) + raw = append(raw, friction.RawMessage{ + Ordinal: m.Ordinal, + Role: m.Role, + Content: m.Content, + ThinkingText: m.ThinkingText, + IsSystem: m.IsSystem, + IsCompactBoundary: m.IsCompactBoundary, + SourceSubtype: m.SourceSubtype, + Timestamp: ts, + ContextTokens: m.ContextTokens, + HasContextTokens: m.HasContextTokens, + }) + for ci, tc := range m.ToolCalls { + c := friction.RawToolCall{ + MessageOrdinal: m.Ordinal, + CallIndex: ci, + ToolName: tc.ToolName, + Category: tc.Category, + InputJSON: tc.InputJSON, + ResultContent: tc.ResultContent, + Timestamp: ts, + } + if n := len(tc.ResultEvents); n > 0 { + last := tc.ResultEvents[n-1] + c.LastEventContent = last.Content + c.EventStatus = last.Status + } + calls = append(calls, c) + } + } + return raw, calls +} + +// frictionIsSubAgent applies D13: a sub-agent has a parent session and the +// sub-agent relationship, not jilog's 16-zero id prefix. +func frictionIsSubAgent(s db.Session) bool { + return s.ParentSessionID != nil && *s.ParentSessionID != "" && + s.RelationshipType == string(parser.RelSubagent) +} + +func frictionDimsNonDefault(d db.FrictionSessionDims) bool { + return d.Seat != "" || d.Persona != "" || d.Channel != "" || + d.DimsSource != "" || d.ReviewExcluded +} + +// computeSessionFriction runs the friction detectors over a session's +// stored (projected) messages. pressureMax is the context pressure the +// signal pass computed for the same messages. A panic in the detectors is +// returned as an error so one bad session never aborts a sync. +func computeSessionFriction( + ctx context.Context, s db.Session, msgs []db.Message, + pressureMax *float64, o frictionOptions, +) (u db.SessionFrictionUpdate, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("friction compute %s: panic: %v", s.ID, r) + } + }() + dims := friction.Dims{Agent: s.Agent, Machine: s.Machine} + var row *db.FrictionSessionDims + excluded := false + if o.dims != nil { + if d, ok := o.dims(ctx, s); ok { + dims.Seat, dims.Persona, dims.Channel = d.Seat, d.Persona, d.Channel + excluded = d.ReviewExcluded + if frictionDimsNonDefault(d) { + d.SessionID = s.ID + row = &d + } + } + } + raw, calls := frictionRawInput(msgs) + in := friction.BuildSessionInput(s.ID, dims, frictionIsSubAgent(s), raw, calls, + friction.BuildOptions{ + RedactedToolRenderings: o.redacted, + PressureMax: pressureMax, + }) + in.Excluded = excluded + review := o.review + if review == nil { + review = friction.Review + } + sigs := review(in) + + u.RulesVersion = friction.RulesVersion + u.Dims = row + u.Findings = make([]db.FrictionFinding, 0, len(sigs)) + for _, sig := range sigs { + f := db.FrictionFinding{ + SessionID: s.ID, + Kind: string(sig.Kind), + Detector: sig.Detector, + MessageOrdinal: sig.Ordinal, + CallIndex: sig.CallIndex, + ToolName: sig.ToolName, + Label: sig.Label, + Text: sig.Text, + Evidence: sig.Evidence, + Title: sig.Title(), + Fingerprint: sig.Fingerprint(), + Seq: sig.Seq, + RulesVersion: friction.RulesVersion, + } + if !sig.OccurredAt.IsZero() { + at := sig.OccurredAt.UTC() + f.OccurredAt = &at + } + u.Findings = append(u.Findings, f) + } + u.Hash = db.FrictionHash(u.Findings, u.Dims, u.RulesVersion) + return u, nil +} diff --git a/internal/sync/friction_compute_test.go b/internal/sync/friction_compute_test.go new file mode 100644 index 0000000000..836f519f86 --- /dev/null +++ b/internal/sync/friction_compute_test.go @@ -0,0 +1,219 @@ +package sync + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +func TestFrictionRawInput(t *testing.T) { + msgs := []db.Message{ + { + Ordinal: 0, Role: "user", Content: "please fix it", + Timestamp: "2026-09-16T01:00:00Z", + }, + { + Ordinal: 2, Role: "assistant", Content: "[Bash] make test", + ThinkingText: "hmm", Timestamp: "2026-09-16T01:00:01.5Z", + ContextTokens: 1200, HasContextTokens: true, + ToolCalls: []db.ToolCall{ + { + ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"make test"}`, ResultContent: "old", + ResultEvents: []db.ToolResultEvent{ + {Status: "running", Content: "partial"}, + {Status: "errored", Content: "boom"}, + }, + }, + { + ToolName: "Read", Category: "Read", InputJSON: `{}`, + ResultContent: "file body", + }, + }, + }, + { + Ordinal: 4, Role: "user", Content: "[Request interrupted by user]", + IsSystem: true, SourceSubtype: "interrupted", + Timestamp: "2026-09-16T01:00:04Z", + }, + { + Ordinal: 5, Role: "assistant", Content: "summary", IsSystem: true, + IsCompactBoundary: true, SourceSubtype: "compact_boundary", + Timestamp: "not a time", + }, + } + raw, calls := frictionRawInput(msgs) + require.Len(t, raw, 4, "every stored row is handed to the adapter") + assert.Equal(t, 2, raw[1].Ordinal) + assert.Equal(t, "hmm", raw[1].ThinkingText) + assert.Equal(t, 1200, raw[1].ContextTokens) + assert.True(t, raw[1].HasContextTokens) + assert.True(t, raw[2].IsSystem) + assert.Equal(t, "interrupted", raw[2].SourceSubtype) + assert.True(t, raw[3].IsCompactBoundary) + assert.True(t, raw[3].Timestamp.IsZero(), "unparseable timestamps map to zero") + msgTime := time.Date(2026, 9, 16, 1, 0, 1, 500000000, time.UTC) + require.Len(t, calls, 2) + assert.Equal(t, friction.RawToolCall{ + MessageOrdinal: 2, CallIndex: 0, ToolName: "Bash", Category: "Bash", + InputJSON: `{"command":"make test"}`, ResultContent: "old", + LastEventContent: "boom", EventStatus: "errored", + Timestamp: msgTime, + }, calls[0]) + assert.Equal(t, 1, calls[1].CallIndex) + assert.Empty(t, calls[1].EventStatus) + assert.Equal(t, msgTime, calls[1].Timestamp, + "calls take the owning message timestamp") +} + +func TestFrictionIsSubAgent(t *testing.T) { + tests := []struct { + name string + s db.Session + want bool + }{ + {"root", db.Session{}, false}, + {"subagent", db.Session{ParentSessionID: new("p"), RelationshipType: "subagent"}, true}, + {"continuation", db.Session{ParentSessionID: new("p"), RelationshipType: "continuation"}, false}, + {"empty parent", db.Session{ParentSessionID: new(""), RelationshipType: "subagent"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, frictionIsSubAgent(tt.s)) + }) + } +} + +func frictionTestMessages(sessionID string) []db.Message { + return []db.Message{ + { + SessionID: sessionID, Ordinal: 0, Role: "user", Content: "please fix the failing build", + Timestamp: "2026-09-16T01:00:00Z", + }, + { + SessionID: sessionID, Ordinal: 1, Role: "assistant", Content: "Sure, I'll hardcode the path for now.", + Timestamp: "2026-09-16T01:00:01Z", + }, + { + SessionID: sessionID, Ordinal: 2, Role: "user", Content: "no, use the config file instead", + Timestamp: "2026-09-16T01:00:02Z", + }, + { + SessionID: sessionID, Ordinal: 3, Role: "assistant", Content: "Understood.", + Timestamp: "2026-09-16T01:00:03Z", + }, + { + SessionID: sessionID, Ordinal: 4, Role: "user", Content: "this is broken again, same error!!!", + Timestamp: "2026-09-16T01:00:04Z", + }, + { + SessionID: sessionID, Ordinal: 5, Role: "user", Content: "[Request interrupted by user]", + IsSystem: true, SourceSubtype: "interrupted", + Timestamp: "2026-09-16T01:00:05Z", + }, + } +} + +func TestComputeSessionFriction(t *testing.T) { + s := db.Session{ID: "s1", Agent: "claude", Machine: "local"} + u, err := computeSessionFriction(t.Context(), s, frictionTestMessages("s1"), nil, frictionOptions{}) + require.NoError(t, err) + require.Len(t, u.Findings, 4) + assert.Equal(t, friction.RulesVersion, u.RulesVersion) + assert.Nil(t, u.Dims, "no dims hook in PR 3, so no dims row") + + var kinds []string + for i, f := range u.Findings { + kinds = append(kinds, f.Kind+":"+f.Detector) + assert.Equal(t, i, f.Seq) + assert.Equal(t, "s1", f.SessionID) + assert.Equal(t, friction.RulesVersion, f.RulesVersion) + assert.Len(t, f.Fingerprint, len("fl1:")+64) + } + assert.Equal(t, []string{ + "correction:correction.coding", + "workaround:workaround", + "frustration:frustration", + "interruption:interruption", + }, kinds, "spec §6.8 run order; frustration and interruption persist") + assert.Equal(t, db.FrictionHash(u.Findings, u.Dims, friction.RulesVersion), u.Hash) + require.NotNil(t, u.Findings[0].MessageOrdinal) + assert.Equal(t, 2, *u.Findings[0].MessageOrdinal) + assert.Equal(t, "no, use the config file instead", u.Findings[0].Text) + require.NotNil(t, u.Findings[3].MessageOrdinal) + assert.Equal(t, 5, *u.Findings[3].MessageOrdinal) + assert.Empty(t, u.Findings[3].Text, "interruption findings carry no text") +} + +func TestComputeSessionFrictionDimsHook(t *testing.T) { + tests := []struct { + name string + hook FrictionDimsFunc + wantDims *db.FrictionSessionDims + wantEmpty bool + }{ + { + name: "seat pattern", + hook: func(context.Context, db.Session) (db.FrictionSessionDims, bool) { + return db.FrictionSessionDims{Seat: "seat-02", DimsSource: "seat_pattern"}, true + }, + wantDims: &db.FrictionSessionDims{SessionID: "s1", Seat: "seat-02", DimsSource: "seat_pattern"}, + }, + { + name: "excluded keeps the row and drops findings", + hook: func(context.Context, db.Session) (db.FrictionSessionDims, bool) { + return db.FrictionSessionDims{ + Persona: "helper", Channel: "general", + DimsSource: "nanoclaw", ReviewExcluded: true, + }, true + }, + wantDims: &db.FrictionSessionDims{ + SessionID: "s1", Persona: "helper", Channel: "general", + DimsSource: "nanoclaw", ReviewExcluded: true, + }, + wantEmpty: true, + }, + { + name: "hook declines", + hook: func(context.Context, db.Session) (db.FrictionSessionDims, bool) { + return db.FrictionSessionDims{}, false + }, + }, + { + name: "all-default answer writes no row", + hook: func(context.Context, db.Session) (db.FrictionSessionDims, bool) { + return db.FrictionSessionDims{}, true + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := db.Session{ID: "s1", Agent: "claude"} + u, err := computeSessionFriction(t.Context(), s, frictionTestMessages("s1"), nil, + frictionOptions{dims: tt.hook}) + require.NoError(t, err) + assert.Equal(t, tt.wantDims, u.Dims) + if tt.wantEmpty { + assert.Empty(t, u.Findings, "review_excluded sessions get no findings") + } else { + assert.NotEmpty(t, u.Findings) + } + }) + } +} + +func TestComputeSessionFrictionRecoversPanic(t *testing.T) { + s := db.Session{ID: "s1"} + _, err := computeSessionFriction(t.Context(), s, frictionTestMessages("s1"), nil, + frictionOptions{review: func(friction.SessionInput) []friction.Signal { + panic("detector bug") + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "detector bug") +} From efad5c14f89b7419ecf57f86752a078fc97914b1 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:44:29 +0000 Subject: [PATCH 24/62] feat(sync): compute friction findings on every session write --- .../sync/archive_content_integration_test.go | 30 ++++ internal/sync/engine.go | 45 +++++- internal/sync/friction_compute.go | 69 +++++++++ internal/sync/friction_compute_test.go | 134 ++++++++++++++++++ internal/sync/secret_scan.go | 15 +- internal/sync/signal_schedule.go | 19 +++ internal/sync/signal_schedule_test.go | 13 ++ internal/sync/watch_batch_sync.go | 1 + 8 files changed, 323 insertions(+), 3 deletions(-) diff --git a/internal/sync/archive_content_integration_test.go b/internal/sync/archive_content_integration_test.go index acda5adf20..1322da9bbe 100644 --- a/internal/sync/archive_content_integration_test.go +++ b/internal/sync/archive_content_integration_test.go @@ -807,3 +807,33 @@ func TestArchivePolicyCortexToolResultsDoNotChangeAutomation(t *testing.T) { }) } } + +func TestFrictionTranscriptsPolicyMatchesStoredRecompute(t *testing.T) { + claudeRoot := t.TempDir() + const sessionID = "transcripts-friction-session" + writeTranscriptsFixture(t, claudeRoot, sessionID) + + database := dbtest.OpenTestDB(t) + engine := sync.NewEngine(t.Context(), database, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {claudeRoot}}, + Machine: "local", + ArchiveContent: config.ArchiveContentTranscripts, + }) + t.Cleanup(engine.Close) + require.Equal(t, 1, engine.SyncAll(t.Context(), nil).Synced) + + written, err := database.GetSessionFull(t.Context(), sessionID) + require.NoError(t, err) + writtenFindings, err := database.SessionFrictionFindings(t.Context(), sessionID) + require.NoError(t, err) + for _, f := range writtenFindings { + assert.NotContains(t, f.Text, "make: command not found", + "dropped tool output never reaches a finding") + } + + require.NoError(t, engine.RecomputeFriction(t.Context(), sessionID)) + recomputed, err := database.GetSessionFull(t.Context(), sessionID) + require.NoError(t, err) + assert.Equal(t, written.FrictionHash, recomputed.FrictionHash, + "write-time friction equals a recompute from stored rows") +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 4a9aa58a07..81e179b525 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -28,6 +28,7 @@ import ( "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/export" + "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/pathutil" "go.kenn.io/agentsview/internal/secrets" @@ -505,6 +506,9 @@ type EngineConfig struct { // DisableSignalRecomputation skips quality and secret signal work. It is // reserved for bounded parsers whose result does not consume those fields. DisableSignalRecomputation bool + // FrictionDims supplies seat, persona, channel, and review exclusion + // when configured. Nil uses the default coding detector and no dims row. + FrictionDims FrictionDimsFunc // DisableFilesystemProjectDiscovery prevents project attribution from // touching working directories recorded in imported transcripts. Bounded // capture uses lexical metadata instead. @@ -719,7 +723,11 @@ type Engine struct { // recompute triggered by incremental writes, so streaming // sessions don't rescan their whole history on every appended // line. Close flushes and stops it. - signalSched *signalScheduler + signalSched *signalScheduler + frictionSched *signalScheduler + frictionDims FrictionDimsFunc + // frictionReviewHook replaces friction.Review in tests. + frictionReviewHook func(friction.SessionInput) []friction.Signal // containerMu guards the OpenCode-family shared-SQLite freshness // gate (see opencode_container_gate.go). trustedSQLiteContainers @@ -980,6 +988,7 @@ func NewEngine(ctx context.Context, stagedCodexMin: stagedCodexMinBytes(cfg.StagedCodexParseMinBytes), stagedCodexDir: stagedCodexDir, toolResultImages: toolResultImages, + frictionDims: cfg.FrictionDims, cwdFilter: newCwdPrefixFilter(cfg.IncludeCwdPrefixes), scanProtectedPaths: cfg.ScanProtectedPaths, homeDir: userHomeDirOrEmpty(), @@ -1059,6 +1068,27 @@ func NewEngine(ctx context.Context, if e.disableSignalRecompute { e.signalSched.stop() } + recomputeFriction := func(sessionID string) { + if err := e.recomputeFrictionFromDB(context.Background(), sessionID); err != nil { + log.Printf("friction: recompute %s: %v", sessionID, err) + e.frictionSched.deferRetry(sessionID) + } + } + if e.disableSignalRecompute { + recomputeFriction = func(string) {} + } + e.frictionSched = newSignalScheduler( + signalRecomputeInterval, signalRecomputeQuiet, + recomputeFriction, + func(flush func()) { + e.syncMu.Lock() + defer e.syncMu.Unlock() + flush() + }, + ) + if e.disableSignalRecompute { + e.frictionSched.stop() + } return e } @@ -1176,6 +1206,7 @@ func pathWithinRoot(path, root string) bool { // safe to call repeatedly. func (e *Engine) Close() { e.signalSched.stop() + e.frictionSched.stop() } // FlushSignals immediately recomputes signals for sessions with a @@ -1186,6 +1217,7 @@ func (e *Engine) Close() { // by the engine instead. func (e *Engine) FlushSignals() { e.signalSched.flushAll() + e.frictionSched.flushAll() } func providerFactoryMap( @@ -4311,6 +4343,7 @@ func (e *Engine) syncThenRunLocked( // deferred signal recomputes first (inline: syncMu is held) or // pushed sessions could carry stale signal/secret fields. e.signalSched.flushAllInline() + e.frictionSched.flushAllInline() e.clearCurrentProgress() if err := work(full || didResync); err != nil { return stats, err @@ -4441,6 +4474,7 @@ func (e *Engine) SyncThenRunWithRebuild( return stats, nil } e.signalSched.flushAllInline() + e.frictionSched.flushAllInline() e.clearCurrentProgress() if err := work(full || didResync, didResync); err != nil { return stats, err @@ -4699,6 +4733,7 @@ func (e *Engine) RunExclusiveFlushed(work func() error) error { e.syncMu.Lock() defer e.syncMu.Unlock() e.signalSched.flushAllInline() + e.frictionSched.flushAllInline() return work() } @@ -18537,6 +18572,10 @@ func (e *Engine) writeStagedFullParse( return err } e.anomalies.recordSanitize(pw.staged.ValidationStats()) + if !e.disableSignalRecompute { + // Staged tool results become readable only after the commit above. + e.frictionSched.markDirty(s.ID) + } return nil } @@ -19510,6 +19549,10 @@ func (e *Engine) writeIncremental(ctx context.Context, // write or flush retries. if !signalsMaintained { e.signalSched.markDirty(inc.sessionID) + } else { + // Friction windows span the session, so the maintained delta is + // insufficient to refresh its findings. + e.frictionSched.markDirtyDeferred(inc.sessionID) } if inc.providerStatHash != nil { e.recordProviderStatHash( diff --git a/internal/sync/friction_compute.go b/internal/sync/friction_compute.go index c01464d1da..9f4f998181 100644 --- a/internal/sync/friction_compute.go +++ b/internal/sync/friction_compute.go @@ -2,9 +2,12 @@ package sync import ( "context" + "errors" "fmt" + "log" "time" + "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/parser" @@ -156,3 +159,69 @@ func computeSessionFriction( u.Hash = db.FrictionHash(u.Findings, u.Dims, u.RulesVersion) return u, nil } + +func (e *Engine) frictionOptions() frictionOptions { + return frictionOptions{ + dims: e.frictionDims, + redacted: e.db.ArchiveContent() == config.ArchiveContentTranscripts, + review: e.frictionReviewHook, + } +} + +// attachFriction computes from the same projected messages as the signal +// pass. A detector failure leaves the update unset and the session stale for +// backfill without failing the content write. +func (e *Engine) attachFriction( + u *db.SessionSignalUpdate, s db.Session, msgs []db.Message, +) { + fu, err := computeSessionFriction( + context.Background(), s, msgs, u.ContextPressureMax, e.frictionOptions(), + ) + if err != nil { + log.Printf("friction: %v", err) + return + } + u.Friction = &fu +} + +// recomputeFrictionFromDB publishes a finding snapshot only when the +// transcript revision still matches the rows it was computed from. +func (e *Engine) recomputeFrictionFromDB(ctx context.Context, sessionID string) error { + sess, err := e.db.GetSessionFull(ctx, sessionID) + if err != nil { + return fmt.Errorf("loading session %s: %w", sessionID, err) + } + if sess == nil { + return nil + } + revision := "" + if sess.TranscriptRevision != nil { + revision = *sess.TranscriptRevision + } + msgs, err := e.db.GetAllMessages(ctx, sessionID) + if err != nil { + return fmt.Errorf("loading messages %s: %w", sessionID, err) + } + fu, err := computeSessionFriction( + ctx, *sess, msgs, sess.ContextPressureMax, e.frictionOptions(), + ) + if err != nil { + return err + } + applied, err := e.db.ReplaceSessionFrictionAtRevision(ctx, sessionID, revision, fu) + if err != nil { + return fmt.Errorf("publishing friction %s: %w", sessionID, err) + } + if !applied { + return fmt.Errorf("session %s changed during friction recompute", sessionID) + } + return nil +} + +// RecomputeFriction recomputes one session's friction from stored rows. +func (e *Engine) RecomputeFriction(ctx context.Context, sessionID string) error { + if e.refuseWriteInForceParse("RecomputeFriction") { + return errors.New("RecomputeFriction refused on report-only parse-diff engine") + } + return e.recomputeFrictionFromDB(ctx, sessionID) +} diff --git a/internal/sync/friction_compute_test.go b/internal/sync/friction_compute_test.go index 836f519f86..88b221c5a2 100644 --- a/internal/sync/friction_compute_test.go +++ b/internal/sync/friction_compute_test.go @@ -2,6 +2,8 @@ package sync import ( "context" + "os" + "path/filepath" "testing" "time" @@ -10,6 +12,7 @@ import ( "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/testjsonl" ) func TestFrictionRawInput(t *testing.T) { @@ -217,3 +220,134 @@ func TestComputeSessionFrictionRecoversPanic(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "detector bug") } + +func writeFrictionClaudeSession(t *testing.T, dir, name string, extra ...string) string { + t.Helper() + b := testjsonl.NewSessionBuilder(). + AddClaudeUser("2026-09-16T01:00:00Z", "please fix the failing build"). + AddClaudeAssistant("2026-09-16T01:00:01Z", "Sure, I'll hardcode the path for now."). + AddClaudeUser("2026-09-16T01:00:02Z", "no, use the config file instead"). + AddClaudeAssistant("2026-09-16T01:00:03Z", "Understood.") + for _, line := range extra { + b = b.AddRaw(line) + } + path := filepath.Join(dir, "proj", name+".jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(b.String()), 0o644)) + return path +} + +func frictionKinds(t *testing.T, d *db.DB, id string) []string { + t.Helper() + got, err := d.SessionFrictionFindings(t.Context(), id) + require.NoError(t, err) + kinds := make([]string, 0, len(got)) + for _, f := range got { + kinds = append(kinds, f.Kind+":"+f.Detector) + } + return kinds +} + +func TestSyncPersistsFrictionFindings(t *testing.T) { + fx := newEngineFixture(t) + path := writeFrictionClaudeSession(t, fx.claudeDir, "friction-a", + testjsonl.ClaudeAssistantJSON([]map[string]any{{ + "type": "tool_use", "id": "toolu_1", "name": "Bash", + "input": map[string]string{"command": "go test ./..."}, + }}, "2026-09-16T01:00:04Z"), + testjsonl.ClaudeToolResultUserJSON("toolu_1", + "bash: go: command not found", "2026-09-16T01:00:05Z"), + ) + stats := fx.engine.SyncAll(t.Context(), nil) + require.NotZero(t, stats.Synced) + id := fx.sessionIDFor(t, path) + + assert.Equal(t, []string{ + "correction:correction.coding", + "error:error", + "workaround:workaround", + }, frictionKinds(t, fx.db, id)) + s, err := fx.db.GetSessionFull(t.Context(), id) + require.NoError(t, err) + assert.Equal(t, 3, s.FrictionCount) + assert.Equal(t, friction.RulesVersion, s.FrictionRulesVersion) + assert.NotEmpty(t, s.FrictionHash) +} + +func TestFrictionRefreshesAfterIncrementalAppend(t *testing.T) { + fx := newEngineFixture(t) + path := writeFrictionClaudeSession(t, fx.claudeDir, "friction-grow") + fx.engine.SyncAll(t.Context(), nil) + id := fx.sessionIDFor(t, path) + before := frictionKinds(t, fx.db, id) + + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + require.NoError(t, err) + for _, line := range []string{ + testjsonl.ClaudeUserJSON("stop adding comments to every line", "2026-09-16T01:00:10Z"), + testjsonl.ClaudeAssistantJSON("Leaving the rest for later; next session I will finish.", "2026-09-16T01:00:11Z"), + } { + _, err = f.WriteString(line + "\n") + require.NoError(t, err) + } + require.NoError(t, f.Close()) + fx.engine.SyncAll(t.Context(), nil) + fx.engine.FlushSignals() + + after := frictionKinds(t, fx.db, id) + assert.Len(t, after, len(before)+2, "one new correction and one deferral") + assert.Contains(t, after, "deferral:deferral") + s, err := fx.db.GetSessionFull(t.Context(), id) + require.NoError(t, err) + assert.Equal(t, friction.RulesVersion, s.FrictionRulesVersion) +} + +func TestFrictionComputePanicLeavesSessionRetryable(t *testing.T) { + fx := newEngineFixture(t) + bad := writeFrictionClaudeSession(t, fx.claudeDir, "friction-bad") + good := writeFrictionClaudeSession(t, fx.claudeDir, "friction-good") + badID, goodID := fx.sessionIDFor(t, bad), fx.sessionIDFor(t, good) + fx.engine.frictionReviewHook = func(in friction.SessionInput) []friction.Signal { + if in.SubjectID == badID { + panic("detector bug") + } + return friction.Review(in) + } + + stats := fx.engine.SyncAll(t.Context(), nil) + assert.Equal(t, 2, stats.Synced, "a friction failure never fails the sync") + + badSess, err := fx.db.GetSessionFull(t.Context(), badID) + require.NoError(t, err) + assert.Empty(t, badSess.FrictionRulesVersion, "left stale for the backfill") + assert.Empty(t, frictionKinds(t, fx.db, badID)) + goodSess, err := fx.db.GetSessionFull(t.Context(), goodID) + require.NoError(t, err) + assert.Equal(t, friction.RulesVersion, goodSess.FrictionRulesVersion) + assert.NotEmpty(t, frictionKinds(t, fx.db, goodID)) +} + +func TestSyncPersistsInterruptionFinding(t *testing.T) { + fx := newEngineFixture(t) + path := writeFrictionClaudeSession(t, fx.claudeDir, "friction-interrupt", + testjsonl.ClaudeUserJSON("[Request interrupted by user]", "2026-09-16T01:00:06Z"), + ) + fx.engine.SyncAll(t.Context(), nil) + id := fx.sessionIDFor(t, path) + + got, err := fx.db.SessionFrictionFindings(t.Context(), id) + require.NoError(t, err) + var interruptions []db.FrictionFinding + for _, f := range got { + if f.Kind == "interruption" { + interruptions = append(interruptions, f) + } + } + require.Len(t, interruptions, 1, "one interrupted row, one finding") + assert.Equal(t, "interruption", interruptions[0].Detector) + assert.Empty(t, interruptions[0].Text) + require.NotNil(t, interruptions[0].MessageOrdinal) + s, err := fx.db.GetSessionFull(t.Context(), id) + require.NoError(t, err) + assert.Equal(t, len(got), s.FrictionCount, "friction_count counts interruptions") +} diff --git a/internal/sync/secret_scan.go b/internal/sync/secret_scan.go index 72122f756a..74946dceb3 100644 --- a/internal/sync/secret_scan.go +++ b/internal/sync/secret_scan.go @@ -164,7 +164,9 @@ func (e *Engine) computeSignalsAndSecretsForStorage( msgs, e.toolResultImages, ) } - return computeSignalsAndSecrets(s, msgs) + update, findings := computeSignalsAndSecrets(s, msgs) + e.attachFriction(&update, s, msgs) + return update, findings } // computeFullSignalsAndSecretsForStorage seeds full-parse state from the same @@ -180,5 +182,14 @@ func (e *Engine) computeFullSignalsAndSecretsForStorage( msgs, e.toolResultImages, ) } - return computeFullSignalsAndSecrets(s, msgs, failures) + update, findings, err := computeFullSignalsAndSecrets(s, msgs, failures) + if err != nil { + return update, findings, err + } + // Staged writes use placeholder tool results in memory. Their friction + // is recomputed from stored rows after the staged transaction commits. + if failures == nil { + e.attachFriction(&update, s, msgs) + } + return update, findings, nil } diff --git a/internal/sync/signal_schedule.go b/internal/sync/signal_schedule.go index f0337553dc..536a2146b9 100644 --- a/internal/sync/signal_schedule.go +++ b/internal/sync/signal_schedule.go @@ -95,6 +95,25 @@ func (s *signalScheduler) markDirty(sessionID string) { s.mu.Unlock() } +// markDirtyDeferred queues a recompute without loading session history on +// the caller's write path. The first mark starts the maximum wait interval; +// subsequent marks move only the quiet-period deadline. +func (s *signalScheduler) markDirtyDeferred(sessionID string) { + s.mu.Lock() + if s.stopped { + s.mu.Unlock() + s.run(sessionID) + return + } + now := s.now() + if _, pending := s.dirty[sessionID]; !pending { + s.last[sessionID] = now + } + s.dirty[sessionID] = now + s.armLocked() + s.mu.Unlock() +} + // deferRetry schedules a failed recompute without the leading-edge execution // of markDirty. Shutdown gets only its existing final flush attempt. func (s *signalScheduler) deferRetry(sessionID string) { diff --git a/internal/sync/signal_schedule_test.go b/internal/sync/signal_schedule_test.go index acdfe590ac..807fb23423 100644 --- a/internal/sync/signal_schedule_test.go +++ b/internal/sync/signal_schedule_test.go @@ -132,6 +132,19 @@ func TestSignalSchedulerFirstMarkRunsInline(t *testing.T) { "inline run should not arm a flush timer") } +func TestSignalSchedulerDeferredMarkPreservesIncrementalFastPath(t *testing.T) { + h := newSchedulerHarness(10*time.Second, 2*time.Second) + + h.sched.markDirtyDeferred("s1") + assert.Empty(t, h.runsSnapshot(), "the append must not load session history") + require.Equal(t, 1, h.armedCount()) + + h.advance(2 * time.Second) + h.fireTimer(t) + assert.Equal(t, []string{"s1"}, h.deferredSnapshot(), + "the quiet flush must publish the new findings") +} + func TestSignalSchedulerDefersWithinIntervalThenQuietFlush(t *testing.T) { h := newSchedulerHarness(10*time.Second, 2*time.Second) diff --git a/internal/sync/watch_batch_sync.go b/internal/sync/watch_batch_sync.go index 15f28e39ab..2874d993a0 100644 --- a/internal/sync/watch_batch_sync.go +++ b/internal/sync/watch_batch_sync.go @@ -463,6 +463,7 @@ func (e *Engine) SyncWatchBatchThenRun( return stats, nil } e.signalSched.flushAllInline() + e.frictionSched.flushAllInline() e.clearCurrentProgress() if work != nil { if err := work(); err != nil { From c585afd52bd0b2b71471f0bf1c9f9ad3c5ab7bfa Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:58:55 +0000 Subject: [PATCH 25/62] feat(sync): backfill friction findings on the reconcile tick --- cmd/agentsview/friction_backfill_test.go | 32 +++++++++ cmd/agentsview/main.go | 9 +++ internal/db/friction.go | 91 +++++++++++++++++++++++- internal/db/friction_test.go | 38 ++++++++++ internal/sync/friction_compute.go | 90 +++++++++++++++++++++++ internal/sync/friction_compute_test.go | 85 ++++++++++++++++++++++ 6 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 cmd/agentsview/friction_backfill_test.go diff --git a/cmd/agentsview/friction_backfill_test.go b/cmd/agentsview/friction_backfill_test.go new file mode 100644 index 0000000000..b30d8f5332 --- /dev/null +++ b/cmd/agentsview/friction_backfill_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/sync" +) + +func TestRecomputeStaleFrictionRunsBackfill(t *testing.T) { + database := dbtest.OpenTestDB(t) + dbtest.SeedSession(t, database, "s1", "proj") + dbtest.SeedMessages(t, database, + dbtest.UserMsg("s1", 0, "please fix the failing build"), + dbtest.AsstMsg("s1", 1, "I'll hardcode it for now."), + ) + engine := sync.NewEngine(t.Context(), database, sync.EngineConfig{Machine: "local"}) + t.Cleanup(engine.Close) + + recomputeStaleFriction(t.Context(), engine) + + s, err := database.GetSessionFull(t.Context(), "s1") + require.NoError(t, err) + assert.Equal(t, friction.RulesVersion, s.FrictionRulesVersion) + stale, err := database.StaleFrictionSessions(t.Context(), friction.RulesVersion, 10) + require.NoError(t, err) + assert.Empty(t, stale) +} diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index a3cc8d39b1..62248046cf 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -2840,6 +2840,7 @@ func startPeriodicSync( runScheduledSyncPass(ctx, engine, scheduledReconcileTargets(cfg)) runRemoteSourceSyncPass(ctx, engine, remoteRoots) recomputePendingSessions(engine, database) + recomputeStaleFriction(ctx, engine) }) } } @@ -3202,3 +3203,11 @@ func recomputePendingSessions( _ = engine.RecomputeSignals(context.Background(), id) } } + +// recomputeStaleFriction retries the guarded friction backfill on the +// scheduled reconcile tick. A current archive is a cheap no-op. +func recomputeStaleFriction(ctx context.Context, engine *sync.Engine) { + if _, err := engine.BackfillFriction(ctx); err != nil && ctx.Err() == nil { + log.Printf("friction backfill: %v", err) + } +} diff --git a/internal/db/friction.go b/internal/db/friction.go index f46de1ed2f..7d16d0099d 100644 --- a/internal/db/friction.go +++ b/internal/db/friction.go @@ -12,6 +12,7 @@ import ( "time" "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/stringutil" ) // FrictionFinding is one persisted friction detection (spec §5.2). Rows @@ -354,14 +355,100 @@ func (db *DB) SessionFrictionDims( // sessions are included so they settle once at the current version. func (db *DB) StaleFrictionSessions( ctx context.Context, rulesVersion string, limit int, +) ([]string, error) { + return db.StaleFrictionSessionsAfter(ctx, rulesVersion, "", limit) +} + +// StaleFrictionSessionsAfter pages past earlier ids, including sessions that +// failed in this pass, so they cannot starve later sessions. +func (db *DB) StaleFrictionSessionsAfter( + ctx context.Context, rulesVersion, afterID string, limit int, ) ([]string, error) { rows, err := db.getReader().QueryContext(ctx, ` SELECT id FROM sessions - WHERE friction_rules_version <> ? + WHERE friction_rules_version <> ? AND id > ? ORDER BY id - LIMIT ?`, rulesVersion, limit) + LIMIT ?`, rulesVersion, afterID, limit) if err != nil { return nil, fmt.Errorf("querying stale friction sessions: %w", err) } return scanStrings(rows) } + +// FrictionBackfillName identifies the resumable migration record. +const FrictionBackfillName = "friction_backfill_v1" + +// MarkFrictionStale queues the selected sessions for a later rules-version +// backfill. With no ids it queues every session. +func (db *DB) MarkFrictionStale(ctx context.Context, sessionIDs ...string) error { + db.mu.Lock() + defer db.mu.Unlock() + tx, err := db.getWriter().Begin(ctx) + if err != nil { + return fmt.Errorf("beginning friction stale tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + if len(sessionIDs) == 0 { + if _, err := tx.ExecContext(ctx, `UPDATE sessions SET friction_rules_version = ''`); err != nil { + return fmt.Errorf("marking friction stale: %w", err) + } + } else { + for _, id := range sessionIDs { + if _, err := tx.ExecContext(ctx, + `UPDATE sessions SET friction_rules_version = '' WHERE id = ?`, id, + ); err != nil { + return fmt.Errorf("marking friction stale %s: %w", id, err) + } + } + } + return tx.Commit() +} + +// MarkFrictionBackfill records the current backfill pass and progress. +func (db *DB) MarkFrictionBackfill( + ctx context.Context, state string, total, completed int, lastErr string, +) error { + db.mu.Lock() + defer db.mu.Unlock() + lastErr = stringutil.SafeTruncate(lastErr, 1024) + _, err := db.getWriter().Exec(ctx, ` + INSERT INTO background_migrations ( + name, state, total_items, completed_items, last_error, + started_at, completed_at + ) VALUES (?, ?, ?, ?, ?, + strftime('%Y-%m-%dT%H:%M:%fZ','now'), + CASE WHEN ? = 'completed' + THEN strftime('%Y-%m-%dT%H:%M:%fZ','now') END) + ON CONFLICT(name) DO UPDATE SET + state = excluded.state, + total_items = excluded.total_items, + completed_items = excluded.completed_items, + last_error = excluded.last_error, + started_at = CASE WHEN background_migrations.state = 'running' + THEN background_migrations.started_at + ELSE excluded.started_at END, + completed_at = excluded.completed_at, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, + FrictionBackfillName, state, total, completed, lastErr, state, + ) + if err != nil { + return fmt.Errorf("recording friction backfill state: %w", err) + } + return nil +} + +// FrictionBackfillState returns the recorded state, or empty when none exists. +func (db *DB) FrictionBackfillState(ctx context.Context) (string, error) { + var state string + err := db.getReader().QueryRow(ctx, + `SELECT state FROM background_migrations WHERE name = ?`, + FrictionBackfillName, + ).Scan(&state) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("reading friction backfill state: %w", err) + } + return state, nil +} diff --git a/internal/db/friction_test.go b/internal/db/friction_test.go index f5c944c3f8..e4943998c5 100644 --- a/internal/db/friction_test.go +++ b/internal/db/friction_test.go @@ -349,6 +349,44 @@ func TestStaleFrictionSessions(t *testing.T) { ids, err = d.StaleFrictionSessions(ctx, friction.RulesVersion, 1) require.NoError(t, err) assert.Equal(t, []string{"a"}, ids) + + ids, err = d.StaleFrictionSessionsAfter(ctx, friction.RulesVersion, "a", 1) + require.NoError(t, err) + assert.Equal(t, []string{"c"}, ids, + "a failed early session must not hide later backfill pages") +} + +func TestFrictionBackfillStateRoundTrip(t *testing.T) { + d := testDB(t) + ctx := t.Context() + state, err := d.FrictionBackfillState(ctx) + require.NoError(t, err) + assert.Empty(t, state) + require.NoError(t, d.MarkFrictionBackfill(ctx, "running", 10, 4, "")) + require.NoError(t, d.MarkFrictionBackfill(ctx, "completed", 10, 10, "")) + state, err = d.FrictionBackfillState(ctx) + require.NoError(t, err) + assert.Equal(t, "completed", state) + var total, done int + require.NoError(t, d.getReader().QueryRow(ctx, + `SELECT total_items, completed_items FROM background_migrations WHERE name = ?`, + FrictionBackfillName).Scan(&total, &done)) + assert.Equal(t, 10, total) + assert.Equal(t, 10, done) +} + +func TestMarkFrictionStaleTargetsRequestedSessions(t *testing.T) { + d := testDB(t) + ctx := t.Context() + for _, id := range []string{"a", "b"} { + insertSession(t, d, id, "proj") + require.NoError(t, d.ReplaceSessionFriction(ctx, id, nil, nil, + friction.RulesVersion, FrictionHash(nil, nil, friction.RulesVersion))) + } + require.NoError(t, d.MarkFrictionStale(ctx, "a")) + stale, err := d.StaleFrictionSessions(ctx, friction.RulesVersion, 10) + require.NoError(t, err) + assert.Equal(t, []string{"a"}, stale) } func TestFrictionReadsPropagateTableProbeError(t *testing.T) { diff --git a/internal/sync/friction_compute.go b/internal/sync/friction_compute.go index 9f4f998181..e7ea0becd8 100644 --- a/internal/sync/friction_compute.go +++ b/internal/sync/friction_compute.go @@ -225,3 +225,93 @@ func (e *Engine) RecomputeFriction(ctx context.Context, sessionID string) error } return e.recomputeFrictionFromDB(ctx, sessionID) } + +const frictionBackfillPage = 200 + +// BackfillFriction computes findings for sessions with an older rules +// version. Each session takes the sync lock separately so live sync can +// interleave with a long backfill. +func (e *Engine) BackfillFriction(ctx context.Context) (int, error) { + if e.refuseWriteInForceParse("BackfillFriction") { + return 0, errors.New("BackfillFriction refused on report-only parse-diff engine") + } + if e.disableSignalRecompute { + return 0, nil + } + ids, err := e.db.StaleFrictionSessions(ctx, friction.RulesVersion, 1) + if err != nil { + return 0, err + } + if len(ids) == 0 { + state, err := e.db.FrictionBackfillState(ctx) + if err != nil { + return 0, err + } + if state != "completed" { + return 0, e.db.MarkFrictionBackfill(ctx, "completed", 0, 0, "") + } + return 0, nil + } + if err := e.db.MarkFrictionBackfill(ctx, "running", 0, 0, ""); err != nil { + return 0, err + } + log.Printf("friction backfill: recomputing stale sessions") + processed, failed := 0, 0 + var lastErr error + cursor := "" + for { + ids, err := e.db.StaleFrictionSessionsAfter( + ctx, friction.RulesVersion, cursor, frictionBackfillPage, + ) + if err != nil { + return processed, err + } + if len(ids) == 0 { + break + } + for _, id := range ids { + if err := ctx.Err(); err != nil { + return processed, err + } + cursor = id + err := e.RunExclusive(func() error { + return e.recomputeFrictionFromDB(ctx, id) + }) + if err != nil { + failed++ + lastErr = err + log.Printf("friction backfill: %s: %v", id, err) + continue + } + processed++ + } + if err := e.db.MarkFrictionBackfill( + ctx, "running", processed+failed, processed, "", + ); err != nil { + return processed, err + } + } + remaining, err := e.db.StaleFrictionSessions(ctx, friction.RulesVersion, 1) + if err != nil { + return processed, err + } + if failed > 0 { + msg := fmt.Sprintf("%d sessions failed; last: %v", failed, lastErr) + if err := e.db.MarkFrictionBackfill( + ctx, "pending", processed+failed, processed, msg, + ); err != nil { + return processed, err + } + return processed, fmt.Errorf("friction backfill incomplete: %s", msg) + } + if len(remaining) > 0 { + // A concurrent writer introduced a stale session before the cursor. + return processed, e.db.MarkFrictionBackfill( + ctx, "pending", processed, processed, "", + ) + } + log.Printf("friction backfill: completed %d sessions", processed) + return processed, e.db.MarkFrictionBackfill( + ctx, "completed", processed, processed, "", + ) +} diff --git a/internal/sync/friction_compute_test.go b/internal/sync/friction_compute_test.go index 88b221c5a2..2cb3cbda96 100644 --- a/internal/sync/friction_compute_test.go +++ b/internal/sync/friction_compute_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/testjsonl" @@ -351,3 +352,87 @@ func TestSyncPersistsInterruptionFinding(t *testing.T) { require.NoError(t, err) assert.Equal(t, len(got), s.FrictionCount, "friction_count counts interruptions") } + +func TestFrictionSameOnSyncResyncAndBackfill(t *testing.T) { + fx := newEngineFixture(t) + path := writeFrictionClaudeSession(t, fx.claudeDir, "friction-stable") + require.Equal(t, 1, fx.engine.SyncAll(t.Context(), nil).Synced) + id := fx.sessionIDFor(t, path) + first, err := fx.db.SessionFrictionFindings(t.Context(), id) + require.NoError(t, err) + require.NotEmpty(t, first) + firstSess, err := fx.db.GetSessionFull(t.Context(), id) + require.NoError(t, err) + + fx.engine.ResyncAll(t.Context(), nil) + afterResync, err := fx.db.SessionFrictionFindings(t.Context(), id) + require.NoError(t, err) + assert.Equal(t, first, afterResync, "resync reproduces findings") + + require.NoError(t, fx.db.MarkFrictionStale(t.Context(), id)) + processed, err := fx.engine.BackfillFriction(t.Context()) + require.NoError(t, err) + assert.Equal(t, 1, processed) + afterBackfill, err := fx.db.SessionFrictionFindings(t.Context(), id) + require.NoError(t, err) + assert.Equal(t, first, afterBackfill, "backfill reproduces findings") + s, err := fx.db.GetSessionFull(t.Context(), id) + require.NoError(t, err) + assert.Equal(t, firstSess.FrictionHash, s.FrictionHash) + + processed, err = fx.engine.BackfillFriction(t.Context()) + require.NoError(t, err) + assert.Zero(t, processed, "nothing stale: the tick is a no-op") + state, err := fx.db.FrictionBackfillState(t.Context()) + require.NoError(t, err) + assert.Equal(t, "completed", state) +} + +func TestBackfillFrictionSettlesUsageOnlyArchive(t *testing.T) { + fx := newEngineFixture(t) + path := writeFrictionClaudeSession(t, fx.claudeDir, "friction-usage") + require.Equal(t, 1, fx.engine.SyncAll(t.Context(), nil).Synced) + id := fx.sessionIDFor(t, path) + fx.db.SetArchiveContent(config.ArchiveContentUsage) + require.NoError(t, fx.db.MarkFrictionStale(t.Context(), id)) + + _, err := fx.engine.BackfillFriction(t.Context()) + require.NoError(t, err) + stale, err := fx.db.StaleFrictionSessions(t.Context(), friction.RulesVersion, 10) + require.NoError(t, err) + assert.Empty(t, stale, "usage-only sessions settle and are never revisited") + assert.Empty(t, frictionKinds(t, fx.db, id)) +} + +func TestBackfillFrictionRetriesFailedSessionWithoutBlockingOthers(t *testing.T) { + fx := newEngineFixture(t) + bad := writeFrictionClaudeSession(t, fx.claudeDir, "friction-backfill-bad") + good := writeFrictionClaudeSession(t, fx.claudeDir, "friction-backfill-good") + require.Equal(t, 2, fx.engine.SyncAll(t.Context(), nil).Synced) + badID, goodID := fx.sessionIDFor(t, bad), fx.sessionIDFor(t, good) + require.NoError(t, fx.db.MarkFrictionStale(t.Context(), badID, goodID)) + fx.engine.frictionReviewHook = func(in friction.SessionInput) []friction.Signal { + if in.SubjectID == badID { + panic("detector bug") + } + return friction.Review(in) + } + + processed, err := fx.engine.BackfillFriction(t.Context()) + require.Error(t, err) + assert.Equal(t, 1, processed, "the other session should still settle") + stale, err := fx.db.StaleFrictionSessions(t.Context(), friction.RulesVersion, 10) + require.NoError(t, err) + assert.Equal(t, []string{badID}, stale) + state, err := fx.db.FrictionBackfillState(t.Context()) + require.NoError(t, err) + assert.Equal(t, "pending", state) + + fx.engine.frictionReviewHook = nil + processed, err = fx.engine.BackfillFriction(t.Context()) + require.NoError(t, err) + assert.Equal(t, 1, processed) + state, err = fx.db.FrictionBackfillState(t.Context()) + require.NoError(t, err) + assert.Equal(t, "completed", state) +} From c13ea2409e7bbe44e97207fdac70a3c2a6f2fb27 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:11:23 +0000 Subject: [PATCH 26/62] feat(postgres): add friction tables and session columns --- internal/postgres/friction_pgtest_test.go | 91 +++++++++++++++++++++++ internal/postgres/schema.go | 62 ++++++++++++++- internal/postgres/sessions.go | 2 + 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 internal/postgres/friction_pgtest_test.go diff --git a/internal/postgres/friction_pgtest_test.go b/internal/postgres/friction_pgtest_test.go new file mode 100644 index 0000000000..3d45c396c9 --- /dev/null +++ b/internal/postgres/friction_pgtest_test.go @@ -0,0 +1,91 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFrictionSchema(t *testing.T) { + pgURL := testPGURL(t) + cleanSchemaTestPG(t, pgURL) + t.Cleanup(func() { cleanSchemaTestPG(t, pgURL) }) + pg, err := Open(pgURL, schemaTestSchema, true) + require.NoError(t, err) + defer pg.Close() + ctx := context.Background() + require.NoError(t, EnsureSchema(ctx, pg, schemaTestSchema)) + require.NoError(t, EnsureSchema(ctx, pg, schemaTestSchema), "idempotent") + + tests := []struct { + table string + columns []string + }{ + {"friction_findings", []string{ + "id", "session_id", "kind", "detector", "message_ordinal", + "call_index", "tool_name", "label", "text", "evidence", "title", + "fingerprint", "occurred_at", "seq", "rules_version", "created_at", + }}, + {"friction_session_dims", []string{ + "session_id", "seat", "persona", "channel", "dims_source", + "review_excluded", + }}, + {"sessions", []string{ + "friction_count", "friction_rules_version", "friction_hash", + }}, + } + for _, tt := range tests { + t.Run(tt.table, func(t *testing.T) { + for _, c := range tt.columns { + var ok bool + require.NoError(t, pg.QueryRowContext(ctx, ` + SELECT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + AND column_name = $3)`, + schemaTestSchema, tt.table, c).Scan(&ok)) + assert.True(t, ok, "%s.%s", tt.table, c) + } + }) + } +} + +func TestCheckSchemaCompatMissingFrictionHash(t *testing.T) { + pgURL := testPGURL(t) + cleanPGSchema(t, pgURL) + t.Cleanup(func() { cleanPGSchema(t, pgURL) }) + pg, err := Open(pgURL, "agentsview", true) + require.NoError(t, err) + defer pg.Close() + ctx := context.Background() + require.NoError(t, EnsureSchema(ctx, pg, "agentsview")) + require.NoError(t, CheckSchemaCompat(ctx, pg)) + _, err = pg.ExecContext(ctx, `ALTER TABLE sessions DROP COLUMN friction_hash`) + require.NoError(t, err) + require.Error(t, CheckSchemaCompat(ctx, pg)) +} + +func TestPushSchemaCurrentRequiresFrictionTables(t *testing.T) { + for _, table := range []string{"friction_findings", "friction_session_dims"} { + t.Run(table, func(t *testing.T) { + pgURL := testPGURL(t) + cleanPGSchema(t, pgURL) + t.Cleanup(func() { cleanPGSchema(t, pgURL) }) + pg, err := Open(pgURL, "agentsview", true) + require.NoError(t, err) + defer pg.Close() + ctx := context.Background() + require.NoError(t, EnsureSchema(ctx, pg, "agentsview")) + require.True(t, pushSchemaCurrent(ctx, pg)) + _, err = pg.ExecContext(ctx, "DROP TABLE "+table) + require.NoError(t, err) + assert.False(t, pushSchemaCurrent(ctx, pg), + "an upgraded hub must run EnsureSchema before pushing friction") + require.NoError(t, EnsureSchema(ctx, pg, "agentsview")) + assert.True(t, pushSchemaCurrent(ctx, pg)) + }) + } +} diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index 52c7bb80e8..a3b795726f 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -187,6 +187,9 @@ CREATE TABLE IF NOT EXISTS sessions ( no_code_context_count INT NOT NULL DEFAULT 0, runaway_tool_loop_count INT NOT NULL DEFAULT 0, termination_status TEXT, + friction_count INT NOT NULL DEFAULT 0, + friction_rules_version TEXT NOT NULL DEFAULT '', + friction_hash TEXT NOT NULL DEFAULT '', transcript_revision TEXT NOT NULL DEFAULT '0', source_archive_id TEXT NOT NULL DEFAULT '', source_database_generation TEXT NOT NULL DEFAULT '', @@ -590,6 +593,43 @@ CREATE INDEX IF NOT EXISTS idx_secret_findings_session CREATE INDEX IF NOT EXISTS idx_secret_findings_rule ON secret_findings (rule_name); +CREATE TABLE IF NOT EXISTS friction_findings ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + detector TEXT NOT NULL, + message_ordinal INTEGER, + call_index INTEGER, + tool_name TEXT NOT NULL DEFAULT '', + label TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + fingerprint TEXT NOT NULL, + occurred_at TIMESTAMPTZ, + seq INTEGER NOT NULL, + rules_version TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_friction_findings_session + ON friction_findings (session_id); + +CREATE INDEX IF NOT EXISTS idx_friction_findings_fingerprint + ON friction_findings (fingerprint); + +CREATE INDEX IF NOT EXISTS idx_friction_findings_kind + ON friction_findings (kind); + +CREATE TABLE IF NOT EXISTS friction_session_dims ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + seat TEXT NOT NULL DEFAULT '', + persona TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL DEFAULT '', + dims_source TEXT NOT NULL DEFAULT '', + review_excluded BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TABLE IF NOT EXISTS insights ( id BIGSERIAL PRIMARY KEY, type TEXT NOT NULL DEFAULT '', @@ -1312,6 +1352,21 @@ func EnsureSchema( `secrets_rules_version TEXT NOT NULL DEFAULT ''`, "adding sessions.secrets_rules_version", }, + { + "sessions", "friction_count", + `friction_count INTEGER NOT NULL DEFAULT 0`, + "adding sessions.friction_count", + }, + { + "sessions", "friction_rules_version", + `friction_rules_version TEXT NOT NULL DEFAULT ''`, + "adding sessions.friction_rules_version", + }, + { + "sessions", "friction_hash", + `friction_hash TEXT NOT NULL DEFAULT ''`, + "adding sessions.friction_hash", + }, { "sessions", "session_name", `session_name TEXT`, @@ -2757,7 +2812,8 @@ func checkPushSchemaCompat(ctx context.Context, db *sql.DB) error { // not require push-only sessions.owner_marker (verified by // checkPushSchemaCompat), model_pricing (always queried by syncModelPricing) // or cursor_usage_events (written by syncCursorUsageEvents), so probe those -// explicitly. It also requires the cursor dedup index, which the cursor usage +// explicitly. Friction findings and dims are also written by push-only paths. +// It also requires the cursor dedup index, which the cursor usage // insert relies on for ON CONFLICT dedup. When any of these is missing the // caller must run EnsureSchema so push migrates the schema instead of failing // or duplicating rows. @@ -2778,7 +2834,9 @@ func pushSchemaCurrent(ctx context.Context, db *sql.DB) bool { !pgHasTable(ctx, db, "source_session_project_identity_snapshot_scopes") || !pgHasTable(ctx, db, "source_worktree_project_mappings") || !pgHasTable(ctx, db, "source_worktree_project_mapping_scopes") || - !pgHasTable(ctx, db, "cursor_usage_events") { + !pgHasTable(ctx, db, "cursor_usage_events") || + !pgHasTable(ctx, db, "friction_findings") || + !pgHasTable(ctx, db, "friction_session_dims") { return false } // bulkInsertCursorUsageEvents dedups via a targetless ON CONFLICT diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 3e28a9d0f1..28faafd7ef 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -71,6 +71,7 @@ const pgSessionBaseCols = `id, project, project_assigned, machine, agent, cwd, git_branch, source_session_id, source_version, transcript_fidelity, parser_malformed_lines, is_truncated, secret_leak_count, secrets_rules_version, + friction_count, friction_rules_version, friction_hash, deleted_at, deletion_cause, termination_status, transcript_revision` // pgSessionCols is the column list for full PG session queries. @@ -243,6 +244,7 @@ func scanPGSessionWithSource( &s.SourceSessionID, &s.SourceVersion, &s.TranscriptFidelity, &s.ParserMalformedLines, &s.IsTruncated, &s.SecretLeakCount, &s.SecretsRulesVersion, + &s.FrictionCount, &s.FrictionRulesVersion, &s.FrictionHash, &deletedAt, &s.DeletionCause, &s.TerminationStatus, &s.TranscriptRevision, } if includeSource { From 94ac8d17cadced1df94bfe18cfbea67b9f307975 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:22:59 +0000 Subject: [PATCH 27/62] feat(friction): fold observed spend and recurrence costs --- internal/friction/spend.go | 146 +++++++++++++++++++++ internal/friction/spend_test.go | 226 ++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 internal/friction/spend.go create mode 100644 internal/friction/spend_test.go diff --git a/internal/friction/spend.go b/internal/friction/spend.go new file mode 100644 index 0000000000..d9e791c3d8 --- /dev/null +++ b/internal/friction/spend.go @@ -0,0 +1,146 @@ +package friction + +import ( + "math/big" + "sort" +) + +// RootRole keys top-level sessions in SpendSummary.RoleCosts +// (jilog digest.rs:1199; spec §8.3 step 5, D28). +const RootRole = "(root)" + +// SessionUsage is one subject's usage, folded into the digest's spend +// and persona rollups. Role is RootRole or "subagent"; "" means root. +type SessionUsage struct { + SubjectID, Role string + InputTokens, OutputTokens uint64 + CostUSD *USD + ModelCosts map[string]USD +} + +// SpendSummary is the observed-spend block (jilog SpendSummary, +// digest.rs:183-198). Total is nil when no session carried a cost. +type SpendSummary struct { + Total *USD + SessionsWithStats, SessionsWithCost int + InputTokens, OutputTokens uint64 + RoleCosts, ModelCosts map[string]USD +} + +// Accumulate folds one session into the summary and returns its cost +// (nil when it had none), porting accumulate_stats (digest.rs:1169-1203). +// Costs arrive already parsed, so jilog's unparseable-cost warnings have +// no equivalent here. +func (s *SpendSummary) Accumulate(u SessionUsage) *USD { + s.SessionsWithStats++ + s.InputTokens += u.InputTokens + s.OutputTokens += u.OutputTokens + for _, model := range sortedKeys(u.ModelCosts) { + s.ModelCosts = addToMap(s.ModelCosts, model, u.ModelCosts[model]) + } + if u.CostUSD == nil { + return nil + } + cost := *u.CostUSD + s.SessionsWithCost++ + s.Total = addPtr(s.Total, cost) + role := u.Role + if role == "" { + role = RootRole + } + s.RoleCosts = addToMap(s.RoleCosts, role, cost) + return &cost +} + +// RecurrenceCostAnnotations ports recurrence_cost_annotations +// (digest.rs:1208-1243): for every non-deferral signal whose fingerprint +// was linked to an open issue before this build, sum the costs of the +// distinct subjects it occurred in. Fingerprints whose subjects carried +// no cost get no entry. +func RecurrenceCostAnnotations( + sigs []Signal, openFingerprints map[string]bool, sessionCosts map[string]USD, +) map[string]USD { + out := map[string]USD{} + if len(openFingerprints) == 0 { + return out + } + subjects := map[string]map[string]bool{} + for _, sig := range sigs { + // jilog excludes deferrals; D36 excludes interruptions. + if sig.Kind == KindDeferral || sig.Kind == KindInterruption { + continue + } + fp := sig.Fingerprint() + if !openFingerprints[fp] { + continue + } + if subjects[fp] == nil { + subjects[fp] = map[string]bool{} + } + subjects[fp][sig.SubjectID] = true + } + for fp, set := range subjects { + var sum *USD + for _, id := range sortedKeys(set) { + if c, ok := sessionCosts[id]; ok { + sum = addPtr(sum, c) + } + } + if sum != nil { + out[fp] = *sum + } + } + return out +} + +// addUSD adds b to a, treating the zero USD (nil Coeff) as rust_decimal's +// Decimal::ZERO at scale 0, so 0 + b keeps b's scale exactly. +func addUSD(a, b USD) USD { + if a.Coeff == nil { + return b + } + return a.Add(b) +} + +func addPtr(total *USD, v USD) *USD { + if total == nil { + c := v + return &c + } + sum := total.Add(v) + return &sum +} + +func addToMap(m map[string]USD, k string, v USD) map[string]USD { + if m == nil { + m = map[string]USD{} + } + m[k] = addUSD(m[k], v) + return m +} + +// cmpUSD compares two amounts numerically, independent of scale. +func cmpUSD(a, b USD) int { + scale := max(a.Scale, b.Scale) + return scaledCoeff(a, scale).Cmp(scaledCoeff(b, scale)) +} + +func scaledCoeff(u USD, scale uint8) *big.Int { + c := new(big.Int) + if u.Coeff != nil { + c.Set(u.Coeff) + } + if d := scale - u.Scale; d > 0 { + c.Mul(c, new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(d)), nil)) + } + return c +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/friction/spend_test.go b/internal/friction/spend_test.go new file mode 100644 index 0000000000..75713a83aa --- /dev/null +++ b/internal/friction/spend_test.go @@ -0,0 +1,226 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func usdPtr(u USD) *USD { return &u } + +// Ports digest.rs accumulate_stats (:1169-1203) through the fold-level +// assertions of run_review_annotates_recurring_signal_with_session_cost, +// run_review_no_recurrence_annotation_for_new_signals and +// stats_only_session_counts_toward_spend_and_personas. +func TestSpendSummaryAccumulate(t *testing.T) { + tests := []struct { + name string + usage []SessionUsage + total string // "" = nil + withStats int + withCost int + in, out uint64 + roles map[string]string + models map[string]string + }{ + { + name: "run_review_annotates_recurring_signal_with_session_cost", + usage: []SessionUsage{{ + SubjectID: "sess-r_explore", Role: "explore", + InputTokens: 100, OutputTokens: 10, + CostUSD: usdPtr(mustUSD(t, "4.2")), + }}, + total: "4.2", withStats: 1, withCost: 1, in: 100, out: 10, + roles: map[string]string{"explore": "4.2"}, + }, + { + name: "run_review_no_recurrence_annotation_for_new_signals", + usage: []SessionUsage{{ + SubjectID: "sess-n", InputTokens: 1, OutputTokens: 1, + CostUSD: usdPtr(mustUSD(t, "1.0")), + }}, + total: "1.0", withStats: 1, withCost: 1, in: 1, out: 1, + roles: map[string]string{"(root)": "1.0"}, + }, + { + name: "stats_only_session_counts_toward_spend_and_personas", + usage: []SessionUsage{{ + SubjectID: "sess-stats", InputTokens: 7, OutputTokens: 3, + }}, + total: "", withStats: 1, withCost: 0, in: 7, out: 3, + }, + { + name: "sums_keep_the_max_scale_and_model_costs_fold", + usage: []SessionUsage{ + {SubjectID: "a", Role: RootRole, CostUSD: usdPtr(mustUSD(t, "1.2")), + ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "1.2")}}, + {SubjectID: "b", Role: "subagent", CostUSD: usdPtr(mustUSD(t, "3")), + ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "3")}}, + }, + total: "4.2", withStats: 2, withCost: 2, + roles: map[string]string{"(root)": "1.2", "subagent": "3"}, + models: map[string]string{"claude-opus-5": "4.2"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var s SpendSummary + for _, u := range tt.usage { + cost := s.Accumulate(u) + if u.CostUSD == nil { + assert.Nil(t, cost) + } else { + require.NotNil(t, cost) + assert.Equal(t, u.CostUSD.String(), cost.String()) + } + } + if tt.total == "" { + assert.Nil(t, s.Total) + } else { + require.NotNil(t, s.Total) + assert.Equal(t, tt.total, s.Total.String()) + } + assert.Equal(t, tt.withStats, s.SessionsWithStats) + assert.Equal(t, tt.withCost, s.SessionsWithCost) + assert.Equal(t, tt.in, s.InputTokens) + assert.Equal(t, tt.out, s.OutputTokens) + assert.Equal(t, tt.roles, usdStrings(s.RoleCosts)) + assert.Equal(t, tt.models, usdStrings(s.ModelCosts)) + }) + } +} + +func usdStrings(m map[string]USD) map[string]string { + if len(m) == 0 { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v.String() + } + return out +} + +// Ports recurrence_cost_annotations (digest.rs:1208-1243), keyed by +// fingerprint instead of title. +func TestRecurrenceCostAnnotations(t *testing.T) { + corr := Signal{Kind: KindCorrection, SubjectID: "sess-r_explore", Text: "no, use the gh cli for calendar"} + corrOther := corr + corrOther.SubjectID = "sess-2" + errSig := Signal{Kind: KindError, SubjectID: "sess-3", ToolName: "bash", Text: "boom"} + def := Signal{Kind: KindDeferral, SubjectID: "sess-r_explore", Label: "next session"} + frus := Signal{Kind: KindFrustration, SubjectID: "sess-r_explore", Text: "this is broken again"} + intr := Signal{Kind: KindInterruption, SubjectID: "sess-r_explore", Text: "[Request interrupted by user]"} + costs := map[string]USD{ + "sess-r_explore": mustUSD(t, "4.2"), + "sess-2": mustUSD(t, "0.05"), + } + tests := []struct { + name string + sigs []Signal + open map[string]bool + want map[string]string + }{ + { + name: "run_review_annotates_recurring_signal_with_session_cost", + sigs: []Signal{corr}, + open: map[string]bool{corr.Fingerprint(): true}, + want: map[string]string{corr.Fingerprint(): "$4.20"}, + }, + { + name: "run_review_no_recurrence_annotation_for_new_signals", + sigs: []Signal{corr}, + open: map[string]bool{"fl1:other": true}, + want: map[string]string{}, + }, + { + name: "distinct_sessions_sum_with_max_scale", + // corr and corrOther have different titles (the correction + // title carries the session id), so give both fingerprints. + sigs: []Signal{corr, corr, corrOther}, + open: map[string]bool{corr.Fingerprint(): true, corrOther.Fingerprint(): true}, + want: map[string]string{ + corr.Fingerprint(): "$4.20", + corrOther.Fingerprint(): "$0.05", + }, + }, + { + name: "sessions_without_cost_give_no_annotation", + sigs: []Signal{errSig}, + open: map[string]bool{errSig.Fingerprint(): true}, + want: map[string]string{}, + }, + { + name: "deferrals_never_annotated", + sigs: []Signal{def}, + open: map[string]bool{def.Fingerprint(): true}, + want: map[string]string{}, + }, + { + name: "frustration_annotated_like_corrections", + sigs: []Signal{frus}, + open: map[string]bool{frus.Fingerprint(): true}, + want: map[string]string{frus.Fingerprint(): "$4.20"}, + }, + { + name: "interruptions_never_annotated", + sigs: []Signal{intr}, + open: map[string]bool{intr.Fingerprint(): true}, + want: map[string]string{}, + }, + { + name: "empty_open_set_short_circuits", + sigs: []Signal{corr}, + open: nil, + want: map[string]string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RecurrenceCostAnnotations(tt.sigs, tt.open, costs) + formatted := map[string]string{} + for fp, u := range got { + formatted[fp] = FormatUSD(u) + } + assert.Equal(t, tt.want, formatted) + }) + } +} + +func TestCmpUSD(t *testing.T) { + tests := []struct { + name string + a, b string + want int + }{ + {"equal_values_different_scale", "4.2", "4.200000", 0}, + {"greater", "224.55406", "104.943456", 1}, + {"less", "1", "1.000001", -1}, + {"negative", "-0.5", "0", -1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, cmpUSD(mustUSD(t, tt.a), mustUSD(t, tt.b))) + }) + } + assert.Equal(t, 0, cmpUSD(USD{}, mustUSD(t, "0")), "zero value compares as 0") +} + +// format_usd lives in PR 1's usd.go; the render contract depends on it, +// so the jilog test is re-pinned here (roadmap PR 4 acceptance). +func TestFormatUSDRenderContract(t *testing.T) { + tests := []struct{ in, want string }{ + {"4.2", "$4.20"}, + {"7", "$7.00"}, + {"0.0003", "$0.0003"}, + {"4.20", "$4.20"}, + {"332.138392", "$332.138392"}, + } + for _, tt := range tests { + t.Run("format_usd_pads_cents_but_keeps_subcent_precision/"+tt.in, func(t *testing.T) { + assert.Equal(t, tt.want, FormatUSD(mustUSD(t, tt.in))) + }) + } + assert.Equal(t, "$1300.250000", FormatUSD(USDFromMicros(1_300_250_000))) +} From 52ad51cd3e7d4b96368d464589e555247fff9bb0 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:36:23 +0000 Subject: [PATCH 28/62] feat(postgres): push friction findings with their session --- internal/postgres/friction.go | 102 ++++++++++++ internal/postgres/friction_pgtest_test.go | 183 ++++++++++++++++++++++ internal/postgres/push.go | 34 +++- internal/postgres/push_test.go | 26 ++- internal/postgres/schema_test.go | 4 + 5 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 internal/postgres/friction.go diff --git a/internal/postgres/friction.go b/internal/postgres/friction.go new file mode 100644 index 0000000000..fb9d3a75e9 --- /dev/null +++ b/internal/postgres/friction.go @@ -0,0 +1,102 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// pushFrictionFindings replaces one session's findings in the push +// transaction. PostgreSQL created_at records the push time. +func (s *Sync) pushFrictionFindings( + ctx context.Context, tx *sql.Tx, sessionID string, +) (bool, error) { + res, err := tx.ExecContext(ctx, + `DELETE FROM friction_findings WHERE session_id = $1`, sessionID) + if err != nil { + return false, fmt.Errorf("deleting pg friction_findings for %s: %w", sessionID, err) + } + deleted, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("counting deleted friction_findings for %s: %w", sessionID, err) + } + findings, err := s.local.SessionFrictionFindings(ctx, sessionID) + if err != nil { + return false, fmt.Errorf("reading local friction_findings for %s: %w", sessionID, err) + } + if len(findings) == 0 { + return deleted > 0, nil + } + const batch = 50 + const cols = 14 + for i := 0; i < len(findings); i += batch { + chunk := findings[i:min(i+batch, len(findings))] + var b strings.Builder + b.WriteString(`INSERT INTO friction_findings ( + session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version) VALUES `) + args := make([]any, 0, len(chunk)*cols) + for j, f := range chunk { + if j > 0 { + b.WriteByte(',') + } + b.WriteByte('(') + for k := range cols { + if k > 0 { + b.WriteByte(',') + } + fmt.Fprintf(&b, "$%d", j*cols+k+1) + } + b.WriteByte(')') + var occurred any + if f.OccurredAt != nil { + occurred = f.OccurredAt.UTC() + } + args = append(args, + f.SessionID, f.Kind, f.Detector, f.MessageOrdinal, f.CallIndex, + sanitizePG(f.ToolName), sanitizePG(f.Label), sanitizePG(f.Text), + sanitizePG(f.Evidence), sanitizePG(f.Title), f.Fingerprint, + occurred, f.Seq, f.RulesVersion, + ) + } + if _, err := tx.ExecContext(ctx, b.String(), args...); err != nil { + return false, fmt.Errorf("bulk inserting friction_findings for %s: %w", sessionID, err) + } + } + return true, nil +} + +// pushFrictionDims replaces one session's dimensions row in the push +// transaction and reports whether a row was inserted or deleted. +func (s *Sync) pushFrictionDims( + ctx context.Context, tx *sql.Tx, sessionID string, +) (bool, error) { + res, err := tx.ExecContext(ctx, + `DELETE FROM friction_session_dims WHERE session_id = $1`, sessionID) + if err != nil { + return false, fmt.Errorf("deleting pg friction_session_dims for %s: %w", sessionID, err) + } + deleted, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("counting deleted friction_session_dims for %s: %w", sessionID, err) + } + dims, err := s.local.SessionFrictionDims(ctx, sessionID) + if err != nil { + return false, fmt.Errorf("reading local friction_session_dims for %s: %w", sessionID, err) + } + if dims == nil { + return deleted > 0, nil + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_session_dims ( + session_id, seat, persona, channel, dims_source, review_excluded + ) VALUES ($1, $2, $3, $4, $5, $6)`, + sessionID, sanitizePG(dims.Seat), sanitizePG(dims.Persona), + sanitizePG(dims.Channel), sanitizePG(dims.DimsSource), dims.ReviewExcluded, + ); err != nil { + return false, fmt.Errorf("inserting friction_session_dims for %s: %w", sessionID, err) + } + return true, nil +} diff --git a/internal/postgres/friction_pgtest_test.go b/internal/postgres/friction_pgtest_test.go index 3d45c396c9..26034cc41a 100644 --- a/internal/postgres/friction_pgtest_test.go +++ b/internal/postgres/friction_pgtest_test.go @@ -5,9 +5,14 @@ package postgres import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/storage" ) func TestFrictionSchema(t *testing.T) { @@ -89,3 +94,181 @@ func TestPushSchemaCurrentRequiresFrictionTables(t *testing.T) { }) } } + +func seedFrictionSession(t *testing.T, local *db.DB, id string) { + t.Helper() + started := time.Now().UTC().Format(time.RFC3339) + first := "friction push test" + require.NoError(t, local.UpsertSession(t.Context(), db.Session{ + ID: id, Project: "friction-project", Machine: "local", + Agent: "claude", FirstMessage: &first, StartedAt: &started, + MessageCount: 1, + })) + require.NoError(t, local.InsertMessages(t.Context(), []db.Message{{ + SessionID: id, Ordinal: 0, Role: "user", Content: first, + }})) +} + +func pgFrictionFindings(t *testing.T, ps *Sync, id string) []db.FrictionFinding { + t.Helper() + rows, err := ps.pg.QueryContext(t.Context(), ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings WHERE session_id = $1 ORDER BY seq`, id) + require.NoError(t, err) + defer rows.Close() + out := []db.FrictionFinding{} + for rows.Next() { + var f db.FrictionFinding + require.NoError(t, rows.Scan(&f.SessionID, &f.Kind, &f.Detector, + &f.MessageOrdinal, &f.CallIndex, &f.ToolName, &f.Label, &f.Text, + &f.Evidence, &f.Title, &f.Fingerprint, &f.OccurredAt, &f.Seq, + &f.RulesVersion)) + if f.OccurredAt != nil { + utc := f.OccurredAt.UTC() + f.OccurredAt = &utc + } + out = append(out, f) + } + require.NoError(t, rows.Err()) + return out +} + +func testFrictionRows(id string) ([]db.FrictionFinding, *db.FrictionSessionDims) { + ord, call := 0, 0 + at := time.Date(2026, 9, 16, 1, 35, 0, 120000000, time.UTC) + findings := []db.FrictionFinding{ + { + SessionID: id, Kind: "correction", Detector: "correction.coding", + MessageOrdinal: &ord, Text: "no, use the config file instead", + Title: "[friction/correction] " + id + ": no, use the config file instead", + Fingerprint: "fl1:aa", OccurredAt: &at, Seq: 0, + RulesVersion: friction.RulesVersion, + }, + { + SessionID: id, Kind: "error", Detector: "error", MessageOrdinal: &ord, + CallIndex: &call, ToolName: "Bash", + Text: `{"error":"bash: go: command not found","success":false}`, + Title: "[friction/error] Bash: error", Fingerprint: "fl1:bb", + Seq: 1, RulesVersion: friction.RulesVersion, + }, + } + dims := &db.FrictionSessionDims{SessionID: id, Seat: "seat-02", DimsSource: "seat_pattern"} + return findings, dims +} + +func TestPGPushFrictionRoundTrip(t *testing.T) { + pgURL := testPGURL(t) + cleanPGSchema(t, pgURL) + t.Cleanup(func() { cleanPGSchema(t, pgURL) }) + local := testDB(t) + ps, err := New(pgURL, "agentsview", local, "machine-friction", true, storage.PusherOptions{}) + require.NoError(t, err) + defer ps.Close() + ctx := t.Context() + require.NoError(t, ps.EnsureSchema(ctx)) + + const id = "friction-sess-001" + seedFrictionSession(t, local, id) + findings, dims := testFrictionRows(id) + hash := db.FrictionHash(findings, dims, friction.RulesVersion) + require.NoError(t, local.ReplaceSessionFriction(ctx, id, findings, dims, + friction.RulesVersion, hash)) + res, err := ps.Push(ctx, false, nil) + require.NoError(t, err) + require.Equal(t, 1, res.SessionsPushed) + assert.Equal(t, findings, pgFrictionFindings(t, ps, id)) + + var seat, source string + var excluded bool + require.NoError(t, ps.pg.QueryRowContext(ctx, ` + SELECT seat, dims_source, review_excluded + FROM friction_session_dims WHERE session_id = $1`, id, + ).Scan(&seat, &source, &excluded)) + assert.Equal(t, "seat-02", seat) + assert.Equal(t, "seat_pattern", source) + assert.False(t, excluded) + var count int + var version, pgHash string + require.NoError(t, ps.pg.QueryRowContext(ctx, ` + SELECT friction_count, friction_rules_version, friction_hash + FROM sessions WHERE id = $1`, id).Scan(&count, &version, &pgHash)) + assert.Equal(t, 2, count) + assert.Equal(t, friction.RulesVersion, version) + assert.Equal(t, hash, pgHash) + assert.Equal(t, hash, db.FrictionHash(pgFrictionFindings(t, ps, id), dims, version)) +} + +func TestPGPushFrictionChangeRepushesAndUnchangedSkips(t *testing.T) { + pgURL := testPGURL(t) + cleanPGSchema(t, pgURL) + t.Cleanup(func() { cleanPGSchema(t, pgURL) }) + local := testDB(t) + ps, err := New(pgURL, "agentsview", local, "machine-friction-2", true, storage.PusherOptions{}) + require.NoError(t, err) + defer ps.Close() + ctx := t.Context() + require.NoError(t, ps.EnsureSchema(ctx)) + + const id = "friction-sess-002" + seedFrictionSession(t, local, id) + findings, dims := testFrictionRows(id) + require.NoError(t, local.ReplaceSessionFriction(ctx, id, findings, dims, + friction.RulesVersion, db.FrictionHash(findings, dims, friction.RulesVersion))) + _, err = ps.Push(ctx, false, nil) + require.NoError(t, err) + r2, err := ps.Push(ctx, false, nil) + require.NoError(t, err) + assert.Equal(t, 0, r2.SessionsPushed) + assert.Len(t, pgFrictionFindings(t, ps, id), 2) + + only := findings[:1] + require.NoError(t, local.ReplaceSessionFriction(ctx, id, only, nil, + friction.RulesVersion, db.FrictionHash(only, nil, friction.RulesVersion))) + r3, err := ps.Push(ctx, false, nil) + require.NoError(t, err) + assert.Equal(t, 1, r3.SessionsPushed) + assert.Equal(t, only, pgFrictionFindings(t, ps, id)) + var dimsRows int + require.NoError(t, ps.pg.QueryRowContext(ctx, + `SELECT count(*) FROM friction_session_dims WHERE session_id = $1`, id, + ).Scan(&dimsRows)) + assert.Zero(t, dimsRows) +} + +func TestPushFrictionReportsChange(t *testing.T) { + pgURL := testPGURL(t) + cleanPGSchema(t, pgURL) + t.Cleanup(func() { cleanPGSchema(t, pgURL) }) + local := testDB(t) + ps, err := New(pgURL, "agentsview", local, "machine-friction-3", true, storage.PusherOptions{}) + require.NoError(t, err) + defer ps.Close() + ctx := t.Context() + require.NoError(t, ps.EnsureSchema(ctx)) + const id = "friction-sess-003" + seedFrictionSession(t, local, id) + _, err = ps.Push(ctx, false, nil) + require.NoError(t, err) + + pushOnce := func() (bool, bool) { + tx, err := ps.pg.BeginTx(ctx, nil) + require.NoError(t, err) + f, err := ps.pushFrictionFindings(ctx, tx, id) + require.NoError(t, err) + d, err := ps.pushFrictionDims(ctx, tx, id) + require.NoError(t, err) + require.NoError(t, tx.Commit()) + return f, d + } + f, d := pushOnce() + assert.False(t, f) + assert.False(t, d) + findings, dims := testFrictionRows(id) + require.NoError(t, local.ReplaceSessionFriction(ctx, id, findings, dims, + friction.RulesVersion, db.FrictionHash(findings, dims, friction.RulesVersion))) + f, d = pushOnce() + assert.True(t, f) + assert.True(t, d) +} diff --git a/internal/postgres/push.go b/internal/postgres/push.go index 024517218e..2c089e7ab8 100644 --- a/internal/postgres/push.go +++ b/internal/postgres/push.go @@ -1316,12 +1316,24 @@ func (s *Sync) pushBatchAttempt( *pushed = (*pushed)[:len(*pushed)-n] return batchResult{}, nil } + frictionChanged, err := s.pushFrictionFindings(ctx, tx, sess.ID) + if err == nil { + var dimsChanged bool + dimsChanged, err = s.pushFrictionDims(ctx, tx, sess.ID) + frictionChanged = frictionChanged || dimsChanged + } + if err != nil { + log.Printf("pgsync: friction findings %s: %v", sess.ID, err) + _ = tx.Rollback() + *pushed = (*pushed)[:len(*pushed)-n] + return batchResult{}, nil + } - // Bump updated_at when messages or secret findings were + // Bump updated_at when messages, secret findings, or friction were // rewritten but pushSession was a metadata no-op (its // WHERE clause skips unchanged rows). PG read-mode session - // watchers rely on updated_at to surface secret-only changes. - if msgCount > 0 || findingsChanged { + // watchers rely on updated_at to surface finding-only changes. + if msgCount > 0 || findingsChanged || frictionChanged { if _, err := tx.ExecContext(ctx, ` UPDATE sessions SET updated_at = NOW() @@ -2099,6 +2111,9 @@ func sessionPushFingerprint( stringValue(sess.TerminationStatus), strconv.Itoa(sess.SecretLeakCount), sess.SecretsRulesVersion, + strconv.Itoa(sess.FrictionCount), + sess.FrictionRulesVersion, + sess.FrictionHash, usageEventFingerprint, } var b strings.Builder @@ -2282,7 +2297,9 @@ func (s *Sync) pushSession( transcript_fidelity, transcript_revision, agent_label, entrypoint, session_kind, source_archive_id, source_database_generation, file_path, - project_assigned, prompt_evidence_discarded, updated_at + project_assigned, prompt_evidence_discarded, + friction_count, friction_rules_version, friction_hash, + updated_at ) SELECT $1, $2, $3, $4, $5, $6, $7, $8, @@ -2300,7 +2317,7 @@ func (s *Sync) pushSession( $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62, $63, $64, $65, $66, $67, $68, - $70, NOW() + $70, $71, $72, $73, NOW() WHERE NOT EXISTS ( SELECT 1 FROM excluded_sessions WHERE id = $1 ) @@ -2384,6 +2401,9 @@ func (s *Sync) pushSession( has_context_data = EXCLUDED.has_context_data, secret_leak_count = EXCLUDED.secret_leak_count, secrets_rules_version = EXCLUDED.secrets_rules_version, + friction_count = EXCLUDED.friction_count, + friction_rules_version = EXCLUDED.friction_rules_version, + friction_hash = EXCLUDED.friction_hash, quality_signal_version = EXCLUDED.quality_signal_version, short_prompt_count = EXCLUDED.short_prompt_count, unstructured_start = EXCLUDED.unstructured_start, @@ -2471,6 +2491,9 @@ func (s *Sync) pushSession( OR sessions.has_context_data IS DISTINCT FROM EXCLUDED.has_context_data OR sessions.secret_leak_count IS DISTINCT FROM EXCLUDED.secret_leak_count OR sessions.secrets_rules_version IS DISTINCT FROM EXCLUDED.secrets_rules_version + OR sessions.friction_count IS DISTINCT FROM EXCLUDED.friction_count + OR sessions.friction_rules_version IS DISTINCT FROM EXCLUDED.friction_rules_version + OR sessions.friction_hash IS DISTINCT FROM EXCLUDED.friction_hash OR sessions.quality_signal_version IS DISTINCT FROM EXCLUDED.quality_signal_version OR sessions.short_prompt_count IS DISTINCT FROM EXCLUDED.short_prompt_count OR sessions.unstructured_start IS DISTINCT FROM EXCLUDED.unstructured_start @@ -2530,6 +2553,7 @@ func (s *Sync) pushSession( sess.ProjectAssigned, string(legacyMarkerMachinesJSON), s.local.ArchiveContent().UsageOnly(), + sess.FrictionCount, sess.FrictionRulesVersion, sess.FrictionHash, ) if err != nil { return err diff --git a/internal/postgres/push_test.go b/internal/postgres/push_test.go index ec6698b9a4..3472396680 100644 --- a/internal/postgres/push_test.go +++ b/internal/postgres/push_test.go @@ -756,7 +756,7 @@ func TestPushSessionCarriesDeletionCauseInStableParameterOrder(t *testing.T) { "marker", nil, ) require.NoError(t, err) - require.Len(t, state.upsertArgs, 70) + require.Len(t, state.upsertArgs, 73) assert.IsType(t, time.Time{}, state.upsertArgs[12].Value) assert.IsType(t, time.Time{}, state.upsertArgs[13].Value) assert.Equal(t, cause, state.upsertArgs[14].Value) @@ -765,6 +765,9 @@ func TestPushSessionCarriesDeletionCauseInStableParameterOrder(t *testing.T) { assert.Empty(t, state.upsertArgs[63].Value) assert.Equal(t, false, state.upsertArgs[67].Value) assert.Equal(t, "[]", state.upsertArgs[68].Value) + assert.Equal(t, 0, state.upsertArgs[70].Value) + assert.Empty(t, state.upsertArgs[71].Value) + assert.Empty(t, state.upsertArgs[72].Value) query := strings.ToLower(strings.Join(strings.Fields(state.upsertQuery), " ")) assert.Contains(t, query, @@ -1257,6 +1260,27 @@ func TestSessionPushFingerprintDiffers(t *testing.T) { return s }, }, + { + name: "friction hash change", + modify: func(s db.Session) db.Session { + s.FrictionHash = "abc" + return s + }, + }, + { + name: "friction count change", + modify: func(s db.Session) db.Session { + s.FrictionCount = 2 + return s + }, + }, + { + name: "friction rules version change", + modify: func(s db.Session) db.Session { + s.FrictionRulesVersion = "friction-v1" + return s + }, + }, } for _, tc := range tests { diff --git a/internal/postgres/schema_test.go b/internal/postgres/schema_test.go index c524b8368c..5b841486ac 100644 --- a/internal/postgres/schema_test.go +++ b/internal/postgres/schema_test.go @@ -596,6 +596,8 @@ func TestSyncEnsureSchemaSkipsLegacyDDLWhenSchemaCompatible(t *testing.T) { "source_worktree_project_mappings": true, "source_worktree_project_mapping_scopes": true, "cursor_usage_events": true, + "friction_findings": true, + "friction_session_dims": true, } state.existingIndexes = map[string]bool{ "idx_cursor_usage_events_dedup": true, @@ -636,6 +638,8 @@ func TestEnsureSchemaScrubsProjectIdentityGitRemoteCredentials(t *testing.T) { "source_worktree_project_mappings": true, "source_worktree_project_mapping_scopes": true, "cursor_usage_events": true, + "friction_findings": true, + "friction_session_dims": true, } state.existingIndexes = map[string]bool{ "idx_cursor_usage_events_dedup": true, From 89046977164286484598bde821a1d1448f0b3d91 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:36:29 +0000 Subject: [PATCH 29/62] feat(friction): summarize trailing-week archive spend --- internal/friction/spend.go | 107 ++++++++++++++++++++++++++++++++ internal/friction/spend_test.go | 104 +++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/internal/friction/spend.go b/internal/friction/spend.go index d9e791c3d8..85616df463 100644 --- a/internal/friction/spend.go +++ b/internal/friction/spend.go @@ -1,8 +1,10 @@ package friction import ( + "fmt" "math/big" "sort" + "time" ) // RootRole keys top-level sessions in SpendSummary.RoleCosts @@ -144,3 +146,108 @@ func sortedKeys[V any](m map[string]V) []string { sort.Strings(keys) return keys } + +// PeriodSpend is archive spend over one day or the trailing week. +// Days counts rows present. +type PeriodSpend struct { + Total USD + Days int + Agents, Models map[string]USD +} + +// ArchiveSpend is the archive spend block. WeekFrom and WeekTo are +// YYYY-MM-DD dates in Timezone. +type ArchiveSpend struct { + Yesterday *PeriodSpend + Week PeriodSpend + WeekFrom, WeekTo, Timezone string +} + +// DailySpend is one local-date usage row. +type DailySpend struct { + Date string + Total USD + Agents, Models map[string]USD +} + +// NamedUSD is one ranked agent or model cost. +type NamedUSD struct { + Name string + USD USD +} + +func (p *PeriodSpend) add(r DailySpend) { + p.Days++ + p.Total = addUSD(p.Total, r.Total) + for _, k := range sortedKeys(r.Agents) { + p.Agents = addToMap(p.Agents, k, r.Agents[k]) + } + for _, k := range sortedKeys(r.Models) { + p.Models = addToMap(p.Models, k, r.Models[k]) + } +} + +// AgentsByCost ranks agents by cost descending, ties by name. +func (p PeriodSpend) AgentsByCost() []NamedUSD { return rankByCost(p.Agents) } + +// TopModels returns the n most expensive models, ties by name. +func (p PeriodSpend) TopModels(n int) []NamedUSD { + ranked := rankByCost(p.Models) + if len(ranked) > n { + ranked = ranked[:n] + } + return ranked +} + +func rankByCost(m map[string]USD) []NamedUSD { + out := make([]NamedUSD, 0, len(m)) + for _, k := range sortedKeys(m) { + out = append(out, NamedUSD{Name: k, USD: m[k]}) + } + sort.SliceStable(out, func(i, j int) bool { + return cmpUSD(out[i].USD, out[j].USD) > 0 + }) + return out +} + +// ArchiveWindow returns (date-7, date-1), excluding the digest day. +func ArchiveWindow(date string) (from, to string, err error) { + d, err := time.Parse(time.DateOnly, date) + if err != nil { + return "", "", fmt.Errorf("archive spend window: %w", err) + } + return d.AddDate(0, 0, -7).Format(time.DateOnly), + d.AddDate(0, 0, -1).Format(time.DateOnly), nil +} + +// SummarizeArchiveSpend buckets rows into yesterday and the trailing +// week, returning nil when no row falls in the window. +func SummarizeArchiveSpend(rows []DailySpend, date, timezone string) (*ArchiveSpend, error) { + from, to, err := ArchiveWindow(date) + if err != nil { + return nil, err + } + var week PeriodSpend + var yesterday *PeriodSpend + for _, r := range rows { + if _, err := time.Parse(time.DateOnly, r.Date); err != nil { + return nil, fmt.Errorf("archive spend row date %q: %w", r.Date, err) + } + if r.Date < from || r.Date > to { + continue + } + week.add(r) + if r.Date == to { + var y PeriodSpend + y.add(r) + yesterday = &y + } + } + if week.Days == 0 { + return nil, nil + } + return &ArchiveSpend{ + Yesterday: yesterday, Week: week, + WeekFrom: from, WeekTo: to, Timezone: timezone, + }, nil +} diff --git a/internal/friction/spend_test.go b/internal/friction/spend_test.go index 75713a83aa..3c74aadfd5 100644 --- a/internal/friction/spend_test.go +++ b/internal/friction/spend_test.go @@ -224,3 +224,107 @@ func TestFormatUSDRenderContract(t *testing.T) { } assert.Equal(t, "$1300.250000", FormatUSD(USDFromMicros(1_300_250_000))) } + +// Ports archive_spend.rs parses_daily_rows_and_summarizes_yesterday_and_week +// (:269-330), minus usage-daily JSON parsing replaced by the native rollup query. +func archiveRows() []DailySpend { + return []DailySpend{ + {Date: "2026-09-14", Total: USDFromMicros(1_500_000), + Agents: map[string]USD{"codex": USDFromMicros(1_500_000)}, + Models: map[string]USD{"gpt-5.6-sol": USDFromMicros(1_500_000)}}, + {Date: "2026-09-15", Total: USDFromMicros(332_138_392), + Agents: map[string]USD{ + "codex": USDFromMicros(224_554_060), "claude": USDFromMicros(104_943_456), + "cowork": USDFromMicros(2_640_876), + }, + Models: map[string]USD{ + "gpt-6-astra": USDFromMicros(125_784_488), "claude-opus-5": USDFromMicros(107_584_332), + "gpt-5.6-sol": USDFromMicros(98_769_572), + }}, + {Date: "2026-09-16", Total: USDFromMicros(999)}, + } +} + +func names(ranked []NamedUSD) []string { + out := make([]string, 0, len(ranked)) + for _, r := range ranked { + out = append(out, r.Name) + } + return out +} + +func TestArchiveWindow(t *testing.T) { + tests := []struct{ date, from, to string }{ + {"2026-09-16", "2026-09-09", "2026-09-15"}, + {"2026-09-18", "2026-09-11", "2026-09-17"}, + {"2026-03-01", "2026-02-22", "2026-02-28"}, + } + for _, tt := range tests { + t.Run(tt.date, func(t *testing.T) { + from, to, err := ArchiveWindow(tt.date) + require.NoError(t, err) + assert.Equal(t, tt.from, from) + assert.Equal(t, tt.to, to) + }) + } + _, _, err := ArchiveWindow("2026-9-16") + require.Error(t, err) +} + +func TestSummarizeArchiveSpend(t *testing.T) { + t.Run("parses_daily_rows_and_summarizes_yesterday_and_week", func(t *testing.T) { + spend, err := SummarizeArchiveSpend(archiveRows(), "2026-09-16", "Asia/Dhaka") + require.NoError(t, err) + require.NotNil(t, spend, "rows in window") + assert.Equal(t, "Asia/Dhaka", spend.Timezone) + assert.Equal(t, "2026-09-09", spend.WeekFrom) + assert.Equal(t, "2026-09-15", spend.WeekTo) + require.NotNil(t, spend.Yesterday) + assert.Equal(t, "332.138392", spend.Yesterday.Total.String()) + assert.Equal(t, 2, spend.Week.Days) + assert.Equal(t, "333.638392", spend.Week.Total.String()) + assert.Equal(t, "226.054060", spend.Week.Agents["codex"].String()) + assert.Equal(t, []string{"codex", "claude", "cowork"}, names(spend.Week.AgentsByCost())) + assert.Equal(t, []string{"gpt-6-astra", "claude-opus-5"}, names(spend.Week.TopModels(2))) + }) + t.Run("yesterday_absent_week_still_summarized", func(t *testing.T) { + spend, err := SummarizeArchiveSpend(archiveRows(), "2026-09-18", "UTC") + require.NoError(t, err) + require.NotNil(t, spend) + assert.Nil(t, spend.Yesterday) + assert.Equal(t, 3, spend.Week.Days) + }) + t.Run("nothing_in_window_is_nil", func(t *testing.T) { + spend, err := SummarizeArchiveSpend(archiveRows(), "2027-01-01", "UTC") + require.NoError(t, err) + assert.Nil(t, spend) + spend, err = SummarizeArchiveSpend(nil, "2026-09-16", "UTC") + require.NoError(t, err) + assert.Nil(t, spend) + }) + t.Run("window_edges", func(t *testing.T) { + edge := func(d string) DailySpend { return DailySpend{Date: d, Total: mustUSD(t, "1")} } + spend, err := SummarizeArchiveSpend([]DailySpend{edge("2026-09-08"), edge("2026-09-09")}, "2026-09-16", "UTC") + require.NoError(t, err) + require.NotNil(t, spend) + assert.Equal(t, 1, spend.Week.Days) + }) + t.Run("top_5_truncation_descending", func(t *testing.T) { + many := DailySpend{Date: "2026-09-15", Total: mustUSD(t, "1"), Models: map[string]USD{}} + for i := range 8 { + many.Models["m"+string(rune('0'+i))] = mustUSD(t, string(rune('0'+i))) + } + spend, err := SummarizeArchiveSpend([]DailySpend{many}, "2026-09-16", "UTC") + require.NoError(t, err) + require.NotNil(t, spend) + assert.Equal(t, []string{"m7", "m6", "m5", "m4", "m3"}, names(spend.Week.TopModels(5))) + }) + t.Run("ties_break_by_name", func(t *testing.T) { + p := PeriodSpend{Agents: map[string]USD{"b": mustUSD(t, "1.00"), "a": mustUSD(t, "1"), "c": mustUSD(t, "2")}} + assert.Equal(t, []string{"c", "a", "b"}, names(p.AgentsByCost())) + }) + t.Run("malformed_row_date_is_an_error", func(t *testing.T) { + _, err := SummarizeArchiveSpend([]DailySpend{{Date: "15/09/2026", Total: mustUSD(t, "1")}}, "2026-09-16", "UTC") + require.Error(t, err) + }) +} From 2376ac35d3df863940ff91b9a1423ddab1dd1fa1 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:44:17 +0000 Subject: [PATCH 30/62] feat(friction): persona rollup with display keys --- internal/friction/personas.go | 112 ++++++++++++++++++++++++++ internal/friction/personas_test.go | 122 +++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 internal/friction/personas.go create mode 100644 internal/friction/personas_test.go diff --git a/internal/friction/personas.go b/internal/friction/personas.go new file mode 100644 index 0000000000..aa6b418706 --- /dev/null +++ b/internal/friction/personas.go @@ -0,0 +1,112 @@ +package friction + +import ( + "fmt" + "sort" +) + +// PersonaKey identifies one fleet persona/channel pair. Channel "" means +// the agent has no registered channel (jilog's None). +type PersonaKey struct{ Persona, Channel string } + +// PersonaCounts is one persona's rollup (jilog PersonaCounts, +// digest.rs:81-121). Tokens always; CostUSD only when a session carried +// a cost (jilog#2hwm). +type PersonaCounts struct { + Sessions, Corrections, Errors, Workarounds, Deferrals, Patterns int + InputTokens, OutputTokens uint64 + CostUSD *USD +} + +// AddSignals adds one session's per-kind counts (digest.rs:447-462). The +// caller increments Sessions once per subject. Frustration and +// interruption signals are not counted (D36 keeps the persona line +// jilog-exact). +func (c *PersonaCounts) AddSignals(sigs []Signal) { + for _, s := range sigs { + switch s.Kind { + case KindCorrection: + c.Corrections++ + case KindError: + c.Errors++ + case KindWorkaround: + c.Workarounds++ + case KindDeferral: + c.Deferrals++ + case KindPattern: + c.Patterns++ + case KindFrustration, KindInterruption: + // Not part of the persona rollup (D36). + } + } +} + +// AddUsage folds one session's usage (fold_session_stats, +// digest.rs:1134-1164, persona half). +func (c *PersonaCounts) AddUsage(u SessionUsage) { + c.InputTokens += u.InputTokens + c.OutputTokens += u.OutputTokens + if u.CostUSD != nil { + c.CostUSD = addPtr(c.CostUSD, *u.CostUSD) + } +} + +func (c PersonaCounts) signalTotal() int { + return c.Corrections + c.Errors + c.Workarounds + c.Deferrals + c.Patterns +} + +func (c PersonaCounts) hasUsage() bool { + return c.InputTokens > 0 || c.OutputTokens > 0 || c.CostUSD != nil +} + +// PersonaDisplayKey is `persona@channel`, or the bare persona without a +// channel, both parts sanitized for a Markdown code span (persona_key, +// digest.rs:123-137). +func PersonaDisplayKey(k PersonaKey) string { + p := SanitizeDisplay(k.Persona) + if k.Channel == "" { + return p + } + return p + "@" + SanitizeDisplay(k.Channel) +} + +// PersonaEntry is one display-keyed rollup row. +type PersonaEntry struct { + Key string + PersonaKey PersonaKey + Counts PersonaCounts +} + +// DisplayKeyedPersonas ports display_keyed_personas (digest.rs:156-177): +// tuples are visited in (persona, channel) order, colliding display keys +// get " (2)", " (3)", … suffixes, and the result is ordered by display +// key bytes like jilog's BTreeMap. +func DisplayKeyedPersonas(m map[PersonaKey]*PersonaCounts) []PersonaEntry { + tuples := make([]PersonaKey, 0, len(m)) + for k := range m { + tuples = append(tuples, k) + } + sort.Slice(tuples, func(i, j int) bool { + if tuples[i].Persona != tuples[j].Persona { + return tuples[i].Persona < tuples[j].Persona + } + return tuples[i].Channel < tuples[j].Channel + }) + used := make(map[string]bool, len(tuples)) + out := make([]PersonaEntry, 0, len(tuples)) + for _, k := range tuples { + base := PersonaDisplayKey(k) + key := base + for n := 2; used[key]; n++ { + key = fmt.Sprintf("%s (%d)", base, n) + } + used[key] = true + var counts PersonaCounts + if c := m[k]; c != nil { + counts = *c + } + out = append(out, PersonaEntry{Key: key, PersonaKey: k, Counts: counts}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/internal/friction/personas_test.go b/internal/friction/personas_test.go new file mode 100644 index 0000000000..e9437a10c0 --- /dev/null +++ b/internal/friction/personas_test.go @@ -0,0 +1,122 @@ +package friction + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersonaDisplayKey(t *testing.T) { + tests := []struct { + name string + key PersonaKey + want string + }{ + {"persona_and_channel", PersonaKey{"helper", "general"}, "helper@general"}, + {"bare_persona_without_channel", PersonaKey{"reviewer", ""}, "reviewer"}, + {"digest_dims_sanitize_hostile_channel_names", PersonaKey{"helper", "general`\ninjected"}, "helper@general' injected"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, PersonaDisplayKey(tt.key)) + }) + } +} + +// Ports display_keyed_personas_disambiguates_colliding_keys +// (digest.rs:1830-1851). +func TestDisplayKeyedPersonas(t *testing.T) { + t.Run("display_keyed_personas_disambiguates_colliding_keys", func(t *testing.T) { + m := map[PersonaKey]*PersonaCounts{ + {"helper", "q&a@hq"}: {Sessions: 1}, + {"helper@q&a", "hq"}: {Sessions: 3}, + } + got := DisplayKeyedPersonas(m) + require.Len(t, got, 2, "colliding display keys must stay separate") + assert.Equal(t, "helper@q&a@hq", got[0].Key) + assert.Equal(t, "helper", got[0].PersonaKey.Persona) + assert.Equal(t, 1, got[0].Counts.Sessions) + assert.Equal(t, "helper@q&a@hq (2)", got[1].Key) + assert.Equal(t, "helper@q&a", got[1].PersonaKey.Persona) + assert.Equal(t, 3, got[1].Counts.Sessions) + }) + t.Run("sorted_bytewise_by_display_key", func(t *testing.T) { + m := map[PersonaKey]*PersonaCounts{ + {"zed", ""}: {Sessions: 1}, + {"Alpha", "b"}: {Sessions: 1}, + {"alpha", ""}: {Sessions: 1}, + {"alpha", "chan"}: {Sessions: 1}, + } + keys := []string{} + for _, e := range DisplayKeyedPersonas(m) { + keys = append(keys, e.Key) + } + assert.Equal(t, []string{"Alpha@b", "alpha", "alpha@chan", "zed"}, keys) + }) + t.Run("third_collision_gets_3", func(t *testing.T) { + m := map[PersonaKey]*PersonaCounts{ + {"a", "b@c"}: {Sessions: 1}, + {"a@b", "c"}: {Sessions: 2}, + {"a@b@c", ""}: {Sessions: 3}, + } + got := DisplayKeyedPersonas(m) + require.Len(t, got, 3) + assert.Equal(t, []string{"a@b@c", "a@b@c (2)", "a@b@c (3)"}, + []string{got[0].Key, got[1].Key, got[2].Key}) + assert.Equal(t, []int{1, 2, 3}, + []int{got[0].Counts.Sessions, got[1].Counts.Sessions, got[2].Counts.Sessions}) + }) + t.Run("nil_counts_are_zero", func(t *testing.T) { + got := DisplayKeyedPersonas(map[PersonaKey]*PersonaCounts{{"x", ""}: nil}) + require.Len(t, got, 1) + assert.Equal(t, PersonaCounts{}, got[0].Counts) + }) +} + +// Fold-level halves of personas_rollup_carries_tokens_and_optional_cost +// (digest.rs:1986-2060) and stats_only_session_counts_toward_spend_and_personas +// (:2206-2238). PR 5 ports the run_review halves. +func TestPersonaCountsFold(t *testing.T) { + t.Run("personas_rollup_carries_tokens_and_optional_cost", func(t *testing.T) { + cell := &PersonaCounts{Sessions: 1} + cell.AddSignals([]Signal{{Kind: KindCorrection}}) + cell.AddUsage(SessionUsage{SubjectID: "sess-cell", InputTokens: 12000, OutputTokens: 300}) + assert.Equal(t, uint64(12000), cell.InputTokens) + assert.Equal(t, uint64(300), cell.OutputTokens) + assert.Nil(t, cell.CostUSD, "cell sessions must stay tokens-only") + + priced := &PersonaCounts{Sessions: 1} + priced.AddSignals(nil) + priced.AddUsage(SessionUsage{SubjectID: "sess-priced", InputTokens: 800, OutputTokens: 40, CostUSD: usdPtr(mustUSD(t, "1.5"))}) + require.NotNil(t, priced.CostUSD) + assert.Equal(t, "1.5", priced.CostUSD.String()) + assert.Equal(t, 1, priced.Sessions) + }) + t.Run("stats_only_session_counts_toward_spend_and_personas", func(t *testing.T) { + c := &PersonaCounts{} + c.Sessions++ // the stats-only path counts the session itself + c.AddUsage(SessionUsage{SubjectID: "s", InputTokens: 5, OutputTokens: 1}) + assert.Equal(t, 1, c.Sessions) + assert.Equal(t, 0, c.signalTotal()) + assert.True(t, c.hasUsage()) + }) + t.Run("add_signals_counts_each_kind", func(t *testing.T) { + c := &PersonaCounts{} + c.AddSignals([]Signal{ + {Kind: KindCorrection}, {Kind: KindCorrection}, {Kind: KindError}, + {Kind: KindWorkaround}, {Kind: KindDeferral}, {Kind: KindPattern}, + // D36: persona counts stay jilog's five kinds. + {Kind: KindFrustration}, {Kind: KindInterruption}, + }) + assert.Equal(t, PersonaCounts{Corrections: 2, Errors: 1, Workarounds: 1, Deferrals: 1, Patterns: 1}, *c) + assert.Equal(t, 6, c.signalTotal()) + }) + t.Run("cost_sums_across_sessions", func(t *testing.T) { + c := &PersonaCounts{} + c.AddUsage(SessionUsage{CostUSD: usdPtr(mustUSD(t, "0.25"))}) + c.AddUsage(SessionUsage{CostUSD: usdPtr(mustUSD(t, "0.25"))}) + require.NotNil(t, c.CostUSD) + assert.Equal(t, "0.50", c.CostUSD.String()) + }) +} From 81da5a381143e8f7dcd9285195cc76511dd25e1b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:52:51 +0000 Subject: [PATCH 31/62] feat(friction): render the friction log digest markdown --- internal/friction/render_md.go | 317 +++++++++++++++++++++ internal/friction/render_md_test.go | 422 ++++++++++++++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 internal/friction/render_md.go create mode 100644 internal/friction/render_md_test.go diff --git a/internal/friction/render_md.go b/internal/friction/render_md.go new file mode 100644 index 0000000000..d97abc4db0 --- /dev/null +++ b/internal/friction/render_md.go @@ -0,0 +1,317 @@ +package friction + +import ( + "bytes" + "fmt" + "slices" + "sort" + "strings" +) + +// IssueRef is a Kata issue a digest line links to. ID is "#", +// Backend "kata"; URL may be empty. +type IssueRef struct{ ID, Backend, URL, Title string } + +// DigestSnapshot is everything a digest renders, frozen at build time +// (spec §5.4 snapshot_json). Signals keep detector run order; the +// renderer groups them by kind without sorting (digest.rs:540). +type DigestSnapshot struct { + Date, Timezone, RulesVersion string + Signals []Signal + P0Alerts map[string][]string + Personas map[PersonaKey]*PersonaCounts + Spend *SpendSummary + ArchiveSpend *ArchiveSpend + SessionsScanned int + RecurrenceCosts map[string]USD // by fingerprint +} + +// RenderLinks carries the current Kata linkage; it changes after the +// build, so it is kept out of the snapshot (spec §8.6). +type RenderLinks struct { + IssueIndex map[string]IssueRef // by fingerprint +} + +// RenderMarkdown ports render_digest (digest.rs:723-1031) byte for byte, +// except the heading (D8). +func RenderMarkdown(s DigestSnapshot, l RenderLinks) []byte { + var corrections, errs, workarounds, deferrals, frustrations, interruptions, patterns []Signal + for _, sig := range s.Signals { + switch sig.Kind { + case KindCorrection: + corrections = append(corrections, sig) + case KindError: + errs = append(errs, sig) + case KindWorkaround: + workarounds = append(workarounds, sig) + case KindDeferral: + deferrals = append(deferrals, sig) + case KindFrustration: + frustrations = append(frustrations, sig) + case KindInterruption: + interruptions = append(interruptions, sig) + case KindPattern: + patterns = append(patterns, sig) + } + } + total := len(corrections) + len(errs) + len(workarounds) + len(deferrals) + + len(frustrations) + len(interruptions) + len(patterns) + + var b bytes.Buffer + b.WriteString("---\n") + fmt.Fprintf(&b, "date: %s\n", s.Date) + fmt.Fprintf(&b, "signals_captured: %d\n", total) + fmt.Fprintf(&b, "p0_count: %d\n", len(s.P0Alerts)) + fmt.Fprintf(&b, "corrections: %d\n", len(corrections)) + fmt.Fprintf(&b, "errors: %d\n", len(errs)) + fmt.Fprintf(&b, "workarounds: %d\n", len(workarounds)) + fmt.Fprintf(&b, "deferrals: %d\n", len(deferrals)) + fmt.Fprintf(&b, "patterns: %d\n", len(patterns)) + // D36: always present, after jilog's keys. + fmt.Fprintf(&b, "frustrations: %d\n", len(frustrations)) + fmt.Fprintf(&b, "interruptions: %d\n", len(interruptions)) + b.WriteString("---\n\n") + fmt.Fprintf(&b, "# Friction Log — %s\n\n", s.Date) + + b.WriteString("## P0 Alerts\n\n") + if len(s.P0Alerts) == 0 { + b.WriteString("_No P0 alerts._\n\n") + } else { + for _, tool := range sortedKeys(s.P0Alerts) { + sessions := sortedUniqueSessions(s.P0Alerts[tool]) + fmt.Fprintf(&b, "- **P0 ALERT**: `%s` failed in %d distinct sessions: %s\n", + tool, len(sessions), strings.Join(sessions, ", ")) + } + b.WriteByte('\n') + } + + section(&b, "Corrections", "_No corrections detected._", corrections, func(sig Signal) string { + return fmt.Sprintf("- %s`%s` — %s%s\n", + dimsPrefix(sig.Dims), sig.SubjectID, PythonRepr(sig.Text), lineAnnotations(sig, s, l)) + }) + section(&b, "Errors", "_No errors detected._", errs, func(sig Signal) string { + return fmt.Sprintf("- %s`%s` / `%s`: %s%s\n", + dimsPrefix(sig.Dims), sig.SubjectID, sig.ToolName, + TruncateWithMarker(sig.Text, MaxErrorMessageLength), lineAnnotations(sig, s, l)) + }) + section(&b, "Workarounds", "_No workarounds detected._", workarounds, func(sig Signal) string { + return fmt.Sprintf("- %s`%s` pattern=`%s`: %s%s\n", + dimsPrefix(sig.Dims), sig.SubjectID, sig.Label, PythonRepr(sig.Text), lineAnnotations(sig, s, l)) + }) + section(&b, "Deferrals", "_No deferrals detected._", deferrals, func(sig Signal) string { + return fmt.Sprintf("- %s`%s` pattern=`%s`\n", dimsPrefix(sig.Dims), sig.SubjectID, sig.Label) + }) + section(&b, "Patterns", "_No patterns detected._", patterns, func(sig Signal) string { + return fmt.Sprintf("- %s`%s` kind=`%s`: %s%s\n", + dimsPrefix(sig.Dims), sig.SubjectID, sig.Label, sig.Evidence, lineAnnotations(sig, s, l)) + }) + // D36 sections: always rendered, after Patterns and before Personas. + section(&b, "Frustration", "_No frustration detected._", frustrations, func(sig Signal) string { + return fmt.Sprintf("- %s`%s` — %s%s\n", + dimsPrefix(sig.Dims), sig.SubjectID, PythonRepr(sig.Text), lineAnnotations(sig, s, l)) + }) + b.WriteString("## Interruptions\n\n") + if groups := groupInterruptions(interruptions); len(groups) == 0 { + b.WriteString("_No interruptions detected._\n\n") + } else { + for _, g := range groups { + fmt.Fprintf(&b, "- %s`%s` interruptions=%d\n", dimsPrefix(g.first.Dims), g.first.SubjectID, g.n) + } + b.WriteByte('\n') + } + + renderPersonas(&b, s.Personas) + + if s.Spend != nil || s.ArchiveSpend != nil { + b.WriteString("## Spend\n\n") + } + if sp := s.Spend; sp != nil { + if sp.Total != nil { + fmt.Fprintf(&b, "- **Total**: %s across %d of %d session(s) with usage data\n", + FormatUSD(*sp.Total), sp.SessionsWithCost, sp.SessionsWithStats) + } else { + fmt.Fprintf(&b, "- **Total**: no cost data (%d session(s) with usage; unpriced models)\n", + sp.SessionsWithStats) + } + fmt.Fprintf(&b, "- **Tokens**: %d in / %d out\n", sp.InputTokens, sp.OutputTokens) + b.WriteByte('\n') + costTable(&b, "### Spend by role\n\n", sp.RoleCosts) + costTable(&b, "### Spend by model\n\n", sp.ModelCosts) + } + if a := s.ArchiveSpend; a != nil { + renderArchiveSpend(&b, a) + } + return b.Bytes() +} + +func section(b *bytes.Buffer, title, placeholder string, sigs []Signal, line func(Signal) string) { + b.WriteString("## " + title + "\n\n") + if len(sigs) == 0 { + b.WriteString(placeholder + "\n\n") + return + } + for _, sig := range sigs { + b.WriteString(line(sig)) + } + b.WriteByte('\n') +} + +type interruptionGroup struct { + first Signal // dims and subject come from the session's first signal + n int +} + +// groupInterruptions folds interruption signals into one entry per +// subject, in order of first appearance (run order). +func groupInterruptions(sigs []Signal) []interruptionGroup { + var groups []interruptionGroup + index := map[string]int{} + for _, sig := range sigs { + if i, ok := index[sig.SubjectID]; ok { + groups[i].n++ + continue + } + index[sig.SubjectID] = len(groups) + groups = append(groups, interruptionGroup{first: sig, n: 1}) + } + return groups +} + +func costTable(b *bytes.Buffer, header string, costs map[string]USD) { + if len(costs) == 0 { + return + } + b.WriteString(header) + for _, k := range sortedKeys(costs) { + fmt.Fprintf(b, "- `%s`: %s\n", k, FormatUSD(costs[k])) + } + b.WriteByte('\n') +} + +func renderPersonas(b *bytes.Buffer, personas map[PersonaKey]*PersonaCounts) { + if len(personas) == 0 { + return + } + b.WriteString("## Personas\n\n") + for _, e := range DisplayKeyedPersonas(personas) { + c := e.Counts + usage := "" + if c.hasUsage() { + usage = fmt.Sprintf(" — %d in / %d out tokens", c.InputTokens, c.OutputTokens) + if c.CostUSD != nil { + usage += ", " + FormatUSD(*c.CostUSD) + } + } + if c.signalTotal() == 0 { + fmt.Fprintf(b, "- `%s`: no signals (%d session(s))%s\n", e.Key, c.Sessions, usage) + continue + } + fmt.Fprintf(b, "- `%s`: %d corrections, %d errors, %d workarounds, %d deferrals, %d patterns (%d session(s))%s\n", + e.Key, c.Corrections, c.Errors, c.Workarounds, c.Deferrals, c.Patterns, c.Sessions, usage) + } + b.WriteByte('\n') +} + +// renderArchiveSpend ports render_archive_spend (digest.rs:975-1024). +func renderArchiveSpend(b *bytes.Buffer, a *ArchiveSpend) { + agents := func(p PeriodSpend) string { + ranked := p.AgentsByCost() + if len(ranked) == 0 { + return "" + } + parts := make([]string, 0, len(ranked)) + for _, r := range ranked { + parts = append(parts, SanitizeDisplay(r.Name)+" "+FormatUSD(r.USD)) + } + return " — " + strings.Join(parts, ", ") + } + b.WriteString("### Archive spend (agentsview)\n\n") + if y := a.Yesterday; y != nil { + fmt.Fprintf(b, "- **Yesterday (%s)**: %s%s\n", a.WeekTo, FormatUSD(y.Total), agents(*y)) + } else { + fmt.Fprintf(b, "- **Yesterday (%s)**: no archive rows\n", a.WeekTo) + } + fmt.Fprintf(b, "- **Trailing 7d (%s – %s, days in %s)**: %s across %d day(s)%s\n", + a.WeekFrom, a.WeekTo, SanitizeDisplay(a.Timezone), + FormatUSD(a.Week.Total), a.Week.Days, agents(a.Week)) + models := a.Week.TopModels(5) + if len(models) > 0 { + parts := make([]string, 0, len(models)) + for _, m := range models { + parts = append(parts, "`"+SanitizeDisplay(m.Name)+"` "+FormatUSD(m.USD)) + } + fmt.Fprintf(b, "- **Top models (7d)**: %s\n", strings.Join(parts, ", ")) + } + b.WriteByte('\n') +} + +// dimsPrefix ports dims_prefix (digest.rs:1064-1085). An empty field is +// jilog's None. +func dimsPrefix(d Dims) string { + var b strings.Builder + if d.Persona != "" { + b.WriteString("`" + PersonaDisplayKey(PersonaKey{Persona: d.Persona, Channel: d.Channel}) + "` ") + } + if d.Seat != "" { + b.WriteString("`seat:" + SanitizeDisplay(d.Seat) + "` ") + } + if d.Agent != "" { + b.WriteString("`agent:" + SanitizeDisplay(d.Agent) + "` ") + } + if d.Machine != "" { + b.WriteString("`machine:" + SanitizeDisplay(d.Machine) + "` ") + } + return b.String() +} + +// lineAnnotations ports line_annotations/issue_annotation +// (digest.rs:1087-1117), keyed by fingerprint. +func lineAnnotations(sig Signal, s DigestSnapshot, l RenderLinks) string { + if len(l.IssueIndex) == 0 && len(s.RecurrenceCosts) == 0 { + return "" + } + fp := sig.Fingerprint() + out := "" + if ref, ok := l.IssueIndex[fp]; ok { + out = " (→ " + ref.Backend + "#" + strings.TrimLeft(ref.ID, "#") + ")" + } + if c, ok := s.RecurrenceCosts[fp]; ok { + out += " (recurred in sessions totaling " + FormatUSD(c) + ")" + } + return out +} + +func sortedUniqueSessions(ids []string) []string { + out := append([]string(nil), ids...) + sort.Strings(out) + return slices.Compact(out) +} + +// kindCounts holds per-kind signal counts. +type kindCounts struct { + Corrections, Errors, Workarounds, Deferrals, Patterns int + Frustrations, Interruptions int // D36 +} + +func countKinds(sigs []Signal) kindCounts { + var k kindCounts + for _, s := range sigs { + switch s.Kind { + case KindCorrection: + k.Corrections++ + case KindError: + k.Errors++ + case KindWorkaround: + k.Workarounds++ + case KindDeferral: + k.Deferrals++ + case KindPattern: + k.Patterns++ + case KindFrustration: + k.Frustrations++ + case KindInterruption: + k.Interruptions++ + } + } + return k +} diff --git a/internal/friction/render_md_test.go b/internal/friction/render_md_test.go new file mode 100644 index 0000000000..549a3df37d --- /dev/null +++ b/internal/friction/render_md_test.go @@ -0,0 +1,422 @@ +package friction + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func render(s DigestSnapshot, l RenderLinks) string { return string(RenderMarkdown(s, l)) } + +func snap(date string, sigs ...Signal) DigestSnapshot { + return DigestSnapshot{Date: date, Signals: sigs} +} + +func lineWith(t *testing.T, body, needle string) string { + t.Helper() + for _, l := range strings.Split(body, "\n") { + if strings.Contains(l, needle) { + return l + } + } + require.FailNowf(t, "line not found", "%q in:\n%s", needle, body) + return "" +} + +// Ports every render test in digest.rs:1266-1376 and :1790-1985 that +// calls render_digest directly. +func TestRenderMarkdown(t *testing.T) { + t.Run("digest_frontmatter_has_counts", func(t *testing.T) { + body := render(snap("2026-04-30", Signal{Kind: KindCorrection, SubjectID: "a", Text: "fix it"}), RenderLinks{}) + assert.True(t, strings.HasPrefix(body, "---\n")) + for _, want := range []string{"date: 2026-04-30", "signals_captured: 1", "corrections: 1", "errors: 0", "deferrals: 0", "frustrations: 0", "interruptions: 0"} { + assert.Contains(t, body, want) + } + }) + t.Run("digest_empty_sections_use_placeholder", func(t *testing.T) { + body := render(snap("2026-04-30"), RenderLinks{}) + for _, want := range []string{ + "_No P0 alerts._", "_No corrections detected._", "_No errors detected._", + "_No workarounds detected._", "_No deferrals detected._", "_No patterns detected._", + "_No frustration detected._", "_No interruptions detected._", + } { + assert.Contains(t, body, want) + } + }) + t.Run("heading_is_friction_log", func(t *testing.T) { + body := render(snap("2026-04-30"), RenderLinks{}) + assert.Contains(t, body, "---\n\n# Friction Log — 2026-04-30\n\n## P0 Alerts\n\n") + assert.NotContains(t, body, "Learning Digest") + }) + t.Run("digest_p0_includes_session_list", func(t *testing.T) { + s := snap("2026-04-30") + s.P0Alerts = map[string][]string{"bash": {"aaa", "bbb", "ccc"}} + body := render(s, RenderLinks{}) + assert.Contains(t, body, "`bash` failed in 3 distinct sessions") + assert.Contains(t, body, "aaa, bbb, ccc") + assert.Contains(t, body, "p0_count: 1\n") + }) + t.Run("digest_corrections_use_python_repr", func(t *testing.T) { + body := render(snap("2026-04-30", Signal{Kind: KindCorrection, SubjectID: "abc", Text: "don't do that"}), RenderLinks{}) + assert.Contains(t, body, `'don\'t do that'`) + }) + t.Run("digest_errors_truncated_with_marker", func(t *testing.T) { + body := render(snap("2026-04-30", Signal{Kind: KindError, SubjectID: "s1", ToolName: "bash", Text: strings.Repeat("x", 600)}), RenderLinks{}) + assert.Contains(t, body, "[truncated]") + line := lineWith(t, body, "`s1` / `bash`: ") + assert.Equal(t, "- `s1` / `bash`: "+strings.Repeat("x", 500)+" … [truncated]", line) + }) + t.Run("errors_truncate_runes_not_bytes_and_skip_repr", func(t *testing.T) { + msg := strings.Repeat("é", 600) + body := render(snap("2026-04-30", Signal{Kind: KindError, SubjectID: "s1", ToolName: "bash", Text: msg}), RenderLinks{}) + line := lineWith(t, body, "`s1` / `bash`: ") + assert.Equal(t, "- `s1` / `bash`: "+strings.Repeat("é", 500)+" … [truncated]", line) + }) + t.Run("digest_patterns_render_kind_and_evidence", func(t *testing.T) { + body := render(snap("2026-07-05", Signal{ + Kind: KindPattern, SubjectID: "s1", Label: "compaction_storm", + Text: "compaction storm: 4 compactions within 10 minutes", + Evidence: "4 compactions 09:01-09:08", + }), RenderLinks{}) + assert.Contains(t, body, "signals_captured: 1") + assert.Contains(t, body, "patterns: 1") + assert.Contains(t, body, "## Patterns") + assert.Contains(t, body, "- `s1` kind=`compaction_storm`: 4 compactions 09:01-09:08") + }) + t.Run("digest_patterns_annotated_when_issue_filed", func(t *testing.T) { + p := Signal{ + Kind: KindPattern, SubjectID: "s1", Label: "stuck_loop", + Text: "stuck loop: `bash` called 5 times with identical arguments", + Evidence: "`bash` x5 identical arguments 09:00-09:04", + } + idx := map[string]IssueRef{p.Fingerprint(): {ID: "#9", Backend: "kata", Title: p.Title()}} + body := render(snap("2026-07-05", p), RenderLinks{IssueIndex: idx}) + assert.Contains(t, body, "(→ kata#9)") + }) + t.Run("digest_deferrals_render_item", func(t *testing.T) { + body := render(snap("2026-04-30", Signal{Kind: KindDeferral, SubjectID: "s1", Label: "next session"}), RenderLinks{}) + assert.Contains(t, body, "signals_captured: 1") + assert.Contains(t, body, "- `s1` pattern=`next session`") + }) + t.Run("workaround_line_format", func(t *testing.T) { + body := render(snap("2026-04-30", Signal{Kind: KindWorkaround, SubjectID: "s1", Label: "for now", Text: "Using it for now\nthen"}), RenderLinks{}) + assert.Contains(t, body, "- `s1` pattern=`for now`: 'Using it for now\\nthen'\n") + }) + t.Run("digest_spend_section_renders_totals_roles_models", func(t *testing.T) { + s := snap("2026-07-05") + s.Spend = &SpendSummary{ + Total: usdPtr(mustUSD(t, "4.2")), + SessionsWithStats: 3, SessionsWithCost: 2, + InputTokens: 12345, OutputTokens: 678, + RoleCosts: map[string]USD{"(root)": mustUSD(t, "3.1"), "explore": mustUSD(t, "1.1")}, + ModelCosts: map[string]USD{"claude-opus-4-8": mustUSD(t, "4.2")}, + } + body := render(s, RenderLinks{}) + for _, want := range []string{ + "## Spend", + "- **Total**: $4.20 across 2 of 3 session(s) with usage data", + "- **Tokens**: 12345 in / 678 out", + "### Spend by role", + "- `(root)`: $3.10", + "- `explore`: $1.10", + "### Spend by model", + "- `claude-opus-4-8`: $4.20", + } { + assert.Contains(t, body, want) + } + }) + t.Run("digest_spend_section_absent_without_stats", func(t *testing.T) { + assert.NotContains(t, render(snap("2026-07-05"), RenderLinks{}), "## Spend") + }) + t.Run("digest_spend_all_unpriced_says_no_cost_data", func(t *testing.T) { + s := snap("2026-07-05") + s.Spend = &SpendSummary{SessionsWithStats: 2, InputTokens: 10, OutputTokens: 1} + body := render(s, RenderLinks{}) + assert.Contains(t, body, "- **Total**: no cost data (2 session(s) with usage; unpriced models)") + assert.NotContains(t, body, "### Spend by role", "no costs → no role table") + }) + t.Run("digest_dims_sanitize_hostile_channel_names", func(t *testing.T) { + c := Signal{ + Kind: KindCorrection, SubjectID: "s1", Text: "no, wrong channel entirely", + Dims: Dims{Persona: "helper", Channel: "general`\ninjected"}, + } + s := snap("2026-07-13", c) + s.Personas = map[PersonaKey]*PersonaCounts{{"helper", "general`\ninjected"}: {Sessions: 1, Corrections: 1}} + body := render(s, RenderLinks{}) + assert.Contains(t, body, "- `helper@general' injected` `s1` — ") + assert.Contains(t, body, "- `helper@general' injected`: 1 corrections") + assert.NotContains(t, body, "general`", "raw backtick leaked into digest") + }) + t.Run("digest_personas_section_absent_for_coding_only_runs", func(t *testing.T) { + assert.NotContains(t, render(snap("2026-07-13"), RenderLinks{}), "## Personas") + }) + t.Run("personas_rollup_carries_tokens_and_optional_cost", func(t *testing.T) { + s := snap("2026-08-26") + s.Personas = map[PersonaKey]*PersonaCounts{ + {"helper", "ops"}: {Sessions: 1, Corrections: 1, InputTokens: 12000, OutputTokens: 300}, + {"reviewer", ""}: {Sessions: 1, InputTokens: 800, OutputTokens: 40, CostUSD: usdPtr(mustUSD(t, "1.5"))}, + {"quiet", "lounge"}: {Sessions: 2}, + } + body := render(s, RenderLinks{}) + assert.Contains(t, body, "## Personas\n\n"+ + "- `helper@ops`: 1 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns (1 session(s)) — 12000 in / 300 out tokens\n"+ + "- `quiet@lounge`: no signals (2 session(s))\n"+ + "- `reviewer`: no signals (1 session(s)) — 800 in / 40 out tokens, $1.50\n\n") + }) + t.Run("digest_annotation_appended_to_correction_bullet", func(t *testing.T) { + c := Signal{Kind: KindCorrection, SubjectID: "0e91a2b4", Text: "no, use the gh cli for calendar"} + idx := map[string]IssueRef{c.Fingerprint(): {ID: "#7", Backend: "kata", Title: c.Title()}} + body := render(snap("2026-05-11", c), RenderLinks{IssueIndex: idx}) + assert.Contains(t, body, "(→ kata#7)") + assert.NotContains(t, body, "kata##") + }) + t.Run("digest_no_annotation_when_issue_index_empty", func(t *testing.T) { + body := render(snap("2026-05-11", Signal{Kind: KindCorrection, SubjectID: "abc", Text: "fix it"}), RenderLinks{}) + assert.NotContains(t, body, "(→") + }) + t.Run("digest_annotation_byte_stable_for_unaffected_lines", func(t *testing.T) { + ann := Signal{Kind: KindCorrection, SubjectID: "ann", Text: "do this"} + plain := Signal{Kind: KindCorrection, SubjectID: "pla", Text: "plain line"} + idx := map[string]IssueRef{ann.Fingerprint(): {ID: "#3", Backend: "kata", Title: ann.Title()}} + with := render(snap("2026-05-11", ann, plain), RenderLinks{IssueIndex: idx}) + without := render(snap("2026-05-11", ann, plain), RenderLinks{}) + assert.Equal(t, lineWith(t, without, "plain line"), lineWith(t, with, "plain line"), "unaffected line changed") + assert.NotEqual(t, lineWith(t, without, "do this"), lineWith(t, with, "do this")) + assert.True(t, strings.HasSuffix(lineWith(t, with, "do this"), "(→ kata#3)")) + }) + t.Run("both_annotations_in_order", func(t *testing.T) { + c := Signal{Kind: KindCorrection, SubjectID: "s", Text: "no, other branch"} + s := snap("2026-05-11", c) + s.RecurrenceCosts = map[string]USD{c.Fingerprint(): mustUSD(t, "4.2")} + idx := map[string]IssueRef{c.Fingerprint(): {ID: "7", Backend: "kata"}} + body := render(s, RenderLinks{IssueIndex: idx}) + assert.Contains(t, body, "- `s` — 'no, other branch' (→ kata#7) (recurred in sessions totaling $4.20)\n") + }) + t.Run("seat_prefix_preserves_fleet_dimensions_and_sanitizes", func(t *testing.T) { + assert.Equal(t, "`bot@chat` `seat:seat' 01` ", + dimsPrefix(Dims{Persona: "bot", Channel: "chat", Seat: "seat`\t\n01"})) + assert.Equal(t, "`agent:cowork` `machine:mac'1 ` ", + dimsPrefix(Dims{Agent: "cowork", Machine: "mac`1\n"})) + assert.Empty(t, dimsPrefix(Dims{Channel: "orphan"}), "channel renders only with a persona") + }) + t.Run("agent_and_machine_tags_are_stamped_and_rendered", func(t *testing.T) { + c := Signal{ + Kind: KindCorrection, SubjectID: "a37ffc87-2799-4a09-830b-a92fde71d768", + Text: "no, use the other path", Dims: Dims{Agent: "claude", Machine: "mac-01"}, + } + body := render(snap("2026-09-16", c), RenderLinks{}) + assert.Contains(t, body, "- `agent:claude` `machine:mac-01` `a37ffc87-2799-4a09-830b-a92fde71d768` — 'no, use the other path'") + }) + t.Run("digest_without_archive_spend_is_unchanged", func(t *testing.T) { + body := render(snap("2026-09-16"), RenderLinks{}) + assert.NotContains(t, body, "## Spend") + // D36 moves jilog's end-of-file rule to the Interruptions placeholder. + assert.True(t, strings.HasSuffix(body, "## Interruptions\n\n_No interruptions detected._\n\n")) + }) +} + +// sampleArchiveSpend ports digest.rs sample_archive_spend (:2506-2535), +// with the zone scrubbed to Asia/Dhaka. +func sampleArchiveSpend(t *testing.T) *ArchiveSpend { + t.Helper() + d := func(s string) USD { return mustUSD(t, s) } + week := PeriodSpend{ + Total: d("2101.5"), Days: 7, + Agents: map[string]USD{"codex": d("1300.25"), "claude": d("800.25"), "cowork": d("1")}, + Models: map[string]USD{ + "gpt-6-astra": d("900"), "claude-opus-5": d("700.5"), "gpt-5.6-sol": d("400"), + "claude-haiku-4-5-20251001": d("60"), "m5": d("30"), "m6": d("11"), + }, + } + yesterday := PeriodSpend{ + Total: d("332.138392"), Days: 1, + Agents: map[string]USD{"codex": d("224.55406"), "claude": d("104.943456"), "cowork": d("2.640876")}, + } + return &ArchiveSpend{ + Yesterday: &yesterday, Week: week, + WeekFrom: "2026-09-09", WeekTo: "2026-09-15", Timezone: "Asia/Dhaka", + } +} + +func TestRenderMarkdownArchiveSpend(t *testing.T) { + t.Run("digest_archive_spend_block_renders_after_observed_spend", func(t *testing.T) { + s := snap("2026-09-16") + s.ArchiveSpend = sampleArchiveSpend(t) + expected := "## Spend\n\n### Archive spend (agentsview)\n\n" + + "- **Yesterday (2026-09-15)**: $332.138392 — codex $224.55406, claude $104.943456, cowork $2.640876\n" + + "- **Trailing 7d (2026-09-09 – 2026-09-15, days in Asia/Dhaka)**: $2101.50 across 7 day(s) — codex $1300.25, claude $800.25, cowork $1.00\n" + + "- **Top models (7d)**: `gpt-6-astra` $900.00, `claude-opus-5` $700.50, `gpt-5.6-sol` $400.00, `claude-haiku-4-5-20251001` $60.00, `m5` $30.00\n\n" + assert.True(t, strings.HasSuffix(render(s, RenderLinks{}), expected), render(s, RenderLinks{})) + }) + t.Run("observed_block_comes_first", func(t *testing.T) { + s := snap("2026-09-16") + s.ArchiveSpend = sampleArchiveSpend(t) + s.Spend = &SpendSummary{SessionsWithStats: 2, InputTokens: 10, OutputTokens: 5} + assert.Contains(t, render(s, RenderLinks{}), + "## Spend\n\n- **Total**: no cost data (2 session(s) with usage; unpriced models)\n- **Tokens**: 10 in / 5 out\n\n### Archive spend (agentsview)\n") + }) + t.Run("yesterday_absent_and_no_agent_rows", func(t *testing.T) { + s := snap("2026-09-16") + a := sampleArchiveSpend(t) + a.Yesterday = nil + a.Week.Agents = nil + s.ArchiveSpend = a + body := render(s, RenderLinks{}) + assert.Contains(t, body, "- **Yesterday (2026-09-15)**: no archive rows\n") + assert.Contains(t, body, "- **Trailing 7d (2026-09-09 – 2026-09-15, days in Asia/Dhaka)**: $2101.50 across 7 day(s)\n") + }) + t.Run("no_models_omits_top_models_line", func(t *testing.T) { + s := snap("2026-09-16") + a := sampleArchiveSpend(t) + a.Week.Models = nil + s.ArchiveSpend = a + assert.NotContains(t, render(s, RenderLinks{}), "Top models") + }) +} + +// Review Focus 1: archive agent, model and zone names are sanitized. +func TestRenderMarkdownArchiveNamesSanitized(t *testing.T) { + s := snap("2026-09-16") + s.ArchiveSpend = &ArchiveSpend{ + Week: PeriodSpend{ + Total: mustUSD(t, "1"), Days: 1, + Agents: map[string]USD{"ag`\nent": mustUSD(t, "1")}, + Models: map[string]USD{"mo`del\n## Injected": mustUSD(t, "1")}, + }, + WeekFrom: "2026-09-09", WeekTo: "2026-09-15", Timezone: "Bad`\nZone", + } + body := render(s, RenderLinks{}) + assert.Contains(t, body, "days in Bad' Zone)") + assert.Contains(t, body, " — ag' ent $1.00\n") + assert.Contains(t, body, "`mo'del ## Injected` $1.00") + assert.NotContains(t, body, "\n## Injected") +} + +// Review Focus 2: Go map order never leaks into the bytes. +func TestRenderIsDeterministic(t *testing.T) { + s := snap("2026-09-16") + s.P0Alerts = map[string][]string{"zsh": {"c", "a", "b"}, "bash": {"b", "a", "c"}, "mode": {"x", "y", "z"}} + s.Personas = map[PersonaKey]*PersonaCounts{} + for _, p := range []string{"e", "d", "c", "b", "a"} { + s.Personas[PersonaKey{p, "ch"}] = &PersonaCounts{Sessions: 1} + } + s.Spend = &SpendSummary{ + Total: usdPtr(mustUSD(t, "5")), SessionsWithStats: 5, SessionsWithCost: 5, + RoleCosts: map[string]USD{"(root)": mustUSD(t, "1"), "subagent": mustUSD(t, "2"), "b": mustUSD(t, "2")}, + ModelCosts: map[string]USD{"m1": mustUSD(t, "1"), "m2": mustUSD(t, "1"), "m3": mustUSD(t, "3")}, + } + s.ArchiveSpend = sampleArchiveSpend(t) + first := RenderMarkdown(s, RenderLinks{}) + for range 50 { + require.Equal(t, string(first), string(RenderMarkdown(s, RenderLinks{}))) + } + assert.Contains(t, string(first), "- **P0 ALERT**: `bash` failed in 3 distinct sessions: a, b, c\n- **P0 ALERT**: `mode`") +} + +// Review Focus 3: unsorted and duplicated P0 session ids. +func TestRenderMarkdownP0SortsAndDedupes(t *testing.T) { + s := snap("2026-09-16") + input := []string{"s3", "s1", "s1"} + s.P0Alerts = map[string][]string{"bash": input} + body := render(s, RenderLinks{}) + assert.Contains(t, body, "- **P0 ALERT**: `bash` failed in 2 distinct sessions: s1, s3\n") + assert.Equal(t, []string{"s3", "s1", "s1"}, input, "renderer must not mutate the snapshot") +} + +// D36 (spec §6.8, §9.1): frustration and interruption kinds. jilog has +// no test for these; this pins their bytes. The file-level golden is +// friction-log-extra-kinds.md (Task 7). +func TestRenderMarkdownFrustrationAndInterruptions(t *testing.T) { + f := Signal{Kind: KindFrustration, SubjectID: "s1", Text: "this is broken, same error again!!!", Dims: Dims{Agent: "claude"}} + c := Signal{Kind: KindCorrection, SubjectID: "s3", Text: "no, use the other branch"} + p := Signal{Kind: KindPattern, SubjectID: "s4", Label: "retry_loop", Evidence: "`bash` x3 identical arguments 01:00-01:02"} + intr := func(subject string, d Dims) Signal { + return Signal{Kind: KindInterruption, SubjectID: subject, Dims: d} + } + + t.Run("sections_and_frontmatter_bytes", func(t *testing.T) { + s := snap("2026-09-16", c, p, f, + intr("s2", Dims{}), intr("s5", Dims{Seat: "seat-02"}), intr("s2", Dims{})) + s.RecurrenceCosts = map[string]USD{f.Fingerprint(): mustUSD(t, "1.5")} + idx := map[string]IssueRef{f.Fingerprint(): {ID: "#11", Backend: "kata"}} + want := "---\n" + + "date: 2026-09-16\n" + + "signals_captured: 6\n" + + "p0_count: 0\n" + + "corrections: 1\n" + + "errors: 0\n" + + "workarounds: 0\n" + + "deferrals: 0\n" + + "patterns: 1\n" + + "frustrations: 1\n" + + "interruptions: 3\n" + + "---\n\n" + + "# Friction Log — 2026-09-16\n\n" + + "## P0 Alerts\n\n_No P0 alerts._\n\n" + + "## Corrections\n\n- `s3` — 'no, use the other branch'\n\n" + + "## Errors\n\n_No errors detected._\n\n" + + "## Workarounds\n\n_No workarounds detected._\n\n" + + "## Deferrals\n\n_No deferrals detected._\n\n" + + "## Patterns\n\n- `s4` kind=`retry_loop`: `bash` x3 identical arguments 01:00-01:02\n\n" + + "## Frustration\n\n- `agent:claude` `s1` — 'this is broken, same error again!!!' (→ kata#11) (recurred in sessions totaling $1.50)\n\n" + + "## Interruptions\n\n- `s2` interruptions=2\n- `seat:seat-02` `s5` interruptions=1\n\n" + assert.Equal(t, want, render(s, RenderLinks{IssueIndex: idx})) + }) + t.Run("empty_kinds_render_keys_and_placeholders", func(t *testing.T) { + body := render(snap("2026-09-16", c), RenderLinks{}) + assert.Contains(t, body, "deferrals: 0\npatterns: 0\nfrustrations: 0\ninterruptions: 0\n---\n") + assert.Contains(t, body, "## Patterns\n\n_No patterns detected._\n\n"+ + "## Frustration\n\n_No frustration detected._\n\n"+ + "## Interruptions\n\n_No interruptions detected._\n\n") + }) + t.Run("new_sections_precede_personas", func(t *testing.T) { + s := snap("2026-09-16") + s.Personas = map[PersonaKey]*PersonaCounts{{"helper", "general"}: {Sessions: 1}} + assert.Contains(t, render(s, RenderLinks{}), + "_No interruptions detected._\n\n## Personas\n\n- `helper@general`: no signals (1 session(s))\n\n") + }) + t.Run("interruptions_group_by_session_in_run_order", func(t *testing.T) { + s := snap("2026-09-16", intr("b", Dims{}), intr("a", Dims{}), intr("b", Dims{Agent: "later"}), intr("a", Dims{})) + body := render(s, RenderLinks{}) + assert.Contains(t, body, "## Interruptions\n\n- `b` interruptions=2\n- `a` interruptions=2\n\n", + "first-appearance order; dims from the first signal") + assert.Contains(t, body, "interruptions: 4\n") + }) + t.Run("interruptions_never_annotated", func(t *testing.T) { + i := intr("s2", Dims{}) + s := snap("2026-09-16", i) + s.RecurrenceCosts = map[string]USD{i.Fingerprint(): mustUSD(t, "2")} + idx := map[string]IssueRef{i.Fingerprint(): {ID: "#12", Backend: "kata"}} + body := render(s, RenderLinks{IssueIndex: idx}) + assert.Contains(t, body, "- `s2` interruptions=1\n") + assert.NotContains(t, body, "(→") + assert.NotContains(t, body, "recurred in sessions") + }) + t.Run("frustration_repr_escapes", func(t *testing.T) { + g := Signal{Kind: KindFrustration, SubjectID: "s9", Text: "why won't it\nload???"} + assert.Contains(t, render(snap("2026-09-16", g), RenderLinks{}), "- `s9` — 'why won\\'t it\\nload???'\n") + }) + t.Run("persona_rollup_ignores_new_kinds", func(t *testing.T) { + fp := f + fp.Dims = Dims{Persona: "helper", Channel: "general"} + s := snap("2026-09-16", fp, intr("s1", fp.Dims)) + pc := &PersonaCounts{Sessions: 1} + pc.AddSignals(s.Signals) + s.Personas = map[PersonaKey]*PersonaCounts{{"helper", "general"}: pc} + assert.Contains(t, render(s, RenderLinks{}), "- `helper@general`: no signals (1 session(s))\n") + }) +} + +// Review Focus 4: deferrals never carry annotations. +func TestRenderMarkdownNeverAnnotatesDeferrals(t *testing.T) { + d := Signal{Kind: KindDeferral, SubjectID: "s1", Label: "next session"} + s := snap("2026-09-16", d) + s.RecurrenceCosts = map[string]USD{d.Fingerprint(): mustUSD(t, "4.2")} + idx := map[string]IssueRef{d.Fingerprint(): {ID: "#5", Backend: "kata"}} + body := render(s, RenderLinks{IssueIndex: idx}) + assert.Contains(t, body, "- `s1` pattern=`next session`\n") + assert.NotContains(t, body, "(→") + assert.NotContains(t, body, "recurred in sessions") +} From fc402652e86c44358ce97cede982e0d30a298f9f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:55:15 +0000 Subject: [PATCH 32/62] docs(friction): describe digest renderer deltas --- internal/friction/render_md.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/friction/render_md.go b/internal/friction/render_md.go index d97abc4db0..82392bb170 100644 --- a/internal/friction/render_md.go +++ b/internal/friction/render_md.go @@ -32,8 +32,8 @@ type RenderLinks struct { IssueIndex map[string]IssueRef // by fingerprint } -// RenderMarkdown ports render_digest (digest.rs:723-1031) byte for byte, -// except the heading (D8). +// RenderMarkdown ports render_digest (digest.rs:723-1031), with the D8 heading +// and D36 frustration/interruption additions. func RenderMarkdown(s DigestSnapshot, l RenderLinks) []byte { var corrections, errs, workarounds, deferrals, frustrations, interruptions, patterns []Signal for _, sig := range s.Signals { From 2f78b253538c38128d07a2d9efd1f105b0045f11 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:57:18 +0000 Subject: [PATCH 33/62] feat(friction): render summary JSON v3 and human summary --- internal/friction/render_json.go | 183 ++++++++++++++++++++ internal/friction/render_json_test.go | 239 ++++++++++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 internal/friction/render_json.go create mode 100644 internal/friction/render_json_test.go diff --git a/internal/friction/render_json.go b/internal/friction/render_json.go new file mode 100644 index 0000000000..1b8e84a7e0 --- /dev/null +++ b/internal/friction/render_json.go @@ -0,0 +1,183 @@ +package friction + +import ( + "fmt" + "strconv" + "strings" + + "go.kenn.io/agentsview/internal/serdejson" +) + +// SummarySchemaVersion is 3: jilog's schema 2 plus the D36 +// frustrations/interruptions count keys (spec §9.2). +const SummarySchemaVersion = 3 + +// SummaryMeta is the per-render context the snapshot does not hold. +// DigestPath is nil on a dry run. +type SummaryMeta struct { + DigestPath *string + CreatedIssues []IssueRef + TrackerFailures int +} + +func jsonInt(n int) serdejson.Number { return serdejson.Number(strconv.Itoa(n)) } +func jsonUint(n uint64) serdejson.Number { return serdejson.Number(strconv.FormatUint(n, 10)) } + +func usdMap(m map[string]USD) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v.String() + } + return out +} + +// RenderSummaryJSON ports digest_report_json (commands/review.rs:276-383) +// printed as `to_string_pretty` plus println's newline. +func RenderSummaryJSON(s DigestSnapshot, m SummaryMeta) []byte { + k := countKinds(s.Signals) + + p0 := make(map[string]any, len(s.P0Alerts)) + for tool, ids := range s.P0Alerts { + sessions := sortedUniqueSessions(ids) + arr := make([]any, 0, len(sessions)) + for _, id := range sessions { + arr = append(arr, id) + } + p0[tool] = arr + } + + issues := make([]any, 0, len(m.CreatedIssues)) + for _, is := range m.CreatedIssues { + var url any + if is.URL != "" { + url = is.URL + } + issues = append(issues, map[string]any{ + "id": is.ID, "backend": is.Backend, "title": is.Title, "url": url, + }) + } + + var digestPath any + if m.DigestPath != nil { + digestPath = *m.DigestPath + } + + var spend any + if sp := s.Spend; sp != nil { + var total any + if sp.Total != nil { + total = sp.Total.String() + } + spend = map[string]any{ + "total_usd": total, + "sessions_with_stats": jsonInt(sp.SessionsWithStats), + "sessions_with_cost": jsonInt(sp.SessionsWithCost), + "input_tokens": jsonUint(sp.InputTokens), + "output_tokens": jsonUint(sp.OutputTokens), + "role_costs_usd": usdMap(sp.RoleCosts), + "model_costs_usd": usdMap(sp.ModelCosts), + } + } + + personas := map[string]any{} + for _, entry := range DisplayKeyedPersonas(s.Personas) { + pc := entry.Counts + var channel, cost any + if entry.PersonaKey.Channel != "" { + channel = entry.PersonaKey.Channel + } + if pc.CostUSD != nil { + cost = pc.CostUSD.String() + } + personas[entry.Key] = map[string]any{ + "persona": entry.PersonaKey.Persona, "channel": channel, + "sessions": jsonInt(pc.Sessions), "corrections": jsonInt(pc.Corrections), + "errors": jsonInt(pc.Errors), "workarounds": jsonInt(pc.Workarounds), + "deferrals": jsonInt(pc.Deferrals), "patterns": jsonInt(pc.Patterns), + "input_tokens": jsonUint(pc.InputTokens), "output_tokens": jsonUint(pc.OutputTokens), + "cost_usd": cost, + } + } + + value := map[string]any{ + "schema_version": jsonInt(SummarySchemaVersion), + "sessions_scanned": jsonInt(s.SessionsScanned), + "tracker_failures": jsonInt(m.TrackerFailures), + "corrections": jsonInt(k.Corrections), + "errors": jsonInt(k.Errors), + "workarounds": jsonInt(k.Workarounds), + "deferrals": jsonInt(k.Deferrals), + "patterns": jsonInt(k.Patterns), + "frustrations": jsonInt(k.Frustrations), // D36 + "interruptions": jsonInt(k.Interruptions), // D36 + "p0_alerts": p0, + "personas": personas, + "spend": spend, + "digest_path": digestPath, + "created_issues": issues, + } + if a := s.ArchiveSpend; a != nil { + period := func(ps PeriodSpend) map[string]any { + return map[string]any{ + "total_usd": ps.Total.String(), + "days": jsonInt(ps.Days), + "agents_usd": usdMap(ps.Agents), + "models_usd": usdMap(ps.Models), + } + } + var yesterday any + if a.Yesterday != nil { + yesterday = period(*a.Yesterday) + } + value["archive_spend"] = map[string]any{ + "yesterday": yesterday, + "week": period(a.Week), + "week_from": a.WeekFrom, + "week_to": a.WeekTo, + "timezone": a.Timezone, + } + } + out, err := serdejson.Pretty(value) + if err != nil { + // Every value above is a serdejson-supported type; an error here is + // a programming bug, never a data condition. + panic(fmt.Sprintf("friction: summary JSON: %v", err)) + } + return append(out, '\n') +} + +// HumanSummary ports the non-JSON `review nightly` lines +// (commands/review.rs:148-195). The Spend and Archive lines print raw +// decimals, not FormatUSD: that is jilog's output and is kept. +func HumanSummary(s DigestSnapshot, m SummaryMeta) string { + k := countKinds(s.Signals) + var b strings.Builder + fmt.Fprintf(&b, "%d corrections, %d errors, %d workarounds, %d deferrals, %d patterns, %d P0 alert(s), %d session(s) scanned", + k.Corrections, k.Errors, k.Workarounds, k.Deferrals, k.Patterns, len(s.P0Alerts), s.SessionsScanned) + if k.Frustrations > 0 || k.Interruptions > 0 { + fmt.Fprintf(&b, ", %d frustrations, %d interruptions", k.Frustrations, k.Interruptions) + } + b.WriteByte('\n') + if sp := s.Spend; sp != nil && sp.Total != nil { + fmt.Fprintf(&b, "Spend: $%s across %d of %d session(s) with usage data\n", + sp.Total.String(), sp.SessionsWithCost, sp.SessionsWithStats) + } + if a := s.ArchiveSpend; a != nil { + y := "n/a" + if a.Yesterday != nil { + y = "$" + a.Yesterday.Total.String() + } + fmt.Fprintf(&b, "Archive spend: %s yesterday, $%s trailing 7d (%d day(s))\n", + y, a.Week.Total.String(), a.Week.Days) + } + if m.DigestPath != nil { + fmt.Fprintf(&b, "Digest: %s\n", *m.DigestPath) + } + if len(m.CreatedIssues) > 0 { + fmt.Fprintf(&b, "Created %d issue(s)\n", len(m.CreatedIssues)) + } + if m.TrackerFailures > 0 { + fmt.Fprintf(&b, "Tracker failures: %d (affected sessions retry next run)\n", m.TrackerFailures) + } + return b.String() +} diff --git a/internal/friction/render_json_test.go b/internal/friction/render_json_test.go new file mode 100644 index 0000000000..ec09cf7745 --- /dev/null +++ b/internal/friction/render_json_test.go @@ -0,0 +1,239 @@ +package friction + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/serdejson" +) + +// digestReportFixture ports commands/review.rs digest_report() (:418-462), +// scrubbed: persona helper@general, backend kata, digest_path is the +// public_url-unset form. +func digestReportFixture(t *testing.T) (DigestSnapshot, SummaryMeta) { + t.Helper() + path := "friction:2026-05-10" + return DigestSnapshot{ + Date: "2026-05-10", + P0Alerts: map[string][]string{"bash": {"session-a", "session-b"}}, + Personas: map[PersonaKey]*PersonaCounts{{"helper", "general"}: {Sessions: 2, Corrections: 1, Patterns: 1, InputTokens: 5000, OutputTokens: 250}}, + SessionsScanned: 3, + }, SummaryMeta{ + DigestPath: &path, + CreatedIssues: []IssueRef{{ + ID: "#42", Backend: "kata", Title: "tracked issue", + URL: "https://example.com/issues/42", + }}, + } +} + +func decodeObject(t *testing.T, b []byte) map[string]any { + t.Helper() + v, err := serdejson.Decode(b) + require.NoError(t, err) + obj, ok := v.(map[string]any) + require.True(t, ok, "top level must be an object") + return obj +} + +func TestRenderSummaryJSON(t *testing.T) { + t.Run("json_output_has_documented_keys", func(t *testing.T) { + s, m := digestReportFixture(t) + obj := decodeObject(t, RenderSummaryJSON(s, m)) + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + assert.Equal(t, []string{ + "corrections", "created_issues", "deferrals", "digest_path", "errors", + "frustrations", "interruptions", "p0_alerts", "patterns", "personas", "schema_version", "sessions_scanned", + "spend", "tracker_failures", "workarounds", + }, keys) + assert.Equal(t, map[string]any{ + "helper@general": map[string]any{ + "persona": "helper", "channel": "general", + "sessions": serdejson.Number("2"), "corrections": serdejson.Number("1"), + "errors": serdejson.Number("0"), "workarounds": serdejson.Number("0"), + "deferrals": serdejson.Number("0"), "patterns": serdejson.Number("1"), + "input_tokens": serdejson.Number("5000"), "output_tokens": serdejson.Number("250"), + "cost_usd": nil, + }, + }, obj["personas"]) + assert.Equal(t, serdejson.Number("3"), obj["schema_version"]) + assert.Equal(t, serdejson.Number("3"), obj["sessions_scanned"]) + assert.Equal(t, map[string]any{"bash": []any{"session-a", "session-b"}}, obj["p0_alerts"]) + assert.Equal(t, []any{map[string]any{ + "id": "#42", "backend": "kata", "title": "tracked issue", + "url": "https://example.com/issues/42", + }}, obj["created_issues"]) + }) + t.Run("json_dry_run_uses_null_digest_path", func(t *testing.T) { + s, m := digestReportFixture(t) + m.DigestPath = nil + m.CreatedIssues = nil + out := RenderSummaryJSON(s, m) + obj := decodeObject(t, out) + assert.Nil(t, obj["digest_path"]) + assert.Equal(t, []any{}, obj["created_issues"]) + assert.Contains(t, string(out), "\"created_issues\": [],\n") + }) + t.Run("review_json_carries_archive_spend_only_when_present", func(t *testing.T) { + s, m := digestReportFixture(t) + _, present := decodeObject(t, RenderSummaryJSON(s, m))["archive_spend"] + assert.False(t, present, "no key when absent") + s.ArchiveSpend = &ArchiveSpend{ + Week: PeriodSpend{ + Total: USDFromMicros(2_101_500_000), Days: 7, + Agents: map[string]USD{"codex": USDFromMicros(1_300_250_000)}, + }, + WeekFrom: "2026-09-09", WeekTo: "2026-09-15", Timezone: "Asia/Dhaka", + } + obj := decodeObject(t, RenderSummaryJSON(s, m)) + archive, ok := obj["archive_spend"].(map[string]any) + require.True(t, ok) + week := archive["week"].(map[string]any) + assert.Equal(t, "2101.500000", week["total_usd"]) + assert.Equal(t, serdejson.Number("7"), week["days"]) + assert.Equal(t, map[string]any{"codex": "1300.250000"}, week["agents_usd"]) + assert.Equal(t, map[string]any{}, week["models_usd"]) + assert.Equal(t, "2026-09-09", archive["week_from"]) + assert.Equal(t, "Asia/Dhaka", archive["timezone"]) + assert.Nil(t, archive["yesterday"]) + assert.Equal(t, serdejson.Number("3"), obj["schema_version"]) + }) + t.Run("spend_object_uses_decimal_strings", func(t *testing.T) { + s, m := digestReportFixture(t) + s.Spend = &SpendSummary{ + Total: usdPtr(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2, + InputTokens: 1000, OutputTokens: 50, + RoleCosts: map[string]USD{"(root)": mustUSD(t, "1.2")}, + ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "4.2")}, + } + spend := decodeObject(t, RenderSummaryJSON(s, m))["spend"].(map[string]any) + assert.Equal(t, map[string]any{ + "total_usd": "4.2", + "sessions_with_stats": serdejson.Number("3"), + "sessions_with_cost": serdejson.Number("2"), + "input_tokens": serdejson.Number("1000"), + "output_tokens": serdejson.Number("50"), + "role_costs_usd": map[string]any{"(root)": "1.2"}, + "model_costs_usd": map[string]any{"claude-opus-5": "4.2"}, + }, spend) + }) + t.Run("counts_come_from_signals", func(t *testing.T) { + s, m := digestReportFixture(t) + s.Signals = []Signal{{Kind: KindError}, {Kind: KindError}, {Kind: KindDeferral}} + obj := decodeObject(t, RenderSummaryJSON(s, m)) + assert.Equal(t, serdejson.Number("2"), obj["errors"]) + assert.Equal(t, serdejson.Number("1"), obj["deferrals"]) + assert.Equal(t, serdejson.Number("0"), obj["corrections"]) + }) + t.Run("frustration_and_interruption_keys_always_present", func(t *testing.T) { + s, m := digestReportFixture(t) + obj := decodeObject(t, RenderSummaryJSON(s, m)) + assert.Equal(t, serdejson.Number("0"), obj["frustrations"]) + assert.Equal(t, serdejson.Number("0"), obj["interruptions"]) + + s.Signals = []Signal{{Kind: KindFrustration}, {Kind: KindFrustration}, {Kind: KindInterruption}} + obj = decodeObject(t, RenderSummaryJSON(s, m)) + assert.Equal(t, serdejson.Number("2"), obj["frustrations"]) + assert.Equal(t, serdejson.Number("1"), obj["interruptions"]) + assert.Equal(t, serdejson.Number("3"), obj["schema_version"]) + }) + t.Run("empty_issue_url_is_null", func(t *testing.T) { + s, m := digestReportFixture(t) + m.CreatedIssues[0].URL = "" + issue := decodeObject(t, RenderSummaryJSON(s, m))["created_issues"].([]any)[0].(map[string]any) + assert.Nil(t, issue["url"]) + }) + t.Run("ends_with_one_newline", func(t *testing.T) { + s, m := digestReportFixture(t) + out := RenderSummaryJSON(s, m) + require.NotEmpty(t, out) + assert.Equal(t, byte('\n'), out[len(out)-1]) + assert.NotEqual(t, byte('\n'), out[len(out)-2]) + }) +} + +// Ports commands/review.rs:148-195 (no jilog unit test exists; the lines +// are pinned here, including the raw-decimal Spend quirk). +func TestHumanSummary(t *testing.T) { + path := "friction:2026-09-16" + tests := []struct { + name string + snap DigestSnapshot + meta SummaryMeta + want string + }{ + { + name: "counts_only_dry_run", + snap: DigestSnapshot{SessionsScanned: 0}, + want: "0 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns, 0 P0 alert(s), 0 session(s) scanned\n", + }, + { + name: "spend_line_prints_the_raw_decimal", + snap: DigestSnapshot{ + Signals: []Signal{{Kind: KindCorrection}, {Kind: KindPattern}}, + P0Alerts: map[string][]string{"bash": {"a", "b", "c"}}, + SessionsScanned: 3, + Spend: &SpendSummary{Total: usdPtr(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2}, + }, + meta: SummaryMeta{DigestPath: &path}, + want: "1 corrections, 0 errors, 0 workarounds, 0 deferrals, 1 patterns, 1 P0 alert(s), 3 session(s) scanned\n" + + "Spend: $4.2 across 2 of 3 session(s) with usage data\n" + + "Digest: friction:2026-09-16\n", + }, + { + name: "no_total_no_spend_line", + snap: DigestSnapshot{Spend: &SpendSummary{SessionsWithStats: 1}}, + want: "0 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns, 0 P0 alert(s), 0 session(s) scanned\n", + }, + { + name: "archive_issues_and_failures", + snap: DigestSnapshot{ + ArchiveSpend: &ArchiveSpend{ + Week: PeriodSpend{Total: USDFromMicros(2_101_500_000), Days: 7}, + WeekFrom: "2026-09-09", WeekTo: "2026-09-15", + }, + }, + meta: SummaryMeta{ + DigestPath: &path, + CreatedIssues: []IssueRef{{ID: "#1"}, {ID: "#2"}}, + TrackerFailures: 1, + }, + want: "0 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns, 0 P0 alert(s), 0 session(s) scanned\n" + + "Archive spend: n/a yesterday, $2101.500000 trailing 7d (7 day(s))\n" + + "Digest: friction:2026-09-16\n" + + "Created 2 issue(s)\n" + + "Tracker failures: 1 (affected sessions retry next run)\n", + }, + { + name: "frustration_and_interruption_counts_appended_when_nonzero", + snap: DigestSnapshot{ + Signals: []Signal{{Kind: KindFrustration}, {Kind: KindCorrection}}, + SessionsScanned: 1, + }, + want: "1 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns, 0 P0 alert(s), 1 session(s) scanned, 1 frustrations, 0 interruptions\n", + }, + { + name: "archive_with_yesterday", + snap: DigestSnapshot{ + ArchiveSpend: &ArchiveSpend{ + Yesterday: &PeriodSpend{Total: USDFromMicros(332_138_392), Days: 1}, + Week: PeriodSpend{Total: USDFromMicros(333_638_392), Days: 2}, + }, + }, + want: "0 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns, 0 P0 alert(s), 0 session(s) scanned\n" + + "Archive spend: $332.138392 yesterday, $333.638392 trailing 7d (2 day(s))\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HumanSummary(tt.snap, tt.meta)) + }) + } +} From d728c88f1d1b11a2d0022d8f1df1a8f805eb8686 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:01:14 +0000 Subject: [PATCH 34/62] feat(friction): resolve the digest time zone --- internal/friction/zone.go | 41 +++++++++++++++ internal/friction/zone_test.go | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 internal/friction/zone.go create mode 100644 internal/friction/zone_test.go diff --git a/internal/friction/zone.go b/internal/friction/zone.go new file mode 100644 index 0000000000..45a0ee4311 --- /dev/null +++ b/internal/friction/zone.go @@ -0,0 +1,41 @@ +package friction + +import ( + "fmt" + "strings" + "time" + + "go.kenn.io/agentsview/internal/timeutil" +) + +// ZoneEnvVar overrides every other digest zone source. +const ZoneEnvVar = "AGENTSVIEW_FRICTION_TZ" + +// ResolveZone picks the zone used for digest dates: the environment override, +// then [friction] timezone, then the local zone. Explicit sources must name an +// IANA zone; a typo is an error rather than a silent fallback. +func ResolveZone(env, configured string) (*time.Location, error) { + return resolveZone(env, configured, timeutil.LocalLocation) +} + +func resolveZone(env, configured string, local func() *time.Location) (*time.Location, error) { + if v := strings.TrimSpace(env); v != "" { + return loadIANAZone(v, ZoneEnvVar) + } + if v := strings.TrimSpace(configured); v != "" { + return loadIANAZone(v, "[friction] timezone") + } + return local(), nil +} + +func loadIANAZone(name, source string) (*time.Location, error) { + // time.LoadLocation accepts "Local", which makes digest dates host-dependent. + if name == "Local" { + return nil, fmt.Errorf("%s %q is not an IANA zone", source, name) + } + loc, err := time.LoadLocation(name) + if err != nil { + return nil, fmt.Errorf("%s %q is not an IANA zone: %w", source, name, err) + } + return loc, nil +} diff --git a/internal/friction/zone_test.go b/internal/friction/zone_test.go new file mode 100644 index 0000000000..46c6452598 --- /dev/null +++ b/internal/friction/zone_test.go @@ -0,0 +1,94 @@ +package friction + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustLoad(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + require.NoError(t, err) + return loc +} + +// Adapts zone.rs resolution_order_is_override_config_tz_system_utc. +// The TZ/system/UTC steps collapse into timeutil.LocalLocation, injected as local. +func TestResolveZone(t *testing.T) { + paris := mustLoad(t, "Europe/Paris") + local := func() *time.Location { return paris } + tests := []struct { + name string + env, cfg string + want string + errContain []string + }{ + {name: "env_override_wins", env: "Asia/Tokyo", cfg: "Asia/Dhaka", want: "Asia/Tokyo"}, + {name: "config_second", cfg: "Asia/Dhaka", want: "Asia/Dhaka"}, + {name: "local_location_last", want: "Europe/Paris"}, + {name: "config_is_trimmed", cfg: " Asia/Dhaka ", want: "Asia/Dhaka"}, + {name: "blank_env_falls_through", env: " ", cfg: "Asia/Dhaka", want: "Asia/Dhaka"}, + {name: "blank_config_is_unset", cfg: " ", want: "Europe/Paris"}, + {name: "utc_is_a_zone", cfg: "UTC", want: "UTC"}, + {name: "env_typo_is_an_error", env: "Asia/Tokio", cfg: "Asia/Dhaka", + errContain: []string{"AGENTSVIEW_FRICTION_TZ", `"Asia/Tokio"`, "is not an IANA zone"}}, + {name: "config_typo_is_an_error", cfg: "Asia/Dhakka", + errContain: []string{`[friction] timezone "Asia/Dhakka" is not an IANA zone`}}, + {name: "local_is_not_an_iana_zone", cfg: "Local", + errContain: []string{`[friction] timezone "Local" is not an IANA zone`}}, + {name: "posix_rule_is_not_a_zone", env: "JST-9", + errContain: []string{"AGENTSVIEW_FRICTION_TZ", `"JST-9"`}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc, err := resolveZone(tt.env, tt.cfg, local) + if len(tt.errContain) > 0 { + require.Error(t, err) + for _, s := range tt.errContain { + assert.Contains(t, err.Error(), s) + } + assert.Nil(t, loc) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, loc.String()) + }) + } +} + +func TestResolveZoneUsesLocalLocation(t *testing.T) { + loc, err := ResolveZone("", "") + require.NoError(t, err) + require.NotNil(t, loc) +} + +// Ports zone.rs digest_date_and_window_follow_the_zone with Asia/Dhaka. +func TestDigestDateAndWindowFollowTheZone(t *testing.T) { + dhaka := mustLoad(t, "Asia/Dhaka") + tokyo := mustLoad(t, "Asia/Tokyo") + tests := []struct { + name string + now time.Time + loc *time.Location + date string + from, to string + }{ + {"late_utc_is_next_day_at_plus_six", time.Date(2026, 9, 17, 22, 30, 0, 0, time.UTC), dhaka, "2026-09-18", "2026-09-11", "2026-09-17"}, + {"same_instant_in_utc", time.Date(2026, 9, 17, 22, 30, 0, 0, time.UTC), time.UTC, "2026-09-17", "2026-09-10", "2026-09-16"}, + {"evening_run_is_still_today_at_plus_six", time.Date(2026, 9, 17, 16, 50, 0, 0, time.UTC), dhaka, "2026-09-17", "2026-09-10", "2026-09-16"}, + {"tokyo_has_turned_over", time.Date(2026, 9, 17, 16, 50, 0, 0, time.UTC), tokyo, "2026-09-18", "2026-09-11", "2026-09-17"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + date := tt.now.In(tt.loc).Format(time.DateOnly) + assert.Equal(t, tt.date, date) + from, to, err := ArchiveWindow(date) + require.NoError(t, err) + assert.Equal(t, tt.from, from) + assert.Equal(t, tt.to, to) + }) + } +} From 951c66be6ae453548dc294cb3d352100cd292209 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:14:15 +0000 Subject: [PATCH 35/62] test(friction): pin digest and summary JSON goldens --- docs/internal/jilog-adaptation.md | 79 +++++++-- internal/friction/golden_test.go | 153 ++++++++++++++++++ .../golden/friction-log-extra-kinds.md | 53 ++++++ .../friction/testdata/golden/friction-log.md | 66 ++++++++ .../friction/testdata/golden/manifest.sha256 | 3 + .../friction/testdata/golden/summary.json | 43 +++++ 6 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 internal/friction/golden_test.go create mode 100644 internal/friction/testdata/golden/friction-log-extra-kinds.md create mode 100644 internal/friction/testdata/golden/friction-log.md create mode 100644 internal/friction/testdata/golden/manifest.sha256 create mode 100644 internal/friction/testdata/golden/summary.json diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md index e07deb9307..692941aa8f 100644 --- a/docs/internal/jilog-adaptation.md +++ b/docs/internal/jilog-adaptation.md @@ -1,5 +1,5 @@ --- -last_edited: 2026-09-23 +last_edited: 2026-09-24 --- # jilog adaptation provenance @@ -10,10 +10,11 @@ AgentsView's Friction Log adapts the session review from behavioral reference. This page records the source, the changes, and the MIT notice. Source paths below are relative to that jilog commit. -PR 1 provides pure detection, signal, formatting, and JSON packages. The -archive adapter, persisted review, digest rendering, scheduling, NanoClaw -integration, and Kata filing are planned for later PRs. Descriptions of those -parts below record the approved mapping; they do not describe PR 1 behavior. +PR 1 provides pure detection, signal, formatting, and JSON packages. PR 4 +adds digest rendering. The archive adapter, persisted review, scheduling, +NanoClaw integration, and Kata filing are planned for later PRs. Descriptions +of those parts below record the approved mapping; they do not describe PR 1 +behavior. ## Kept in PR 1 @@ -109,11 +110,69 @@ parts below record the approved mapping; they do not describe PR 1 behavior. - An empty tool name becomes `unknown`; jilog does this only for a missing name. -## Digest golden deltas - -The digest renderer is planned for PR 4. That PR will replace this note with -every difference between AgentsView's golden digest and jilog's -`tests/golden/learning-digest.md`. +## Digest goldens + +`internal/friction/testdata/golden/friction-log.md` is jilog's +`crates/jilog-review/tests/golden/learning-digest.md` (at `9e8e094`), and +`summary.json` is `crates/jilog/tests/golden/review-nightly.json`. The fixture +builders port `tests/golden_digest.rs` and `digest_report()` in +`crates/jilog/src/commands/review.rs`. The only Markdown differences are the D8 +heading, scrubbed fixture strings, the two D36 frontmatter keys and the two D36 +sections, which are empty here. Every jilog line keeps its bytes. The JSON +differences are the `kata` backend, the `digest_path` meaning, `schema_version` +3 and the two D36 count keys: + +```diff + patterns: 1 ++frustrations: 0 ++interruptions: 0 + --- +-# Learning Digest — 2026-09-16 ++# Friction Log — 2026-09-16 ++- `842c45ce-77b2-4d72-b995-f2a10466eb40` — 'do calendar re-auth' (recurred in sessions totaling $4.20) ++- `helper@general` `seat:seat-02` `chat-1` — 'no, use the gh cli' + - `seat:seat-03` `ee58d934-1049-4da0-b5b3-9a00f50efcc7` kind=`stuck_loop`: `bash` x6 identical arguments 01:35-01:54 + ++## Frustration ++ ++_No frustration detected._ ++ ++## Interruptions ++ ++_No interruptions detected._ ++ ++- `helper@general`: 1 corrections, … +``` + +```diff +- "backend": "github", ++ "backend": "kata", +- "digest_path": "/tmp/learning-digest-2026-05-10.md", ++ "digest_path": "friction:2026-05-10", ++ "frustrations": 0, ++ "interruptions": 0, ++ "helper@general": { ++ "channel": "general", ++ "persona": "helper", +- "schema_version": 2, ++ "schema_version": 3, +``` + +`friction-log-extra-kinds.md` has no jilog counterpart. It pins the D36 sections +with content: a frustration line with both annotations, a persona frustration +line, and interruptions counted per session. + +The pattern line keeps jilog's `stuck_loop` kind. The renderer prints the kind +verbatim, and agentsview's `retry_loop` evidence has the same shape. Ported unit +tests scrub personal, channel and machine names the same way, and replace the +fixture time zone with another UTC+06 zone. The `+` lines above show the +scrubbed fixture strings; jilog's originals are not reproduced here. + +Zone resolution differs from jilog `zone.rs`. `AGENTSVIEW_FRICTION_TZ` and +`[friction] timezone` replace `JILOG_TZ` and the config key. After them, +`timeutil.LocalLocation()` covers jilog's `TZ` and system-zone steps and falls +back to the process zone instead of UTC. An empty configured timezone means +unset rather than an error. ## License diff --git a/internal/friction/golden_test.go b/internal/friction/golden_test.go new file mode 100644 index 0000000000..65b18b45bc --- /dev/null +++ b/internal/friction/golden_test.go @@ -0,0 +1,153 @@ +package friction + +import ( + "bytes" + "crypto/sha256" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// updateGolden rewrites testdata/golden instead of comparing: +// +// go test -tags fts5 ./internal/friction -run TestFrictionDigestGolden -update +var updateGolden = flag.Bool( + "update", false, + "rewrite golden files under testdata/ instead of comparing", +) + +// goldenDigestFixture ports jilog tests/golden_digest.rs render_fixture, +// scrubbed per spec §9.3 (see docs/internal/jilog-adaptation.md). +func goldenDigestFixture(t *testing.T) (DigestSnapshot, RenderLinks) { + t.Helper() + d := func(s string) USD { return mustUSD(t, s) } + c1 := Signal{Kind: KindCorrection, SubjectID: "842c45ce-77b2-4d72-b995-f2a10466eb40", Text: "do calendar re-auth"} + c2 := Signal{ + Kind: KindCorrection, SubjectID: "chat-1", Text: "no, use the gh cli", + Dims: Dims{Seat: "seat-02", Persona: "helper", Channel: "general"}, + } + e1 := Signal{ + Kind: KindError, SubjectID: "0e91a2b4-7d3f-4e2a-9c1b-44a7f3d8a1e2", ToolName: "bash", + Text: `{"error":null,"output":{"returncode":101,"stderr":"error[E0308]: mismatched types","stdout":""},"success":false}`, + } + w1 := Signal{ + Kind: KindWorkaround, SubjectID: "0000000000000000-79f0e43ee2304cdb_self", Label: "TODO", + Text: "All data collected. Let me update the todo list and finalize", + } + d1 := Signal{Kind: KindDeferral, SubjectID: "ae5a0552-47f6-4030-af78-09fe71542d3d", Label: "next session"} + p1 := Signal{ + Kind: KindPattern, SubjectID: "ee58d934-1049-4da0-b5b3-9a00f50efcc7", Label: "stuck_loop", + Text: "stuck loop", Evidence: "`bash` x6 identical arguments 01:35-01:54", + Dims: Dims{Seat: "seat-03"}, + } + snap := DigestSnapshot{ + Date: "2026-09-16", + Signals: []Signal{c1, c2, e1, w1, d1, p1}, + P0Alerts: map[string][]string{"bash": {"s1", "s2", "s3"}}, + Personas: map[PersonaKey]*PersonaCounts{ + {"helper", "general"}: { + Sessions: 2, Corrections: 1, + InputTokens: 5000, OutputTokens: 250, CostUSD: usdPtr(d("0.5")), + }, + }, + Spend: &SpendSummary{ + Total: usdPtr(d("4.2")), SessionsWithStats: 3, SessionsWithCost: 2, + InputTokens: 1000, OutputTokens: 50, + RoleCosts: map[string]USD{"(root)": d("1.2"), "explore": d("3")}, + ModelCosts: map[string]USD{"claude-opus-5": d("4.2")}, + }, + RecurrenceCosts: map[string]USD{c1.Fingerprint(): d("4.2")}, + } + links := RenderLinks{IssueIndex: map[string]IssueRef{ + w1.Fingerprint(): {ID: "#7", Backend: "kata", Title: w1.Title()}, + }} + return snap, links +} + +// extraKindsFixture is the D36 golden (spec §9.3): non-empty Frustration +// and Interruptions sections, one annotated frustration line, a persona +// whose line does not count the new kinds, and interruptions grouped per +// session in run order. +func extraKindsFixture(t *testing.T) (DigestSnapshot, RenderLinks) { + t.Helper() + c := Signal{Kind: KindCorrection, SubjectID: "s-1", Text: "no, use the other branch"} + f1 := Signal{Kind: KindFrustration, SubjectID: "s-1", Text: "this is broken again"} + f2 := Signal{ + Kind: KindFrustration, SubjectID: "chat-2", Text: "why won't it load???", + Dims: Dims{Seat: "seat-02", Persona: "helper", Channel: "general"}, + } + i1 := Signal{Kind: KindInterruption, SubjectID: "s-1", Dims: Dims{Agent: "claude"}} + i2 := Signal{Kind: KindInterruption, SubjectID: "s-2"} + i3 := Signal{Kind: KindInterruption, SubjectID: "s-1", Dims: Dims{Agent: "claude"}} + snap := DigestSnapshot{ + Date: "2026-09-17", + Signals: []Signal{c, f1, f2, i1, i2, i3}, + Personas: map[PersonaKey]*PersonaCounts{{"helper", "general"}: {Sessions: 1}}, + RecurrenceCosts: map[string]USD{f1.Fingerprint(): mustUSD(t, "1.5")}, + } + links := RenderLinks{IssueIndex: map[string]IssueRef{ + f1.Fingerprint(): {ID: "#11", Backend: "kata", Title: f1.Title()}, + }} + return snap, links +} + +func buildGoldenDocuments(t *testing.T) map[string][]byte { + t.Helper() + snap, links := goldenDigestFixture(t) + extra, extraLinks := extraKindsFixture(t) + jsonSnap, meta := digestReportFixture(t) + return map[string][]byte{ + "friction-log.md": RenderMarkdown(snap, links), + "friction-log-extra-kinds.md": RenderMarkdown(extra, extraLinks), + "summary.json": RenderSummaryJSON(jsonSnap, meta), + } +} + +func goldenManifest(docs map[string][]byte) []byte { + names := make([]string, 0, len(docs)) + for name := range docs { + names = append(names, name) + } + sort.Strings(names) + var b bytes.Buffer + for _, name := range names { + sum := sha256.Sum256(docs[name]) + _, _ = fmt.Fprintf(&b, "%x %s\n", sum, name) + } + return b.Bytes() +} + +// TestFrictionDigestGolden ports jilog digest_bytes_match_golden and +// review_json_bytes_match_golden, in the TestExportReportingGolden shape +// (cmd/agentsview/export_reporting_test.go:453-482). +func TestFrictionDigestGolden(t *testing.T) { + got := buildGoldenDocuments(t) + repeated := buildGoldenDocuments(t) + require.Equal(t, got, repeated, "independent renders must be byte-identical") + + base := filepath.Join("testdata", "golden") + manifest := goldenManifest(got) + if *updateGolden { + require.NoError(t, os.MkdirAll(base, 0o755)) + for name, contents := range got { + require.NoError(t, os.WriteFile(filepath.Join(base, name), contents, 0o644)) + } + require.NoError(t, os.WriteFile(filepath.Join(base, "manifest.sha256"), manifest, 0o644)) + t.Logf("rewrote friction goldens under %s", base) + return + } + for name, contents := range got { + want, err := os.ReadFile(filepath.Join(base, name)) + require.NoError(t, err, "read %s (run with -update to generate)", name) + assert.Equal(t, string(want), string(contents), name) + } + wantManifest, err := os.ReadFile(filepath.Join(base, "manifest.sha256")) + require.NoError(t, err, "read golden manifest") + assert.Equal(t, string(wantManifest), string(manifest)) +} diff --git a/internal/friction/testdata/golden/friction-log-extra-kinds.md b/internal/friction/testdata/golden/friction-log-extra-kinds.md new file mode 100644 index 0000000000..abb41b841d --- /dev/null +++ b/internal/friction/testdata/golden/friction-log-extra-kinds.md @@ -0,0 +1,53 @@ +--- +date: 2026-09-17 +signals_captured: 6 +p0_count: 0 +corrections: 1 +errors: 0 +workarounds: 0 +deferrals: 0 +patterns: 0 +frustrations: 2 +interruptions: 3 +--- + +# Friction Log — 2026-09-17 + +## P0 Alerts + +_No P0 alerts._ + +## Corrections + +- `s-1` — 'no, use the other branch' + +## Errors + +_No errors detected._ + +## Workarounds + +_No workarounds detected._ + +## Deferrals + +_No deferrals detected._ + +## Patterns + +_No patterns detected._ + +## Frustration + +- `s-1` — 'this is broken again' (→ kata#11) (recurred in sessions totaling $1.50) +- `helper@general` `seat:seat-02` `chat-2` — 'why won\'t it load???' + +## Interruptions + +- `agent:claude` `s-1` interruptions=2 +- `s-2` interruptions=1 + +## Personas + +- `helper@general`: no signals (1 session(s)) + diff --git a/internal/friction/testdata/golden/friction-log.md b/internal/friction/testdata/golden/friction-log.md new file mode 100644 index 0000000000..a359d23c4f --- /dev/null +++ b/internal/friction/testdata/golden/friction-log.md @@ -0,0 +1,66 @@ +--- +date: 2026-09-16 +signals_captured: 6 +p0_count: 1 +corrections: 2 +errors: 1 +workarounds: 1 +deferrals: 1 +patterns: 1 +frustrations: 0 +interruptions: 0 +--- + +# Friction Log — 2026-09-16 + +## P0 Alerts + +- **P0 ALERT**: `bash` failed in 3 distinct sessions: s1, s2, s3 + +## Corrections + +- `842c45ce-77b2-4d72-b995-f2a10466eb40` — 'do calendar re-auth' (recurred in sessions totaling $4.20) +- `helper@general` `seat:seat-02` `chat-1` — 'no, use the gh cli' + +## Errors + +- `0e91a2b4-7d3f-4e2a-9c1b-44a7f3d8a1e2` / `bash`: {"error":null,"output":{"returncode":101,"stderr":"error[E0308]: mismatched types","stdout":""},"success":false} + +## Workarounds + +- `0000000000000000-79f0e43ee2304cdb_self` pattern=`TODO`: 'All data collected. Let me update the todo list and finalize' (→ kata#7) + +## Deferrals + +- `ae5a0552-47f6-4030-af78-09fe71542d3d` pattern=`next session` + +## Patterns + +- `seat:seat-03` `ee58d934-1049-4da0-b5b3-9a00f50efcc7` kind=`stuck_loop`: `bash` x6 identical arguments 01:35-01:54 + +## Frustration + +_No frustration detected._ + +## Interruptions + +_No interruptions detected._ + +## Personas + +- `helper@general`: 1 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns (2 session(s)) — 5000 in / 250 out tokens, $0.50 + +## Spend + +- **Total**: $4.20 across 2 of 3 session(s) with usage data +- **Tokens**: 1000 in / 50 out + +### Spend by role + +- `(root)`: $1.20 +- `explore`: $3.00 + +### Spend by model + +- `claude-opus-5`: $4.20 + diff --git a/internal/friction/testdata/golden/manifest.sha256 b/internal/friction/testdata/golden/manifest.sha256 new file mode 100644 index 0000000000..af0dfea800 --- /dev/null +++ b/internal/friction/testdata/golden/manifest.sha256 @@ -0,0 +1,3 @@ +acc3b26f5d9353c693f587dc172fae2bc85e2e34fc14eec5758f6ea5280d2124 friction-log-extra-kinds.md +568a4113dd37ee1d6e7b3d36de76db62843d3093618b2f212d1aca3f6bfb850d friction-log.md +4e9ed78c920fc2725e2302ed0b8a3d12cfe97a344d31ffcd291458fef9a5c6ab summary.json diff --git a/internal/friction/testdata/golden/summary.json b/internal/friction/testdata/golden/summary.json new file mode 100644 index 0000000000..711cb0964d --- /dev/null +++ b/internal/friction/testdata/golden/summary.json @@ -0,0 +1,43 @@ +{ + "corrections": 0, + "created_issues": [ + { + "backend": "kata", + "id": "#42", + "title": "tracked issue", + "url": "https://example.com/issues/42" + } + ], + "deferrals": 0, + "digest_path": "friction:2026-05-10", + "errors": 0, + "frustrations": 0, + "interruptions": 0, + "p0_alerts": { + "bash": [ + "session-a", + "session-b" + ] + }, + "patterns": 0, + "personas": { + "helper@general": { + "channel": "general", + "corrections": 1, + "cost_usd": null, + "deferrals": 0, + "errors": 0, + "input_tokens": 5000, + "output_tokens": 250, + "patterns": 1, + "persona": "helper", + "sessions": 2, + "workarounds": 0 + } + }, + "schema_version": 3, + "sessions_scanned": 3, + "spend": null, + "tracker_failures": 0, + "workarounds": 0 +} From c840a93e5a84944782f361681a9fadde23bb3f95 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:17:02 +0000 Subject: [PATCH 36/62] docs(changelog): note persisted friction findings --- docs/changelog.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 159a5e98f5..2a67a14917 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -28,6 +28,11 @@ The latest published release is **Improvements** +- Session sync now saves Friction Log findings such as corrections, tool errors, + workarounds, deferrals, repeated patterns, frustration, and interruptions. + Existing sessions are filled in during background reconciliation, and + PostgreSQL mirrors receive the same findings. These deterministic detections + are stored for upcoming digest, API, and UI work. - Syncing uses less CPU to check transcript text for invalid characters. Clean text is now checked in one pass and stored unchanged. In a full sync of a 950 MB local test corpus, total CPU time fell about 4%. Wall-clock time did From 18671ac72a2b6e74b0e038e27a47ef6df6f28247 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:21:45 +0000 Subject: [PATCH 37/62] chore(friction): satisfy receiver and modernize lint checks --- internal/friction/golden_test.go | 4 ++-- internal/friction/personas.go | 2 +- internal/friction/personas_test.go | 17 +++++++++------ internal/friction/render_json_test.go | 4 ++-- internal/friction/render_md_test.go | 8 +++---- internal/friction/spend.go | 2 +- internal/friction/spend_test.go | 30 ++++++++++++++++----------- internal/friction/zone_test.go | 24 ++++++++++++++------- 8 files changed, 55 insertions(+), 36 deletions(-) diff --git a/internal/friction/golden_test.go b/internal/friction/golden_test.go index 65b18b45bc..b463ca2e7d 100644 --- a/internal/friction/golden_test.go +++ b/internal/friction/golden_test.go @@ -53,11 +53,11 @@ func goldenDigestFixture(t *testing.T) (DigestSnapshot, RenderLinks) { Personas: map[PersonaKey]*PersonaCounts{ {"helper", "general"}: { Sessions: 2, Corrections: 1, - InputTokens: 5000, OutputTokens: 250, CostUSD: usdPtr(d("0.5")), + InputTokens: 5000, OutputTokens: 250, CostUSD: new(d("0.5")), }, }, Spend: &SpendSummary{ - Total: usdPtr(d("4.2")), SessionsWithStats: 3, SessionsWithCost: 2, + Total: new(d("4.2")), SessionsWithStats: 3, SessionsWithCost: 2, InputTokens: 1000, OutputTokens: 50, RoleCosts: map[string]USD{"(root)": d("1.2"), "explore": d("3")}, ModelCosts: map[string]USD{"claude-opus-5": d("4.2")}, diff --git a/internal/friction/personas.go b/internal/friction/personas.go index aa6b418706..fd7176a7bc 100644 --- a/internal/friction/personas.go +++ b/internal/friction/personas.go @@ -12,7 +12,7 @@ type PersonaKey struct{ Persona, Channel string } // PersonaCounts is one persona's rollup (jilog PersonaCounts, // digest.rs:81-121). Tokens always; CostUSD only when a session carried // a cost (jilog#2hwm). -type PersonaCounts struct { +type PersonaCounts struct { //nolint:recvcheck // Mutating fold methods need pointers; read-only helpers keep value semantics. Sessions, Corrections, Errors, Workarounds, Deferrals, Patterns int InputTokens, OutputTokens uint64 CostUSD *USD diff --git a/internal/friction/personas_test.go b/internal/friction/personas_test.go index e9437a10c0..80f78dc783 100644 --- a/internal/friction/personas_test.go +++ b/internal/friction/personas_test.go @@ -88,7 +88,7 @@ func TestPersonaCountsFold(t *testing.T) { priced := &PersonaCounts{Sessions: 1} priced.AddSignals(nil) - priced.AddUsage(SessionUsage{SubjectID: "sess-priced", InputTokens: 800, OutputTokens: 40, CostUSD: usdPtr(mustUSD(t, "1.5"))}) + priced.AddUsage(SessionUsage{SubjectID: "sess-priced", InputTokens: 800, OutputTokens: 40, CostUSD: new(mustUSD(t, "1.5"))}) require.NotNil(t, priced.CostUSD) assert.Equal(t, "1.5", priced.CostUSD.String()) assert.Equal(t, 1, priced.Sessions) @@ -104,18 +104,23 @@ func TestPersonaCountsFold(t *testing.T) { t.Run("add_signals_counts_each_kind", func(t *testing.T) { c := &PersonaCounts{} c.AddSignals([]Signal{ - {Kind: KindCorrection}, {Kind: KindCorrection}, {Kind: KindError}, - {Kind: KindWorkaround}, {Kind: KindDeferral}, {Kind: KindPattern}, + {Kind: KindCorrection}, + {Kind: KindCorrection}, + {Kind: KindError}, + {Kind: KindWorkaround}, + {Kind: KindDeferral}, + {Kind: KindPattern}, // D36: persona counts stay jilog's five kinds. - {Kind: KindFrustration}, {Kind: KindInterruption}, + {Kind: KindFrustration}, + {Kind: KindInterruption}, }) assert.Equal(t, PersonaCounts{Corrections: 2, Errors: 1, Workarounds: 1, Deferrals: 1, Patterns: 1}, *c) assert.Equal(t, 6, c.signalTotal()) }) t.Run("cost_sums_across_sessions", func(t *testing.T) { c := &PersonaCounts{} - c.AddUsage(SessionUsage{CostUSD: usdPtr(mustUSD(t, "0.25"))}) - c.AddUsage(SessionUsage{CostUSD: usdPtr(mustUSD(t, "0.25"))}) + c.AddUsage(SessionUsage{CostUSD: new(mustUSD(t, "0.25"))}) + c.AddUsage(SessionUsage{CostUSD: new(mustUSD(t, "0.25"))}) require.NotNil(t, c.CostUSD) assert.Equal(t, "0.50", c.CostUSD.String()) }) diff --git a/internal/friction/render_json_test.go b/internal/friction/render_json_test.go index ec09cf7745..c0a52ee152 100644 --- a/internal/friction/render_json_test.go +++ b/internal/friction/render_json_test.go @@ -108,7 +108,7 @@ func TestRenderSummaryJSON(t *testing.T) { t.Run("spend_object_uses_decimal_strings", func(t *testing.T) { s, m := digestReportFixture(t) s.Spend = &SpendSummary{ - Total: usdPtr(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2, + Total: new(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2, InputTokens: 1000, OutputTokens: 50, RoleCosts: map[string]USD{"(root)": mustUSD(t, "1.2")}, ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "4.2")}, @@ -180,7 +180,7 @@ func TestHumanSummary(t *testing.T) { Signals: []Signal{{Kind: KindCorrection}, {Kind: KindPattern}}, P0Alerts: map[string][]string{"bash": {"a", "b", "c"}}, SessionsScanned: 3, - Spend: &SpendSummary{Total: usdPtr(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2}, + Spend: &SpendSummary{Total: new(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2}, }, meta: SummaryMeta{DigestPath: &path}, want: "1 corrections, 0 errors, 0 workarounds, 0 deferrals, 1 patterns, 1 P0 alert(s), 3 session(s) scanned\n" + diff --git a/internal/friction/render_md_test.go b/internal/friction/render_md_test.go index 549a3df37d..83f339fea7 100644 --- a/internal/friction/render_md_test.go +++ b/internal/friction/render_md_test.go @@ -16,7 +16,7 @@ func snap(date string, sigs ...Signal) DigestSnapshot { func lineWith(t *testing.T, body, needle string) string { t.Helper() - for _, l := range strings.Split(body, "\n") { + for l := range strings.SplitSeq(body, "\n") { if strings.Contains(l, needle) { return l } @@ -107,7 +107,7 @@ func TestRenderMarkdown(t *testing.T) { t.Run("digest_spend_section_renders_totals_roles_models", func(t *testing.T) { s := snap("2026-07-05") s.Spend = &SpendSummary{ - Total: usdPtr(mustUSD(t, "4.2")), + Total: new(mustUSD(t, "4.2")), SessionsWithStats: 3, SessionsWithCost: 2, InputTokens: 12345, OutputTokens: 678, RoleCosts: map[string]USD{"(root)": mustUSD(t, "3.1"), "explore": mustUSD(t, "1.1")}, @@ -156,7 +156,7 @@ func TestRenderMarkdown(t *testing.T) { s := snap("2026-08-26") s.Personas = map[PersonaKey]*PersonaCounts{ {"helper", "ops"}: {Sessions: 1, Corrections: 1, InputTokens: 12000, OutputTokens: 300}, - {"reviewer", ""}: {Sessions: 1, InputTokens: 800, OutputTokens: 40, CostUSD: usdPtr(mustUSD(t, "1.5"))}, + {"reviewer", ""}: {Sessions: 1, InputTokens: 800, OutputTokens: 40, CostUSD: new(mustUSD(t, "1.5"))}, {"quiet", "lounge"}: {Sessions: 2}, } body := render(s, RenderLinks{}) @@ -303,7 +303,7 @@ func TestRenderIsDeterministic(t *testing.T) { s.Personas[PersonaKey{p, "ch"}] = &PersonaCounts{Sessions: 1} } s.Spend = &SpendSummary{ - Total: usdPtr(mustUSD(t, "5")), SessionsWithStats: 5, SessionsWithCost: 5, + Total: new(mustUSD(t, "5")), SessionsWithStats: 5, SessionsWithCost: 5, RoleCosts: map[string]USD{"(root)": mustUSD(t, "1"), "subagent": mustUSD(t, "2"), "b": mustUSD(t, "2")}, ModelCosts: map[string]USD{"m1": mustUSD(t, "1"), "m2": mustUSD(t, "1"), "m3": mustUSD(t, "3")}, } diff --git a/internal/friction/spend.go b/internal/friction/spend.go index 85616df463..c653a17404 100644 --- a/internal/friction/spend.go +++ b/internal/friction/spend.go @@ -149,7 +149,7 @@ func sortedKeys[V any](m map[string]V) []string { // PeriodSpend is archive spend over one day or the trailing week. // Days counts rows present. -type PeriodSpend struct { +type PeriodSpend struct { //nolint:recvcheck // add mutates; ranking methods use the planned value-receiver API. Total USD Days int Agents, Models map[string]USD diff --git a/internal/friction/spend_test.go b/internal/friction/spend_test.go index 3c74aadfd5..93b21c915d 100644 --- a/internal/friction/spend_test.go +++ b/internal/friction/spend_test.go @@ -7,8 +7,6 @@ import ( "github.com/stretchr/testify/require" ) -func usdPtr(u USD) *USD { return &u } - // Ports digest.rs accumulate_stats (:1169-1203) through the fold-level // assertions of run_review_annotates_recurring_signal_with_session_cost, // run_review_no_recurrence_annotation_for_new_signals and @@ -29,7 +27,7 @@ func TestSpendSummaryAccumulate(t *testing.T) { usage: []SessionUsage{{ SubjectID: "sess-r_explore", Role: "explore", InputTokens: 100, OutputTokens: 10, - CostUSD: usdPtr(mustUSD(t, "4.2")), + CostUSD: new(mustUSD(t, "4.2")), }}, total: "4.2", withStats: 1, withCost: 1, in: 100, out: 10, roles: map[string]string{"explore": "4.2"}, @@ -38,7 +36,7 @@ func TestSpendSummaryAccumulate(t *testing.T) { name: "run_review_no_recurrence_annotation_for_new_signals", usage: []SessionUsage{{ SubjectID: "sess-n", InputTokens: 1, OutputTokens: 1, - CostUSD: usdPtr(mustUSD(t, "1.0")), + CostUSD: new(mustUSD(t, "1.0")), }}, total: "1.0", withStats: 1, withCost: 1, in: 1, out: 1, roles: map[string]string{"(root)": "1.0"}, @@ -53,10 +51,14 @@ func TestSpendSummaryAccumulate(t *testing.T) { { name: "sums_keep_the_max_scale_and_model_costs_fold", usage: []SessionUsage{ - {SubjectID: "a", Role: RootRole, CostUSD: usdPtr(mustUSD(t, "1.2")), - ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "1.2")}}, - {SubjectID: "b", Role: "subagent", CostUSD: usdPtr(mustUSD(t, "3")), - ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "3")}}, + { + SubjectID: "a", Role: RootRole, CostUSD: new(mustUSD(t, "1.2")), + ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "1.2")}, + }, + { + SubjectID: "b", Role: "subagent", CostUSD: new(mustUSD(t, "3")), + ModelCosts: map[string]USD{"claude-opus-5": mustUSD(t, "3")}, + }, }, total: "4.2", withStats: 2, withCost: 2, roles: map[string]string{"(root)": "1.2", "subagent": "3"}, @@ -229,10 +231,13 @@ func TestFormatUSDRenderContract(t *testing.T) { // (:269-330), minus usage-daily JSON parsing replaced by the native rollup query. func archiveRows() []DailySpend { return []DailySpend{ - {Date: "2026-09-14", Total: USDFromMicros(1_500_000), + { + Date: "2026-09-14", Total: USDFromMicros(1_500_000), Agents: map[string]USD{"codex": USDFromMicros(1_500_000)}, - Models: map[string]USD{"gpt-5.6-sol": USDFromMicros(1_500_000)}}, - {Date: "2026-09-15", Total: USDFromMicros(332_138_392), + Models: map[string]USD{"gpt-5.6-sol": USDFromMicros(1_500_000)}, + }, + { + Date: "2026-09-15", Total: USDFromMicros(332_138_392), Agents: map[string]USD{ "codex": USDFromMicros(224_554_060), "claude": USDFromMicros(104_943_456), "cowork": USDFromMicros(2_640_876), @@ -240,7 +245,8 @@ func archiveRows() []DailySpend { Models: map[string]USD{ "gpt-6-astra": USDFromMicros(125_784_488), "claude-opus-5": USDFromMicros(107_584_332), "gpt-5.6-sol": USDFromMicros(98_769_572), - }}, + }, + }, {Date: "2026-09-16", Total: USDFromMicros(999)}, } } diff --git a/internal/friction/zone_test.go b/internal/friction/zone_test.go index 46c6452598..a0ad18552c 100644 --- a/internal/friction/zone_test.go +++ b/internal/friction/zone_test.go @@ -33,14 +33,22 @@ func TestResolveZone(t *testing.T) { {name: "blank_env_falls_through", env: " ", cfg: "Asia/Dhaka", want: "Asia/Dhaka"}, {name: "blank_config_is_unset", cfg: " ", want: "Europe/Paris"}, {name: "utc_is_a_zone", cfg: "UTC", want: "UTC"}, - {name: "env_typo_is_an_error", env: "Asia/Tokio", cfg: "Asia/Dhaka", - errContain: []string{"AGENTSVIEW_FRICTION_TZ", `"Asia/Tokio"`, "is not an IANA zone"}}, - {name: "config_typo_is_an_error", cfg: "Asia/Dhakka", - errContain: []string{`[friction] timezone "Asia/Dhakka" is not an IANA zone`}}, - {name: "local_is_not_an_iana_zone", cfg: "Local", - errContain: []string{`[friction] timezone "Local" is not an IANA zone`}}, - {name: "posix_rule_is_not_a_zone", env: "JST-9", - errContain: []string{"AGENTSVIEW_FRICTION_TZ", `"JST-9"`}}, + { + name: "env_typo_is_an_error", env: "Asia/Tokio", cfg: "Asia/Dhaka", + errContain: []string{"AGENTSVIEW_FRICTION_TZ", `"Asia/Tokio"`, "is not an IANA zone"}, + }, + { + name: "config_typo_is_an_error", cfg: "Asia/Dhakka", + errContain: []string{`[friction] timezone "Asia/Dhakka" is not an IANA zone`}, + }, + { + name: "local_is_not_an_iana_zone", cfg: "Local", + errContain: []string{`[friction] timezone "Local" is not an IANA zone`}, + }, + { + name: "posix_rule_is_not_a_zone", env: "JST-9", + errContain: []string{"AGENTSVIEW_FRICTION_TZ", `"JST-9"`}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 7263fce626e874ed79775c3abcba41d866702771 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:29:43 +0000 Subject: [PATCH 38/62] fix(friction): sanitize spend names and guard model limits --- docs/internal/jilog-adaptation.md | 2 ++ internal/friction/render_md.go | 2 +- internal/friction/render_md_test.go | 21 ++++++++++++++++ internal/friction/spend.go | 6 ++++- internal/friction/spend_test.go | 37 ++++++++++++++++++++--------- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md index 692941aa8f..945891bb14 100644 --- a/docs/internal/jilog-adaptation.md +++ b/docs/internal/jilog-adaptation.md @@ -109,6 +109,8 @@ behavior. exponents and underscores, which AgentsView does not produce. - An empty tool name becomes `unknown`; jilog does this only for a missing name. +- Observed-spend role and model names are sanitized in Markdown so a backtick + or control character cannot break a line. JSON keeps the original map keys. ## Digest goldens diff --git a/internal/friction/render_md.go b/internal/friction/render_md.go index 82392bb170..30fdc269fe 100644 --- a/internal/friction/render_md.go +++ b/internal/friction/render_md.go @@ -183,7 +183,7 @@ func costTable(b *bytes.Buffer, header string, costs map[string]USD) { } b.WriteString(header) for _, k := range sortedKeys(costs) { - fmt.Fprintf(b, "- `%s`: %s\n", k, FormatUSD(costs[k])) + fmt.Fprintf(b, "- `%s`: %s\n", SanitizeDisplay(k), FormatUSD(costs[k])) } b.WriteByte('\n') } diff --git a/internal/friction/render_md_test.go b/internal/friction/render_md_test.go index 83f339fea7..0d642e6a44 100644 --- a/internal/friction/render_md_test.go +++ b/internal/friction/render_md_test.go @@ -294,6 +294,27 @@ func TestRenderMarkdownArchiveNamesSanitized(t *testing.T) { assert.NotContains(t, body, "\n## Injected") } +func TestRenderMarkdownSpendNamesSanitized(t *testing.T) { + role := "role`\n## Injected role" + model := "model`\n## Injected model" + s := snap("2026-09-16") + s.Spend = &SpendSummary{ + Total: new(mustUSD(t, "2")), SessionsWithStats: 1, SessionsWithCost: 1, + RoleCosts: map[string]USD{role: mustUSD(t, "1")}, + ModelCosts: map[string]USD{model: mustUSD(t, "1")}, + } + body := render(s, RenderLinks{}) + assert.Contains(t, body, "- `role' ## Injected role`: $1.00\n") + assert.Contains(t, body, "- `model' ## Injected model`: $1.00\n") + assert.NotContains(t, body, "\n## Injected role") + assert.NotContains(t, body, "\n## Injected model") + assert.Contains(t, s.Spend.RoleCosts, role, "display sanitization must leave the raw key intact") + assert.Contains(t, s.Spend.ModelCosts, model, "display sanitization must leave the raw key intact") + spend := decodeObject(t, RenderSummaryJSON(s, SummaryMeta{}))["spend"].(map[string]any) + assert.Equal(t, map[string]any{role: "1"}, spend["role_costs_usd"]) + assert.Equal(t, map[string]any{model: "1"}, spend["model_costs_usd"]) +} + // Review Focus 2: Go map order never leaks into the bytes. func TestRenderIsDeterministic(t *testing.T) { s := snap("2026-09-16") diff --git a/internal/friction/spend.go b/internal/friction/spend.go index c653a17404..fe9b0669f3 100644 --- a/internal/friction/spend.go +++ b/internal/friction/spend.go @@ -190,8 +190,12 @@ func (p *PeriodSpend) add(r DailySpend) { // AgentsByCost ranks agents by cost descending, ties by name. func (p PeriodSpend) AgentsByCost() []NamedUSD { return rankByCost(p.Agents) } -// TopModels returns the n most expensive models, ties by name. +// TopModels returns the n most expensive models, ties by name. Nonpositive +// limits return no models. func (p PeriodSpend) TopModels(n int) []NamedUSD { + if n <= 0 { + return nil + } ranked := rankByCost(p.Models) if len(ranked) > n { ranked = ranked[:n] diff --git a/internal/friction/spend_test.go b/internal/friction/spend_test.go index 93b21c915d..d89355aac7 100644 --- a/internal/friction/spend_test.go +++ b/internal/friction/spend_test.go @@ -1,6 +1,7 @@ package friction import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -108,9 +109,13 @@ func usdStrings(m map[string]USD) map[string]string { // fingerprint instead of title. func TestRecurrenceCostAnnotations(t *testing.T) { corr := Signal{Kind: KindCorrection, SubjectID: "sess-r_explore", Text: "no, use the gh cli for calendar"} - corrOther := corr - corrOther.SubjectID = "sess-2" errSig := Signal{Kind: KindError, SubjectID: "sess-3", ToolName: "bash", Text: "boom"} + sharedErr := Signal{Kind: KindError, SubjectID: "sess-r_explore", ToolName: "bash", Text: "same failure"} + sharedErrOther := sharedErr + sharedErrOther.SubjectID = "sess-2" + sharedWorkaround := Signal{Kind: KindWorkaround, SubjectID: "sess-r_explore", Label: "for now", Text: "temporary fix"} + sharedWorkaroundOther := sharedWorkaround + sharedWorkaroundOther.SubjectID = "sess-2" def := Signal{Kind: KindDeferral, SubjectID: "sess-r_explore", Label: "next session"} frus := Signal{Kind: KindFrustration, SubjectID: "sess-r_explore", Text: "this is broken again"} intr := Signal{Kind: KindInterruption, SubjectID: "sess-r_explore", Text: "[Request interrupted by user]"} @@ -137,15 +142,16 @@ func TestRecurrenceCostAnnotations(t *testing.T) { want: map[string]string{}, }, { - name: "distinct_sessions_sum_with_max_scale", - // corr and corrOther have different titles (the correction - // title carries the session id), so give both fingerprints. - sigs: []Signal{corr, corr, corrOther}, - open: map[string]bool{corr.Fingerprint(): true, corrOther.Fingerprint(): true}, - want: map[string]string{ - corr.Fingerprint(): "$4.20", - corrOther.Fingerprint(): "$0.05", - }, + name: "matching_errors_sum_distinct_sessions_once_at_max_scale", + sigs: []Signal{sharedErr, sharedErr, sharedErrOther}, + open: map[string]bool{sharedErr.Fingerprint(): true}, + want: map[string]string{sharedErr.Fingerprint(): "$4.25"}, + }, + { + name: "matching_workarounds_sum_distinct_sessions_once_at_max_scale", + sigs: []Signal{sharedWorkaround, sharedWorkaroundOther, sharedWorkaroundOther}, + open: map[string]bool{sharedWorkaround.Fingerprint(): true}, + want: map[string]string{sharedWorkaround.Fingerprint(): "$4.25"}, }, { name: "sessions_without_cost_give_no_annotation", @@ -334,3 +340,12 @@ func TestSummarizeArchiveSpend(t *testing.T) { require.Error(t, err) }) } + +func TestPeriodSpendTopModelsNonPositive(t *testing.T) { + p := PeriodSpend{Models: map[string]USD{"model": mustUSD(t, "1")}} + for _, n := range []int{0, -1} { + t.Run(fmt.Sprintf("limit_%d", n), func(t *testing.T) { + assert.Empty(t, p.TopModels(n)) + }) + } +} From 5ceb63dd237042f2f4fd326a5604d8be27a8fd0c Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:31:00 +0000 Subject: [PATCH 39/62] fix(friction): satisfy digest lint gates --- internal/friction/render_json_test.go | 6 ++++-- internal/friction/render_md.go | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/friction/render_json_test.go b/internal/friction/render_json_test.go index c0a52ee152..8ed364780a 100644 --- a/internal/friction/render_json_test.go +++ b/internal/friction/render_json_test.go @@ -16,18 +16,20 @@ import ( func digestReportFixture(t *testing.T) (DigestSnapshot, SummaryMeta) { t.Helper() path := "friction:2026-05-10" - return DigestSnapshot{ + snapshot := DigestSnapshot{ Date: "2026-05-10", P0Alerts: map[string][]string{"bash": {"session-a", "session-b"}}, Personas: map[PersonaKey]*PersonaCounts{{"helper", "general"}: {Sessions: 2, Corrections: 1, Patterns: 1, InputTokens: 5000, OutputTokens: 250}}, SessionsScanned: 3, - }, SummaryMeta{ + } + meta := SummaryMeta{ DigestPath: &path, CreatedIssues: []IssueRef{{ ID: "#42", Backend: "kata", Title: "tracked issue", URL: "https://example.com/issues/42", }}, } + return snapshot, meta } func decodeObject(t *testing.T, b []byte) map[string]any { diff --git a/internal/friction/render_md.go b/internal/friction/render_md.go index 30fdc269fe..96a2ec73b9 100644 --- a/internal/friction/render_md.go +++ b/internal/friction/render_md.go @@ -164,7 +164,7 @@ type interruptionGroup struct { // groupInterruptions folds interruption signals into one entry per // subject, in order of first appearance (run order). func groupInterruptions(sigs []Signal) []interruptionGroup { - var groups []interruptionGroup + groups := make([]interruptionGroup, 0) index := map[string]int{} for _, sig := range sigs { if i, ok := index[sig.SubjectID]; ok { From 1925ed7cd3f4f87580b31e9a249bcba87db2ca0e Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:47:15 +0000 Subject: [PATCH 40/62] feat(config): add friction review settings on by default --- docs/configuration.md | 1 + internal/config/config.go | 63 +++++++++++++++++ internal/config/config_friction_test.go | 91 +++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 internal/config/config_friction_test.go diff --git a/docs/configuration.md b/docs/configuration.md index e502bdd9cc..e0ac88a05e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -233,6 +233,7 @@ zoom_level = 120 | `[vector]` | Opt-in semantic-search index; model settings live in `[vector.embeddings]`, named endpoints in `[vector.embeddings.servers.]`, embedding schedule in `[vector.embed]` — see [Semantic Search](/docs/semantic-search/#enabling-vector) for every key | | `[recall.extract]` | Opt-in model-backed recall extraction; named endpoints in `[recall.extract.servers.]`, prompt selection in `[recall.extract.prompts]`, request overrides in `[recall.extract.request]` — see [Recall](/docs/recall/#automatic-extraction) | | `[insights]` | Optional generated-insights endpoint and model; local loopback HTTP is allowed, remote plaintext requires `allow_http = true`, and endpoint failures do not retry through a CLI — see [Recall](/docs/recall/#current-surface) | +| `[friction]` | Friction Log review, on by default: `enabled`, `timezone` (IANA; `AGENTSVIEW_FRICTION_TZ` overrides), `backfill_days` (default 7), `seat_patterns` (session path globs with one `{seat}` capture) | | `[[remote_hosts]]` | Remote machines synced by a bare `agentsview sync` — see [CLI Reference](/docs/commands/#agentsview-sync) | | `[[session_sources]]` | Additional filesystem session roots with per-root machine keys — see [Filesystem Session Sync](/docs/filesystem-sync/) | | `[automated]` | Custom automated-session patterns — see [Automated Session Detection](#automated-session-detection) | diff --git a/internal/config/config.go b/internal/config/config.go index 9138d71c60..222c7a060c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,7 @@ import ( "github.com/BurntSushi/toml" "github.com/gofrs/flock" "github.com/spf13/pflag" + "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/jsonutil" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/pathutil" @@ -607,6 +608,45 @@ func (c InsightsConfig) Validate() error { return nil } +// DefaultFrictionBackfillDays bounds catch-up to recent complete local dates. +const DefaultFrictionBackfillDays = 7 + +// FrictionConfig controls the deterministic Friction Log review. +type FrictionConfig struct { + Enabled bool `json:"enabled" toml:"enabled"` + // Timezone is the IANA zone digests are dated in. An empty value uses + // the local zone; AGENTSVIEW_FRICTION_TZ overrides this setting. + Timezone string `json:"timezone,omitempty" toml:"timezone"` + // BackfillDays bounds catch-up to recent complete local dates. + BackfillDays int `json:"backfill_days" toml:"backfill_days"` + // SeatPatterns are globs over session file paths with one {seat} capture. + SeatPatterns []string `json:"seat_patterns,omitempty" toml:"seat_patterns"` +} + +// Validate checks the zone name, backfill bound, and seat patterns. +func (c FrictionConfig) Validate() error { + if tz := strings.TrimSpace(c.Timezone); tz != "" { + if tz == "Local" { + return fmt.Errorf("[friction] timezone %q is not an IANA zone", tz) + } + if _, err := time.LoadLocation(tz); err != nil { + return fmt.Errorf("[friction] timezone %q is not an IANA zone", tz) + } + } + if c.BackfillDays < 1 || c.BackfillDays > 3650 { + return errors.New("[friction] backfill_days must be between 1 and 3650") + } + for _, p := range c.SeatPatterns { + if strings.Count(p, "{seat}") != 1 { + return fmt.Errorf("[friction] seat_patterns entry %q must contain exactly one {seat}", p) + } + } + if _, err := friction.CompileSeatPatterns(c.SeatPatterns); err != nil { + return fmt.Errorf("[friction] seat_patterns: %w", err) + } + return nil +} + type CustomModelRate struct { InputMicrodollarsPerMTok int64 `json:"input_microdollars_per_mtok" toml:"input_microdollars_per_mtok"` OutputMicrodollarsPerMTok int64 `json:"output_microdollars_per_mtok" toml:"output_microdollars_per_mtok"` @@ -805,6 +845,7 @@ type Config struct { Vector VectorConfig `json:"vector,omitempty" toml:"vector"` Recall RecallConfig `json:"recall,omitempty" toml:"recall"` Insights InsightsConfig `json:"insights,omitempty" toml:"insights"` + Friction FrictionConfig `json:"friction,omitempty" toml:"friction"` Automated AutomatedConfig `json:"automated,omitempty" toml:"automated"` Agent map[string]AgentConfig `json:"agent,omitempty" toml:"agent"` WriteTimeout time.Duration `json:"-" toml:"-"` @@ -1212,6 +1253,7 @@ func Default() (Config, error) { FailureBackoff: "1h", }, }, + Friction: FrictionConfig{Enabled: true, BackfillDays: DefaultFrictionBackfillDays}, }, nil } @@ -1603,6 +1645,7 @@ func (c *Config) applyConfigTOML(data string) error { Vector VectorConfig `toml:"vector"` Recall RecallConfig `toml:"recall"` Insights InsightsConfig `toml:"insights"` + Friction FrictionConfig `toml:"friction"` Automated AutomatedConfig `toml:"automated"` Agent map[string]AgentConfig `toml:"agent"` EventsCoalesceInterval time.Duration `toml:"events_coalesce_interval"` @@ -1875,6 +1918,23 @@ func (c *Config) applyConfigTOML(data string) error { c.Insights.Model = strings.TrimSpace(c.Insights.Model) c.Insights.APIKeyEnv = strings.TrimSpace(c.Insights.APIKeyEnv) } + if meta.IsDefined("friction", "enabled") { + c.Friction.Enabled = file.Friction.Enabled + } + if meta.IsDefined("friction", "timezone") { + c.Friction.Timezone = strings.TrimSpace(file.Friction.Timezone) + } + if meta.IsDefined("friction", "backfill_days") { + c.Friction.BackfillDays = file.Friction.BackfillDays + } + if meta.IsDefined("friction", "seat_patterns") { + c.Friction.SeatPatterns = make([]string, 0, len(file.Friction.SeatPatterns)) + for _, p := range file.Friction.SeatPatterns { + if p = strings.TrimSpace(p); p != "" { + c.Friction.SeatPatterns = append(c.Friction.SeatPatterns, p) + } + } + } // IsDefined distinguishes "unset" (leave default 10s) from an // explicit "0s" (disable coalescing). Checking != 0 would silently // ignore the latter. @@ -2453,6 +2513,9 @@ func finalize(cfg *Config) error { if err := cfg.Insights.Validate(); err != nil { return err } + if err := cfg.Friction.Validate(); err != nil { + return err + } return nil } diff --git a/internal/config/config_friction_test.go b/internal/config/config_friction_test.go new file mode 100644 index 0000000000..f15615bd70 --- /dev/null +++ b/internal/config/config_friction_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFrictionConfigValidate(t *testing.T) { + tests := []struct { + name string + config FrictionConfig + wantErr string + }{ + {name: "defaults", config: FrictionConfig{Enabled: true, BackfillDays: 7}}, + {name: "zone", config: FrictionConfig{Enabled: true, Timezone: "Asia/Tokyo", BackfillDays: 30}}, + {name: "empty zone means local", config: FrictionConfig{Enabled: true, BackfillDays: 1}}, + {name: "upper bound", config: FrictionConfig{BackfillDays: 3650}}, + {name: "seat pattern", config: FrictionConfig{BackfillDays: 7, SeatPatterns: []string{"*/profiles/{seat}/projects/*"}}}, + {name: "bad zone", config: FrictionConfig{Timezone: "Mars/Olympus", BackfillDays: 7}, wantErr: `[friction] timezone "Mars/Olympus" is not an IANA zone`}, + {name: "local is not an IANA zone", config: FrictionConfig{Timezone: "Local", BackfillDays: 7}, wantErr: `[friction] timezone "Local" is not an IANA zone`}, + {name: "zero backfill", config: FrictionConfig{BackfillDays: 0}, wantErr: "[friction] backfill_days must be between 1 and 3650"}, + {name: "huge backfill", config: FrictionConfig{BackfillDays: 3651}, wantErr: "[friction] backfill_days must be between 1 and 3650"}, + { + name: "seat pattern without capture", config: FrictionConfig{BackfillDays: 7, SeatPatterns: []string{"*/profiles/*"}}, + wantErr: `[friction] seat_patterns entry "*/profiles/*" must contain exactly one {seat}`, + }, + { + name: "seat pattern with two captures", config: FrictionConfig{BackfillDays: 7, SeatPatterns: []string{"{seat}/{seat}"}}, + wantErr: `[friction] seat_patterns entry "{seat}/{seat}" must contain exactly one {seat}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + }) + } +} + +func TestFrictionConfigTOMLLoadAndFinalize(t *testing.T) { + tests := []struct { + name string + data map[string]any + want FrictionConfig + }{ + { + name: "absent section means enabled", data: map[string]any{}, + want: FrictionConfig{Enabled: true, BackfillDays: DefaultFrictionBackfillDays}, + }, + { + name: "all keys trimmed", + data: map[string]any{"friction": map[string]any{ + "enabled": true, "timezone": " Europe/Berlin ", "backfill_days": 14, + "seat_patterns": []any{" */profiles/{seat}/projects/* "}, + }}, + want: FrictionConfig{ + Enabled: true, Timezone: "Europe/Berlin", BackfillDays: 14, + SeatPatterns: []string{"*/profiles/{seat}/projects/*"}, + }, + }, + { + name: "partial table keeps defaults", + data: map[string]any{"friction": map[string]any{"timezone": "UTC"}}, + want: FrictionConfig{Enabled: true, Timezone: "UTC", BackfillDays: DefaultFrictionBackfillDays}, + }, + { + name: "explicit false disables", + data: map[string]any{"friction": map[string]any{"enabled": false}}, + want: FrictionConfig{Enabled: false, BackfillDays: DefaultFrictionBackfillDays}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadMinimalWithConfig(t, tt.data) + assert.Equal(t, tt.want, cfg.Friction) + }) + } + + err := loadMinimalErrWithConfig(t, map[string]any{ + "friction": map[string]any{"timezone": "Nowhere/Land"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), `[friction] timezone "Nowhere/Land" is not an IANA zone`) +} From 4e6e86cc464dc5b5bab27ad74046f234af7eee2a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:06:19 +0000 Subject: [PATCH 41/62] feat(db): store friction digests, membership and recurrence --- internal/db/db.go | 3 + internal/db/friction_digest.go | 367 ++++++++++++++++++++++++++++ internal/db/friction_digest_test.go | 350 ++++++++++++++++++++++++++ internal/db/schema.sql | 42 ++++ 4 files changed, 762 insertions(+) create mode 100644 internal/db/friction_digest.go create mode 100644 internal/db/friction_digest_test.go diff --git a/internal/db/db.go b/internal/db/db.go index 1b6d3a8e2f..1993d81d87 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1826,6 +1826,9 @@ var readOnlyRequiredTables = []string{ "model_pricing_bands", "genai_pricing", "secret_findings", + "friction_digests", + "friction_digest_sessions", + "friction_patterns", "recall_entries", "recall_evidence", "recall_query_events", diff --git a/internal/db/friction_digest.go b/internal/db/friction_digest.go new file mode 100644 index 0000000000..c64690c8ff --- /dev/null +++ b/internal/db/friction_digest.go @@ -0,0 +1,367 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "log" + "time" + + "go.kenn.io/agentsview/internal/friction" +) + +// ErrFrictionDigestConflict reports that another builder wrote the digest +// row first (or the expected revision moved). The caller treats it as +// "already built", never as a failure to retry blindly. +var ErrFrictionDigestConflict = errors.New("friction digest was written concurrently") + +// FrictionSubject is one reviewable subject for a digest date. +type FrictionSubject struct { + SubjectID, SubjectKind, Machine, FilePath, Agent string + IsSubAgent bool + LastActivity time.Time + Dims FrictionSessionDims + RulesVersion string +} + +// FrictionDigest is one stored digest row. +type FrictionDigest struct { + Date, Timezone, RulesVersion string + BuiltAt time.Time + Revision, SessionsScanned int + SnapshotJSON, SummaryJSON, Markdown []byte + MarkdownSHA256, RunID string +} + +// FrictionDigestSubject records that a subject was reviewed into a date. +type FrictionDigestSubject struct{ SubjectID, Date, SubjectKind string } + +// FrictionPatternUpdate is one (fingerprint, subject) contribution to local +// recurrence counts for a digest date. +type FrictionPatternUpdate struct { + Fingerprint, Kind, Title, Date, SubjectID string + Ordinal *int + Occurrences int +} + +// FrictionDayBounds returns [local midnight of date, next local midnight). +// AddDate keeps DST days at their real 23 or 25 hours. +func FrictionDayBounds(date string, loc *time.Location) (time.Time, time.Time, error) { + day, err := time.ParseInLocation("2006-01-02", date, loc) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("parsing friction date %q: %w", date, err) + } + return day, day.AddDate(0, 0, 1), nil +} + +func frictionMarkdownSHA(md []byte) string { + sum := sha256.Sum256(md) + return hex.EncodeToString(sum[:]) +} + +// FrictionSubjectsForDate returns sessions whose last activity falls on the +// local date and that are not yet in any digest; with includeDigested it +// also returns the subjects already recorded for date (rebuild keeps +// membership, spec §8.5). Trashed sessions are never subjects. +func (db *DB) FrictionSubjectsForDate( + ctx context.Context, date string, loc *time.Location, includeDigested bool, +) ([]FrictionSubject, error) { + from, to, err := FrictionDayBounds(date, loc) + if err != nil { + return nil, err + } + labels, err := db.GetMachineLabels(ctx) + if err != nil { + return nil, err + } + // Stored RFC3339 timestamps can retain their source offset. Widen the + // text range by a day, then compare instants after parsing each row. + lo := from.Add(-24 * time.Hour).UTC().Format(time.RFC3339) + hi := to.Add(24 * time.Hour).UTC().Format(time.RFC3339) + rows, err := db.getReader().QueryContext(ctx, ` + SELECT s.id, s.machine, COALESCE(s.file_path, ''), s.agent, + CASE WHEN s.parent_session_id IS NOT NULL + AND s.relationship_type = 'subagent' THEN 1 ELSE 0 END, + COALESCE(s.ended_at, s.started_at, ''), s.friction_rules_version, + COALESCE(d.seat, ''), COALESCE(d.persona, ''), COALESCE(d.channel, ''), + COALESCE(d.dims_source, ''), COALESCE(d.review_excluded, 0), + COALESCE(ds.date, '') + FROM sessions s + LEFT JOIN friction_session_dims d ON d.session_id = s.id + LEFT JOIN friction_digest_sessions ds ON ds.subject_id = s.id + WHERE s.deleted_at IS NULL + AND COALESCE(d.review_excluded, 0) = 0 + AND ((ds.subject_id IS NULL + AND COALESCE(s.ended_at, s.started_at) >= ? + AND COALESCE(s.ended_at, s.started_at) < ?) + OR (? = 1 AND ds.date = ?))`, + lo, hi, boolInt(includeDigested), date, + ) + if err != nil { + return nil, fmt.Errorf("listing friction subjects: %w", err) + } + defer rows.Close() + out := []FrictionSubject{} + for rows.Next() { + var ( + s FrictionSubject + machine, last string + sub, excluded int + digestDate string + ) + if err := rows.Scan(&s.SubjectID, &machine, &s.FilePath, &s.Agent, &sub, + &last, &s.RulesVersion, &s.Dims.Seat, &s.Dims.Persona, &s.Dims.Channel, + &s.Dims.DimsSource, &excluded, &digestDate); err != nil { + return nil, fmt.Errorf("scanning friction subject: %w", err) + } + if last != "" { + ts, err := parseTimestamp(last) + if err != nil { + log.Printf("friction: session %s has unparseable activity time %q", s.SubjectID, last) + continue + } + s.LastActivity = ts.UTC() + } + if digestDate == "" && (s.LastActivity.IsZero() || + s.LastActivity.Before(from) || !s.LastActivity.Before(to)) { + continue + } + s.SubjectKind = friction.SubjectSession + s.IsSubAgent = sub != 0 + s.Dims.SessionID = s.SubjectID + s.Dims.ReviewExcluded = excluded != 0 + s.Machine = machine + if label := labels[machine]; label != "" { + s.Machine = label + } + out = append(out, s) + } + return out, rows.Err() +} + +// FrictionFindingsForSubjects returns stored findings for the subjects, +// ordered by (session_id, seq). Callers impose subject order. +func (db *DB) FrictionFindingsForSubjects( + ctx context.Context, subjectIDs []string, +) ([]FrictionFinding, error) { + out := []FrictionFinding{} + err := queryChunked(subjectIDs, func(chunk []string) error { + ph, args := inPlaceholders(chunk) + rows, err := db.getReader().QueryContext(ctx, ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings + WHERE session_id IN `+ph+` + ORDER BY session_id, seq, id`, args...) + if err != nil { + return fmt.Errorf("loading friction findings: %w", err) + } + defer rows.Close() + for rows.Next() { + var ( + f FrictionFinding + ord, call sql.NullInt64 + occurred sql.NullString + ) + if err := rows.Scan(&f.SessionID, &f.Kind, &f.Detector, &ord, &call, + &f.ToolName, &f.Label, &f.Text, &f.Evidence, &f.Title, &f.Fingerprint, + &occurred, &f.Seq, &f.RulesVersion); err != nil { + return fmt.Errorf("scanning friction finding: %w", err) + } + if ord.Valid { + f.MessageOrdinal = new(int(ord.Int64)) + } + if call.Valid { + f.CallIndex = new(int(call.Int64)) + } + if occurred.Valid && occurred.String != "" { + ts, err := parseTimestamp(occurred.String) + if err == nil { + f.OccurredAt = new(ts.UTC()) + } + } + out = append(out, f) + } + return rows.Err() + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SaveFrictionDigest writes a digest, its membership and pattern counts in +// one transaction (spec §8.3 step 9). +func (db *DB) SaveFrictionDigest( + ctx context.Context, d FrictionDigest, + subjects []FrictionDigestSubject, patterns []FrictionPatternUpdate, +) error { + db.mu.Lock() + defer db.mu.Unlock() + tx, err := db.getWriter().BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("beginning friction digest tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + builtAt := d.BuiltAt.UTC().Format(time.RFC3339Nano) + var res sql.Result + if d.Revision <= 1 { + res, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digests (date, timezone, rules_version, built_at, + revision, sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(date) DO NOTHING`, + d.Date, d.Timezone, d.RulesVersion, builtAt, d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID) + } else { + res, err = tx.ExecContext(ctx, ` + UPDATE friction_digests SET timezone = ?, rules_version = ?, + built_at = ?, revision = ?, sessions_scanned = ?, + snapshot_json = ?, summary_json = ?, markdown = ?, + markdown_sha256 = ?, run_id = ? + WHERE date = ? AND revision = ?`, + d.Timezone, d.RulesVersion, builtAt, d.Revision, d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID, d.Date, d.Revision-1) + } + if err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } else if n == 0 { + return ErrFrictionDigestConflict + } + for _, s := range subjects { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_digest_sessions (subject_id, date, subject_kind) + VALUES (?, ?, ?) ON CONFLICT(subject_id) DO NOTHING`, + s.SubjectID, s.Date, s.SubjectKind); err != nil { + return fmt.Errorf("recording friction subject %s: %w", s.SubjectID, err) + } + } + for _, p := range patterns { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_patterns (fingerprint, kind, title, + first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id, last_ordinal) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET + kind = excluded.kind, + title = excluded.title, + occurrence_count = friction_patterns.occurrence_count + excluded.occurrence_count, + session_count = friction_patterns.session_count + 1, + first_seen_date = MIN(friction_patterns.first_seen_date, excluded.first_seen_date), + last_subject_id = CASE WHEN excluded.last_seen_date >= friction_patterns.last_seen_date + THEN excluded.last_subject_id ELSE friction_patterns.last_subject_id END, + last_ordinal = CASE WHEN excluded.last_seen_date >= friction_patterns.last_seen_date + THEN excluded.last_ordinal ELSE friction_patterns.last_ordinal END, + last_seen_date = MAX(friction_patterns.last_seen_date, excluded.last_seen_date)`, + p.Fingerprint, p.Kind, p.Title, p.Date, p.Date, p.Occurrences, + p.SubjectID, p.Ordinal); err != nil { + return fmt.Errorf("updating friction pattern %s: %w", p.Fingerprint, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing friction digest %s: %w", d.Date, err) + } + return nil +} + +// GetFrictionDigest returns the digest for date, or nil when none exists. +func (db *DB) GetFrictionDigest(ctx context.Context, date string) (*FrictionDigest, error) { + var ( + d FrictionDigest + builtAt string + snapshot, summary, md string + ) + err := db.getReader().QueryRowContext(ctx, ` + SELECT date, timezone, rules_version, built_at, revision, + sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id + FROM friction_digests WHERE date = ?`, date, + ).Scan(&d.Date, &d.Timezone, &d.RulesVersion, &builtAt, &d.Revision, + &d.SessionsScanned, &snapshot, &summary, &md, &d.MarkdownSHA256, &d.RunID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading friction digest %s: %w", date, err) + } + ts, err := parseTimestamp(builtAt) + if err != nil { + return nil, fmt.Errorf("parsing friction digest built_at %q: %w", builtAt, err) + } + d.BuiltAt = ts.UTC() + d.SnapshotJSON, d.SummaryJSON, d.Markdown = []byte(snapshot), []byte(summary), []byte(md) + return &d, nil +} + +// LatestFrictionDigestDate returns the newest digest date, or "". +func (db *DB) LatestFrictionDigestDate(ctx context.Context) (string, error) { + var date string + if err := db.getReader().QueryRowContext(ctx, + `SELECT COALESCE(MAX(date), '') FROM friction_digests`, + ).Scan(&date); err != nil { + return "", fmt.Errorf("reading latest friction digest date: %w", err) + } + return date, nil +} + +// EarliestSessionDate returns the local date of the earliest session +// activity (ended_at, else started_at), or "" for an empty archive. +func (db *DB) EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) { + var raw string + err := db.getReader().QueryRowContext(ctx, ` + SELECT COALESCE(COALESCE(ended_at, started_at), '') + FROM sessions + WHERE deleted_at IS NULL AND COALESCE(ended_at, started_at) IS NOT NULL + ORDER BY julianday(COALESCE(ended_at, started_at)) ASC + LIMIT 1`, + ).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("reading earliest session date: %w", err) + } + if raw == "" { + return "", nil + } + ts, err := parseTimestamp(raw) + if err != nil { + return "", fmt.Errorf("parsing earliest session time %q: %w", raw, err) + } + return ts.In(loc).Format("2006-01-02"), nil +} + +// UpdateFrictionDigestRender replaces the rendered forms of a digest and +// bumps its revision (spec §8.6). revision is the new revision. +func (db *DB) UpdateFrictionDigestRender( + ctx context.Context, date string, markdown, summaryJSON []byte, revision int, +) error { + db.mu.Lock() + defer db.mu.Unlock() + res, err := db.getWriter().Exec(ctx, ` + UPDATE friction_digests + SET markdown = ?, summary_json = ?, markdown_sha256 = ?, revision = ? + WHERE date = ? AND revision = ?`, + string(markdown), string(summaryJSON), frictionMarkdownSHA(markdown), + revision, date, revision-1) + if err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } else if n == 0 { + return ErrFrictionDigestConflict + } + return nil +} diff --git a/internal/db/friction_digest_test.go b/internal/db/friction_digest_test.go new file mode 100644 index 0000000000..ef1fe43545 --- /dev/null +++ b/internal/db/friction_digest_test.go @@ -0,0 +1,350 @@ +package db + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/friction" +) + +func seedFrictionSession( + t *testing.T, d *DB, id, started, ended string, mutate func(*Session), +) { + t.Helper() + s := Session{ID: id, Project: "proj", Machine: "machine-a", Agent: "claude", MessageCount: 1} + if started != "" { + s.StartedAt = &started + } + if ended != "" { + s.EndedAt = &ended + } + if mutate != nil { + mutate(&s) + } + require.NoError(t, d.UpsertSession(t.Context(), s)) + require.NoError(t, d.ReplaceSessionFriction(t.Context(), id, nil, nil, "friction-v1", "h-"+id)) +} + +func subjectIDs(subjects []FrictionSubject) []string { + out := make([]string, 0, len(subjects)) + for _, s := range subjects { + out = append(out, s.SubjectID) + } + return out +} + +func TestFrictionSubjectsForDate(t *testing.T) { + ny, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + tests := []struct { + name string + loc *time.Location + date string + seed func(t *testing.T, d *DB) + includeDigested bool + want []string + }{ + { + name: "ended_at decides the date", + loc: time.UTC, date: "2026-09-15", + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "a", "2026-09-14T23:00:00Z", "2026-09-15T00:30:00Z", nil) + seedFrictionSession(t, d, "b", "2026-09-15T10:00:00Z", "2026-09-16T00:00:00Z", nil) + }, + want: []string{"a"}, + }, + { + name: "started_at_only", + loc: time.UTC, date: "2026-09-15", + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "open", "2026-09-15T08:00:00Z", "", nil) + }, + want: []string{"open"}, + }, + { + name: "timestamp with offset crosses UTC date", + loc: time.UTC, date: "2026-09-15", + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "offset", "", "2026-09-16T08:30:00+09:00", nil) + }, + want: []string{"offset"}, + }, + { + name: "dst_short_day_new_york", + loc: ny, date: "2026-03-08", + seed: func(t *testing.T, d *DB) { + // 23:30 EDT on 2026-03-08 is 03:30Z on 2026-03-09. + seedFrictionSession(t, d, "late", "", "2026-03-09T03:30:00Z", nil) + // 00:30 EDT on 2026-03-09 is 04:30Z: next local date. + seedFrictionSession(t, d, "next", "", "2026-03-09T04:30:00Z", nil) + }, + want: []string{"late"}, + }, + { + name: "digested subjects excluded", + loc: time.UTC, date: "2026-09-15", + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "done", "", "2026-09-15T01:00:00Z", nil) + seedFrictionSession(t, d, "fresh", "", "2026-09-15T02:00:00Z", nil) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), + []FrictionDigestSubject{{SubjectID: "done", Date: "2026-09-15", SubjectKind: friction.SubjectSession}}, nil)) + }, + want: []string{"fresh"}, + }, + { + name: "rebuild keeps membership even after activity moved", + loc: time.UTC, date: "2026-09-15", includeDigested: true, + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "moved", "", "2026-09-17T01:00:00Z", nil) + seedFrictionSession(t, d, "fresh", "", "2026-09-15T02:00:00Z", nil) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), + []FrictionDigestSubject{{SubjectID: "moved", Date: "2026-09-15", SubjectKind: friction.SubjectSession}}, nil)) + }, + want: []string{"fresh", "moved"}, + }, + { + name: "trashed sessions excluded", + loc: time.UTC, date: "2026-09-15", + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "gone", "", "2026-09-15T01:00:00Z", nil) + require.NoError(t, d.SoftDeleteSession(t.Context(), "gone")) + }, + want: []string{}, + }, + { + name: "review excluded sessions excluded", + loc: time.UTC, date: "2026-09-15", + seed: func(t *testing.T, d *DB) { + seedFrictionSession(t, d, "excluded", "", "2026-09-15T01:00:00Z", nil) + require.NoError(t, d.ReplaceSessionFriction(t.Context(), "excluded", nil, + &FrictionSessionDims{SessionID: "excluded", ReviewExcluded: true}, "friction-v1", "h2")) + }, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := testDB(t) + tt.seed(t, d) + got, err := d.FrictionSubjectsForDate(t.Context(), tt.date, tt.loc, tt.includeDigested) + require.NoError(t, err) + assert.ElementsMatch(t, tt.want, subjectIDs(got)) + }) + } +} + +func TestFrictionSubjectsCarryDimsAndRole(t *testing.T) { + d := testDB(t) + parent := "root" + seedFrictionSession(t, d, "root", "", "2026-09-15T01:00:00Z", nil) + seedFrictionSession(t, d, "child", "", "2026-09-15T02:00:00Z", func(s *Session) { + s.ParentSessionID = &parent + s.RelationshipType = "subagent" + fp := "archive/profiles/seat-02/projects/x.jsonl" + s.FilePath = &fp + }) + require.NoError(t, d.ReplaceSessionFriction(t.Context(), "child", nil, + &FrictionSessionDims{SessionID: "child", Seat: "seat-02", Persona: "helper", Channel: "general", DimsSource: "nanoclaw+seat_pattern"}, + "friction-v1", "h2")) + got, err := d.FrictionSubjectsForDate(t.Context(), "2026-09-15", time.UTC, false) + require.NoError(t, err) + require.Len(t, got, 2) + byID := map[string]FrictionSubject{got[0].SubjectID: got[0], got[1].SubjectID: got[1]} + child := byID["child"] + assert.True(t, child.IsSubAgent) + assert.Equal(t, friction.SubjectSession, child.SubjectKind) + assert.Equal(t, "seat-02", child.Dims.Seat) + assert.Equal(t, "helper", child.Dims.Persona) + assert.Equal(t, "general", child.Dims.Channel) + assert.Equal(t, "friction-v1", child.RulesVersion) + assert.Equal(t, "machine-a", child.Machine) + assert.Equal(t, time.Date(2026, 9, 15, 2, 0, 0, 0, time.UTC), child.LastActivity) + assert.False(t, byID["root"].IsSubAgent) +} + +func testDigest(date string, revision int) FrictionDigest { + md := []byte("# Friction Log — " + date + "\n") + sum := sha256.Sum256(md) + return FrictionDigest{ + Date: date, Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2026, 9, 22, 1, 0, 0, 0, time.UTC), Revision: revision, + SnapshotJSON: []byte(`{"date":"` + date + `"}`), SummaryJSON: []byte("{}\n"), + Markdown: md, MarkdownSHA256: hex.EncodeToString(sum[:]), RunID: "run-" + date, + } +} + +func TestSaveFrictionDigest(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T, d *DB) + }{ + {name: "round_trip", run: func(t *testing.T, d *DB) { + want := testDigest("2026-09-15", 1) + want.SessionsScanned = 3 + require.NoError(t, d.SaveFrictionDigest(t.Context(), want, nil, nil)) + got, err := d.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, want, *got) + }}, + {name: "missing_date_is_nil", run: func(t *testing.T, d *DB) { + got, err := d.GetFrictionDigest(t.Context(), "2026-01-01") + require.NoError(t, err) + assert.Nil(t, got) + }}, + {name: "second_insert_conflicts", run: func(t *testing.T, d *DB) { + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, + []FrictionPatternUpdate{{Fingerprint: "fl1:a", Kind: "error", Title: "t", Date: "2026-09-15", SubjectID: "s1", Occurrences: 2}})) + err := d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, + []FrictionPatternUpdate{{Fingerprint: "fl1:a", Kind: "error", Title: "t", Date: "2026-09-15", SubjectID: "s2", Occurrences: 5}}) + require.ErrorIs(t, err, ErrFrictionDigestConflict) + assert.Equal(t, 2, frictionPatternCount(t, d, "fl1:a"), "rolled-back save must not count") + }}, + {name: "revision_update_requires_previous", run: func(t *testing.T, d *DB) { + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, nil)) + require.ErrorIs(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 3), nil, nil), ErrFrictionDigestConflict) + second := testDigest("2026-09-15", 2) + second.SessionsScanned = 9 + require.NoError(t, d.SaveFrictionDigest(t.Context(), second, nil, nil)) + got, err := d.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + assert.Equal(t, 2, got.Revision) + assert.Equal(t, 9, got.SessionsScanned) + }}, + {name: "subjects_are_recorded_once", run: func(t *testing.T, d *DB) { + subj := []FrictionDigestSubject{{SubjectID: "s1", Date: "2026-09-15", SubjectKind: friction.SubjectSession}} + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), subj, nil)) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 2), subj, nil)) + assert.Equal(t, 1, countRows(t, d, "friction_digest_sessions")) + }}, + {name: "patterns_accumulate_across_dates", run: func(t *testing.T, d *DB) { + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, []FrictionPatternUpdate{ + {Fingerprint: "fl1:a", Kind: "correction", Title: "t", Date: "2026-09-15", SubjectID: "s1", Ordinal: Ptr(4), Occurrences: 2}, + {Fingerprint: "fl1:a", Kind: "correction", Title: "t", Date: "2026-09-15", SubjectID: "s2", Ordinal: Ptr(7), Occurrences: 1}, + })) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-14", 1), nil, []FrictionPatternUpdate{ + {Fingerprint: "fl1:a", Kind: "correction", Title: "t", Date: "2026-09-14", SubjectID: "s0", Ordinal: Ptr(1), Occurrences: 1}, + })) + row := readFrictionPattern(t, d, "fl1:a") + assert.Equal(t, frictionPatternRow{ + FirstSeen: "2026-09-14", LastSeen: "2026-09-15", Occurrences: 4, + Sessions: 3, LastSubject: "s2", LastOrdinal: Ptr(7), + }, row) + }}, + {name: "latest_date", run: func(t *testing.T, d *DB) { + latest, err := d.LatestFrictionDigestDate(t.Context()) + require.NoError(t, err) + assert.Empty(t, latest) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-14", 1), nil, nil)) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-16", 1), nil, nil)) + latest, err = d.LatestFrictionDigestDate(t.Context()) + require.NoError(t, err) + assert.Equal(t, "2026-09-16", latest) + }}, + {name: "update_render_bumps_revision", run: func(t *testing.T, d *DB) { + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, nil)) + require.NoError(t, d.UpdateFrictionDigestRender(t.Context(), "2026-09-15", []byte("new md\n"), []byte("{\"x\":1}\n"), 2)) + got, err := d.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + sum := sha256.Sum256([]byte("new md\n")) + assert.Equal(t, 2, got.Revision) + assert.Equal(t, "new md\n", string(got.Markdown)) + assert.Equal(t, hex.EncodeToString(sum[:]), got.MarkdownSHA256) + require.ErrorIs(t, d.UpdateFrictionDigestRender(t.Context(), "2026-09-15", []byte("x"), []byte("{}"), 2), ErrFrictionDigestConflict) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { tt.run(t, testDB(t)) }) + } +} + +func TestEarliestSessionDate(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + require.NoError(t, err) + d := testDB(t) + got, err := d.EarliestSessionDate(t.Context(), time.UTC) + require.NoError(t, err) + assert.Empty(t, got) + seedFrictionSession(t, d, "a", "2026-09-10T20:00:00Z", "2026-09-10T20:30:00Z", nil) + seedFrictionSession(t, d, "b", "2026-09-12T01:00:00Z", "", nil) + got, err = d.EarliestSessionDate(t.Context(), time.UTC) + require.NoError(t, err) + assert.Equal(t, "2026-09-10", got) + got, err = d.EarliestSessionDate(t.Context(), tokyo) + require.NoError(t, err) + assert.Equal(t, "2026-09-11", got, "20:30Z is 05:30 next day in Tokyo") +} + +func TestEarliestSessionDateUsesInstantAcrossOffsets(t *testing.T) { + d := testDB(t) + seedFrictionSession(t, d, "earlier", "", "2026-09-16T08:30:00+09:00", nil) + seedFrictionSession(t, d, "later", "", "2026-09-16T00:00:00Z", nil) + got, err := d.EarliestSessionDate(t.Context(), time.UTC) + require.NoError(t, err) + assert.Equal(t, "2026-09-15", got) +} + +func TestFrictionFindingsForSubjects(t *testing.T) { + d := testDB(t) + seedFrictionSession(t, d, "s1", "", "2026-09-15T01:00:00Z", nil) + seedFrictionSession(t, d, "s2", "", "2026-09-15T02:00:00Z", nil) + occurred := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + mk := func(sid string, seq int) FrictionFinding { + return FrictionFinding{ + SessionID: sid, Kind: "workaround", Detector: "workaround", MessageOrdinal: Ptr(seq), + Label: "for now", Text: "for now", Title: "[friction/workaround] for now: for now", + Fingerprint: "fl1:x", OccurredAt: &occurred, Seq: seq, RulesVersion: "friction-v1", + } + } + require.NoError(t, d.ReplaceSessionFriction(t.Context(), "s1", []FrictionFinding{mk("s1", 1), mk("s1", 0)}, nil, "friction-v1", "h1")) + require.NoError(t, d.ReplaceSessionFriction(t.Context(), "s2", []FrictionFinding{mk("s2", 0)}, nil, "friction-v1", "h2")) + got, err := d.FrictionFindingsForSubjects(t.Context(), []string{"s2", "s1", "missing"}) + require.NoError(t, err) + require.Len(t, got, 3) + assert.Equal(t, []int{0, 1}, []int{got[0].Seq, got[1].Seq}, "s1 rows ordered by seq") + assert.Equal(t, "s1", got[0].SessionID) + assert.Equal(t, occurred, *got[0].OccurredAt) + empty, err := d.FrictionFindingsForSubjects(t.Context(), nil) + require.NoError(t, err) + assert.Empty(t, empty) +} + +type frictionPatternRow struct { + FirstSeen, LastSeen string + Occurrences int + Sessions int + LastSubject string + LastOrdinal *int +} + +func readFrictionPattern(t *testing.T, d *DB, fp string) frictionPatternRow { + t.Helper() + var r frictionPatternRow + var ord *int64 + require.NoError(t, d.getReader().QueryRowContext(t.Context(), ` + SELECT first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id, last_ordinal + FROM friction_patterns WHERE fingerprint = ?`, fp, + ).Scan(&r.FirstSeen, &r.LastSeen, &r.Occurrences, &r.Sessions, &r.LastSubject, &ord)) + if ord != nil { + r.LastOrdinal = Ptr(int(*ord)) + } + return r +} + +func frictionPatternCount(t *testing.T, d *DB, fp string) int { + t.Helper() + return readFrictionPattern(t, d, fp).Occurrences +} + +func countRows(t *testing.T, d *DB, table string) int { + t.Helper() + var n int + require.NoError(t, d.getReader().QueryRowContext(t.Context(), "SELECT count(*) FROM "+table).Scan(&n)) + return n +} diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 31c3b91584..49510f3e7c 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -1335,6 +1335,48 @@ CREATE TABLE IF NOT EXISTS friction_session_dims ( review_excluded INTEGER NOT NULL DEFAULT 0 ); +-- Friction Log review state is built independently by each store. +-- One digest is kept for each completed local date. +CREATE TABLE IF NOT EXISTS friction_digests ( + date TEXT PRIMARY KEY, + timezone TEXT NOT NULL, + rules_version TEXT NOT NULL, + built_at TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + sessions_scanned INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + summary_json TEXT NOT NULL, + markdown TEXT NOT NULL, + markdown_sha256 TEXT NOT NULL, + run_id TEXT NOT NULL +); + +-- A subject enters one digest only, even if the session resumes later. +CREATE TABLE IF NOT EXISTS friction_digest_sessions ( + subject_id TEXT PRIMARY KEY, + date TEXT NOT NULL, + subject_kind TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_friction_digest_sessions_date + ON friction_digest_sessions(date); + +-- Local recurrence per fingerprint across digests. +CREATE TABLE IF NOT EXISTS friction_patterns ( + fingerprint TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + first_seen_date TEXT NOT NULL, + last_seen_date TEXT NOT NULL, + occurrence_count INTEGER NOT NULL, + session_count INTEGER NOT NULL, + last_subject_id TEXT NOT NULL, + last_ordinal INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_friction_patterns_last_seen + ON friction_patterns(last_seen_date); + -- Durable normalized-artifact import claims. Artifact kinds evolve -- independently, so each claim retains a separate version gate. CREATE TABLE IF NOT EXISTS artifact_import_queue ( From d618d161ef3e9ba8aa5f6aa365172aff1af952d4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:13:41 +0000 Subject: [PATCH 42/62] feat(db): friction usage and archive spend from native usage --- internal/db/friction_usage.go | 151 ++++++++++++++++++++++++++++ internal/db/friction_usage_test.go | 154 +++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 internal/db/friction_usage.go create mode 100644 internal/db/friction_usage_test.go diff --git a/internal/db/friction_usage.go b/internal/db/friction_usage.go new file mode 100644 index 0000000000..448f08165c --- /dev/null +++ b/internal/db/friction_usage.go @@ -0,0 +1,151 @@ +package db + +import ( + "context" + "fmt" + "time" + + "go.kenn.io/agentsview/internal/friction" +) + +// FrictionArchiveWindow is jilog's ArchiveSpend::window: (date−7, date−1). +func FrictionArchiveWindow(date string) (string, string, error) { + day, err := time.Parse("2006-01-02", date) + if err != nil { + return "", "", fmt.Errorf("parsing friction date %q: %w", date, err) + } + return day.AddDate(0, 0, -7).Format("2006-01-02"), day.AddDate(0, 0, -1).Format("2006-01-02"), nil +} + +func newFrictionPeriod() friction.PeriodSpend { + return friction.PeriodSpend{ + Total: friction.USDFromMicros(0), + Agents: map[string]friction.USD{}, + Models: map[string]friction.USD{}, + } +} + +func addFrictionPeriodRow(p *friction.PeriodSpend, row DailyUsageEntry) { + p.Days++ + p.Total = p.Total.Add(friction.USDFromMicros(row.TotalCost.Microdollars)) + for _, a := range row.AgentBreakdowns { + p.Agents[a.Agent] = addFrictionUSD(p.Agents, a.Agent, a.Cost.Microdollars) + } + for _, m := range row.ModelBreakdowns { + p.Models[m.ModelName] = addFrictionUSD(p.Models, m.ModelName, m.Cost.Microdollars) + } +} + +func addFrictionUSD(m map[string]friction.USD, key string, micros int64) friction.USD { + v := friction.USDFromMicros(micros) + if cur, ok := m[key]; ok { + return cur.Add(v) + } + return v +} + +// FrictionArchiveSpendFromDaily ports ArchiveSpend::summarize +// (archive_spend.rs:93-109): rows in [from, to] form the week, the row +// dated `to` is yesterday, and a window with no rows yields nil. +func FrictionArchiveSpendFromDaily( + res DailyUsageResult, from, to, timezone string, +) *friction.ArchiveSpend { + week := newFrictionPeriod() + var yesterday *friction.PeriodSpend + for _, row := range res.Daily { + if row.Date < from || row.Date > to { + continue + } + addFrictionPeriodRow(&week, row) + if row.Date == to { + y := newFrictionPeriod() + addFrictionPeriodRow(&y, row) + yesterday = &y + } + } + if week.Days == 0 { + return nil + } + return &friction.ArchiveSpend{ + Yesterday: yesterday, Week: week, + WeekFrom: from, WeekTo: to, Timezone: timezone, + } +} + +// FrictionUsageFromSessionUsage converts one session's usage into the +// review's stats form. The bool is false when the session carries no +// token or cost data (jilog load_stats → None). +func FrictionUsageFromSessionUsage(u *SessionUsage) (friction.SessionUsage, bool) { + if u == nil || (!u.HasTokenData && !u.HasCost) { + return friction.SessionUsage{}, false + } + out := friction.SessionUsage{SubjectID: u.SessionID, ModelCosts: map[string]friction.USD{}} + for _, row := range u.Breakdown { + out.InputTokens += uint64(row.InputTokens + row.CacheReadInputTokens + row.CacheCreationInputTokens) + out.OutputTokens += uint64(row.OutputTokens) + if row.HasCost { + out.ModelCosts[row.Model] = addFrictionUSD(out.ModelCosts, row.Model, row.Cost.Microdollars) + } + } + if u.HasCost { + c := friction.USDFromMicros(u.Cost.Microdollars) + out.CostUSD = &c + } + return out, true +} + +// frictionUsageSource is the part of db.Store both primary backends share +// for per-session usage. +type frictionUsageSource interface { + GetSessionUsage(ctx context.Context, sessionID string, includeBreakdown bool) (*SessionUsage, error) + GetDailyUsage(ctx context.Context, f UsageFilter) (DailyUsageResult, error) +} + +// FrictionUsageForSessionsFrom is the shared implementation of +// FrictionUsageForSessions for any backend with GetSessionUsage. +func FrictionUsageForSessionsFrom( + ctx context.Context, src frictionUsageSource, sessionIDs []string, +) (map[string]friction.SessionUsage, error) { + out := make(map[string]friction.SessionUsage, len(sessionIDs)) + for _, id := range sessionIDs { + u, err := src.GetSessionUsage(ctx, id, true) + if err != nil { + return nil, fmt.Errorf("friction usage for %s: %w", id, err) + } + if fu, ok := FrictionUsageFromSessionUsage(u); ok { + fu.SubjectID = id + out[id] = fu + } + } + return out, nil +} + +// FrictionArchiveSpendFrom is the shared implementation of +// FrictionArchiveSpend: native daily usage in the review zone replaces +// jilog's `agentsview usage daily` shell-out (spec §4.1). +func FrictionArchiveSpendFrom( + ctx context.Context, src frictionUsageSource, from, to string, loc *time.Location, +) (*friction.ArchiveSpend, error) { + res, err := src.GetDailyUsage(ctx, UsageFilter{ + From: from, To: to, Timezone: loc.String(), + Breakdowns: true, SkipSessionCounts: true, + }) + if err != nil { + return nil, fmt.Errorf("friction archive spend %s..%s: %w", from, to, err) + } + return FrictionArchiveSpendFromDaily(res, from, to, loc.String()), nil +} + +// FrictionUsageForSessions implements review.Store. +func (db *DB) FrictionUsageForSessions( + ctx context.Context, sessionIDs []string, +) (map[string]friction.SessionUsage, error) { + return FrictionUsageForSessionsFrom(ctx, db, sessionIDs) +} + +// FrictionArchiveSpend implements review.Store. +func (db *DB) FrictionArchiveSpend( + ctx context.Context, from, to string, loc *time.Location, +) (*friction.ArchiveSpend, error) { + return FrictionArchiveSpendFrom(ctx, db, from, to, loc) +} diff --git a/internal/db/friction_usage_test.go b/internal/db/friction_usage_test.go new file mode 100644 index 0000000000..0c3a19048f --- /dev/null +++ b/internal/db/friction_usage_test.go @@ -0,0 +1,154 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/money" +) + +// Port of jilog archive_spend.rs:270 parses_daily_rows_and_summarizes_yesterday_and_week, +// fed as the native DailyUsageResult instead of `usage daily --json`. +func TestFrictionArchiveSpendFromDaily(t *testing.T) { + m := func(micros int64) money.Money { return money.Money{Microdollars: micros} } + daily := DailyUsageResult{Daily: []DailyUsageEntry{ + { + Date: "2026-09-14", TotalCost: m(1500000), + ModelBreakdowns: []ModelBreakdown{{ModelName: "gpt-5.6-sol", Cost: m(1500000)}}, + AgentBreakdowns: []AgentBreakdown{{Agent: "codex", Cost: m(1500000)}}, + }, + { + Date: "2026-09-15", TotalCost: m(332138392), + ModelBreakdowns: []ModelBreakdown{ + {ModelName: "gpt-6-astra", Cost: m(125784488)}, + {ModelName: "claude-opus-5", Cost: m(107584332)}, + {ModelName: "gpt-5.6-sol", Cost: m(98769572)}, + }, + AgentBreakdowns: []AgentBreakdown{ + {Agent: "codex", Cost: m(224554060)}, + {Agent: "claude", Cost: m(104943456)}, + {Agent: "cowork", Cost: m(2640876)}, + }, + }, + {Date: "2026-09-16", TotalCost: m(999)}, + }} + + t.Run("parses_daily_rows_and_summarizes_yesterday_and_week", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2026-09-16") + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-09", "2026-09-15"}, []string{from, to}) + spend := FrictionArchiveSpendFromDaily(daily, from, to, "Asia/Dhaka") + require.NotNil(t, spend) + assert.Equal(t, "Asia/Dhaka", spend.Timezone) + require.NotNil(t, spend.Yesterday, "2026-09-15 present") + assert.Equal(t, "332.138392", spend.Yesterday.Total.String()) + assert.Equal(t, 2, spend.Week.Days, "the 16th is today and excluded") + assert.Equal(t, "333.638392", spend.Week.Total.String()) + assert.Equal(t, "226.054060", spend.Week.Agents["codex"].String()) + assert.Equal(t, "125.784488", spend.Week.Models["gpt-6-astra"].String()) + }) + t.Run("yesterday_absent", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2026-09-18") + require.NoError(t, err) + spend := FrictionArchiveSpendFromDaily(daily, from, to, "UTC") + require.NotNil(t, spend) + assert.Nil(t, spend.Yesterday) + assert.Equal(t, 3, spend.Week.Days) + }) + t.Run("nothing_in_window", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2027-01-01") + require.NoError(t, err) + assert.Nil(t, FrictionArchiveSpendFromDaily(daily, from, to, "UTC")) + assert.Nil(t, FrictionArchiveSpendFromDaily(DailyUsageResult{}, from, to, "UTC")) + }) + t.Run("window_edges", func(t *testing.T) { + from, to, err := FrictionArchiveWindow("2026-09-16") + require.NoError(t, err) + edge := DailyUsageResult{Daily: []DailyUsageEntry{ + {Date: "2026-09-08", TotalCost: m(1000000)}, + {Date: "2026-09-09", TotalCost: m(1000000)}, + }} + spend := FrictionArchiveSpendFromDaily(edge, from, to, "UTC") + require.NotNil(t, spend) + assert.Equal(t, 1, spend.Week.Days, "2026-09-08 is outside the 7-day window ending 2026-09-15") + }) +} + +func TestFrictionUsageFromSessionUsage(t *testing.T) { + tests := []struct { + name string + in *SessionUsage + ok bool + input uint64 + output uint64 + cost string + models map[string]string + }{ + {name: "nil", in: nil}, + {name: "no token or cost data", in: &SessionUsage{}}, + { + name: "tokens only", + in: &SessionUsage{HasTokenData: true, Breakdown: []SessionUsageBreakdownEntry{ + {Model: "unpriced", InputTokens: 100, CacheReadInputTokens: 20, CacheCreationInputTokens: 5, OutputTokens: 7}, + }}, + ok: true, input: 125, output: 7, models: map[string]string{}, + }, + { + name: "priced rows", + in: &SessionUsage{ + HasTokenData: true, HasCost: true, Cost: money.Money{Microdollars: 1500000}, + Breakdown: []SessionUsageBreakdownEntry{ + {Model: "m1", InputTokens: 10, OutputTokens: 1, Cost: money.Money{Microdollars: 1000000}, HasCost: true}, + {Model: "m1", InputTokens: 10, OutputTokens: 1, Cost: money.Money{Microdollars: 500000}, HasCost: true}, + {Model: "m2", InputTokens: 1, OutputTokens: 1}, + }, + }, + ok: true, input: 21, output: 3, cost: "1.500000", models: map[string]string{"m1": "1.500000"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := FrictionUsageFromSessionUsage(tt.in) + require.Equal(t, tt.ok, ok) + if !ok { + return + } + assert.Equal(t, tt.input, got.InputTokens) + assert.Equal(t, tt.output, got.OutputTokens) + if tt.cost == "" { + assert.Nil(t, got.CostUSD) + } else { + require.NotNil(t, got.CostUSD) + assert.Equal(t, tt.cost, got.CostUSD.String()) + } + models := map[string]string{} + for k, v := range got.ModelCosts { + models[k] = v.String() + } + assert.Equal(t, tt.models, models) + assert.Empty(t, got.Role, "role is the runner's decision") + }) + } +} + +func TestFrictionUsageForSessionsSQLite(t *testing.T) { + d := testDB(t) + seedFrictionSession(t, d, "priced", "", "2026-09-15T01:00:00Z", nil) + seedFrictionSession(t, d, "silent", "", "2026-09-15T02:00:00Z", nil) + cost := money.MustParseDollars("1.5") + require.NoError(t, d.ReplaceSessionUsageEvents(t.Context(), "priced", []UsageEvent{{ + Source: "shutdown", Model: "test-model", InputTokens: 800, OutputTokens: 40, + Cost: &cost, CostStatus: "exact", OccurredAt: "2026-09-15T01:00:00Z", DedupKey: "one", + }})) + got, err := d.FrictionUsageForSessions(t.Context(), []string{"priced", "silent", "missing"}) + require.NoError(t, err) + require.Contains(t, got, "priced") + assert.NotContains(t, got, "silent", "no usage data means no stats") + assert.Equal(t, uint64(800), got["priced"].InputTokens) + require.NotNil(t, got["priced"].CostUSD) + assert.Equal(t, "1.500000", got["priced"].CostUSD.String()) + assert.Equal(t, friction.USDFromMicros(1500000).String(), got["priced"].CostUSD.String()) +} From 03d009604ac3ccabe44b5c9d01a8db8f35d3853c Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:23:08 +0000 Subject: [PATCH 43/62] feat(postgres): friction review state with SQLite parity --- internal/db/friction_usage.go | 8 +- internal/postgres/backend.go | 1 + internal/postgres/friction_digest.go | 348 ++++++++++++++++++ .../postgres/friction_digest_pgtest_test.go | 148 ++++++++ internal/postgres/schema.go | 41 +++ internal/postgres/sessions.go | 2 + 6 files changed, 544 insertions(+), 4 deletions(-) create mode 100644 internal/postgres/friction_digest.go create mode 100644 internal/postgres/friction_digest_pgtest_test.go diff --git a/internal/db/friction_usage.go b/internal/db/friction_usage.go index 448f08165c..d37a0d15b7 100644 --- a/internal/db/friction_usage.go +++ b/internal/db/friction_usage.go @@ -94,9 +94,9 @@ func FrictionUsageFromSessionUsage(u *SessionUsage) (friction.SessionUsage, bool return out, true } -// frictionUsageSource is the part of db.Store both primary backends share +// FrictionUsageSource is the part of db.Store both primary backends share // for per-session usage. -type frictionUsageSource interface { +type FrictionUsageSource interface { GetSessionUsage(ctx context.Context, sessionID string, includeBreakdown bool) (*SessionUsage, error) GetDailyUsage(ctx context.Context, f UsageFilter) (DailyUsageResult, error) } @@ -104,7 +104,7 @@ type frictionUsageSource interface { // FrictionUsageForSessionsFrom is the shared implementation of // FrictionUsageForSessions for any backend with GetSessionUsage. func FrictionUsageForSessionsFrom( - ctx context.Context, src frictionUsageSource, sessionIDs []string, + ctx context.Context, src FrictionUsageSource, sessionIDs []string, ) (map[string]friction.SessionUsage, error) { out := make(map[string]friction.SessionUsage, len(sessionIDs)) for _, id := range sessionIDs { @@ -124,7 +124,7 @@ func FrictionUsageForSessionsFrom( // FrictionArchiveSpend: native daily usage in the review zone replaces // jilog's `agentsview usage daily` shell-out (spec §4.1). func FrictionArchiveSpendFrom( - ctx context.Context, src frictionUsageSource, from, to string, loc *time.Location, + ctx context.Context, src FrictionUsageSource, from, to string, loc *time.Location, ) (*friction.ArchiveSpend, error) { res, err := src.GetDailyUsage(ctx, UsageFilter{ From: from, To: to, Timezone: loc.String(), diff --git a/internal/postgres/backend.go b/internal/postgres/backend.go index fbc5c236a8..b491585fc1 100644 --- a/internal/postgres/backend.go +++ b/internal/postgres/backend.go @@ -144,6 +144,7 @@ func prepareServeStore(ctx context.Context, store *Store, schema string) error { "probing insight generation capability: %w", err, ) } + store.DetectFrictionAvailability(ctx) return nil } diff --git a/internal/postgres/friction_digest.go b/internal/postgres/friction_digest.go new file mode 100644 index 0000000000..9df4be0e10 --- /dev/null +++ b/internal/postgres/friction_digest.go @@ -0,0 +1,348 @@ +package postgres + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "log" + "sort" + "time" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +// FrictionAvailable reports whether startup proved this role can write the +// friction review tables. pg serve runs the friction-review job only then. +func (s *Store) FrictionAvailable() bool { return s.frictionAvailable.Load() } + +// DetectFrictionAvailability probes the review tables inside a rolled-back +// transaction. A missing table or a read-only role disables the job; any +// other failure is logged and also disables it, so startup never fails on +// an optional feature (spec §20). +func (s *Store) DetectFrictionAvailability(ctx context.Context) { + for _, table := range []string{"friction_digests", "friction_digest_sessions", "friction_patterns"} { + if !pgHasTable(ctx, s.pg, table) { + s.frictionAvailable.Store(false) + return + } + } + tx, err := s.pg.BeginTx(ctx, nil) + if err != nil { + log.Printf("friction: capability probe: %v", err) + s.frictionAvailable.Store(false) + return + } + defer func() { _ = tx.Rollback() }() + _, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digests (date, timezone, rules_version, built_at, + sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id) + VALUES ('0000-00-00', 'UTC', '', NOW(), 0, '', '', '', '', '') + ON CONFLICT (date) DO NOTHING`) + if err == nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digest_sessions (subject_id, date, subject_kind) + VALUES ('friction-capability-probe', '0000-00-00', 'diagnostic') + ON CONFLICT (subject_id) DO NOTHING`) + } + if err == nil { + _, err = tx.ExecContext(ctx, ` + INSERT INTO friction_patterns (fingerprint, kind, title, + first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id) + VALUES ('friction-capability-probe', 'pattern', '', + '0000-00-00', '0000-00-00', 0, 0, 'friction-capability-probe') + ON CONFLICT (fingerprint) DO NOTHING`) + } + if err == nil { + for _, query := range []string{ + "UPDATE friction_digests SET revision = revision WHERE date = '0000-00-00'", + "UPDATE friction_patterns SET title = title WHERE fingerprint = 'friction-capability-probe'", + } { + if _, err = tx.ExecContext(ctx, query); err != nil { + break + } + } + } + if err != nil { + if !IsReadOnlyError(err) { + log.Printf("friction: capability probe: %v", err) + } + s.frictionAvailable.Store(false) + return + } + s.frictionAvailable.Store(true) +} + +func (s *Store) FrictionSubjectsForDate( + ctx context.Context, date string, loc *time.Location, includeDigested bool, +) ([]db.FrictionSubject, error) { + from, to, err := db.FrictionDayBounds(date, loc) + if err != nil { + return nil, err + } + labels, err := s.GetMachineLabels(ctx) + if err != nil { + return nil, err + } + rows, err := s.pg.QueryContext(ctx, ` + SELECT s.id, s.machine, COALESCE(s.file_path, ''), s.agent, + (s.parent_session_id IS NOT NULL AND s.relationship_type = 'subagent'), + COALESCE(s.ended_at, s.started_at), s.friction_rules_version, + COALESCE(d.seat, ''), COALESCE(d.persona, ''), COALESCE(d.channel, ''), + COALESCE(d.dims_source, ''), COALESCE(d.review_excluded, FALSE) + FROM sessions s + LEFT JOIN friction_session_dims d ON d.session_id = s.id + LEFT JOIN friction_digest_sessions ds ON ds.subject_id = s.id + WHERE s.deleted_at IS NULL + AND COALESCE(d.review_excluded, FALSE) = FALSE + AND ((ds.subject_id IS NULL + AND COALESCE(s.ended_at, s.started_at) >= $1 + AND COALESCE(s.ended_at, s.started_at) < $2) + OR ($3 AND ds.date = $4))`, + from.UTC(), to.UTC(), includeDigested, date) + if err != nil { + return nil, fmt.Errorf("listing friction subjects: %w", err) + } + defer rows.Close() + out := []db.FrictionSubject{} + for rows.Next() { + var ( + sub db.FrictionSubject + machine string + last sql.NullTime + ) + if err := rows.Scan(&sub.SubjectID, &machine, &sub.FilePath, &sub.Agent, + &sub.IsSubAgent, &last, &sub.RulesVersion, &sub.Dims.Seat, + &sub.Dims.Persona, &sub.Dims.Channel, &sub.Dims.DimsSource, + &sub.Dims.ReviewExcluded); err != nil { + return nil, fmt.Errorf("scanning friction subject: %w", err) + } + if last.Valid { + sub.LastActivity = last.Time.UTC() + } + sub.SubjectKind = friction.SubjectSession + sub.Dims.SessionID = sub.SubjectID + sub.Machine = machine + if label := labels[machine]; label != "" { + sub.Machine = label + } + out = append(out, sub) + } + return out, rows.Err() +} + +func (s *Store) FrictionFindingsForSubjects( + ctx context.Context, subjectIDs []string, +) ([]db.FrictionFinding, error) { + out := []db.FrictionFinding{} + for start := 0; start < len(subjectIDs); start += 500 { + chunk := subjectIDs[start:min(start+500, len(subjectIDs))] + pb := ¶mBuilder{} + in := pgInPlaceholders(chunk, pb) + rows, err := s.pg.QueryContext(ctx, ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings + WHERE session_id IN `+in, pb.args...) + if err != nil { + return nil, fmt.Errorf("loading friction findings: %w", err) + } + for rows.Next() { + var ( + f db.FrictionFinding + ord, call sql.NullInt64 + occurred sql.NullTime + ) + if err := rows.Scan(&f.SessionID, &f.Kind, &f.Detector, &ord, &call, + &f.ToolName, &f.Label, &f.Text, &f.Evidence, &f.Title, &f.Fingerprint, + &occurred, &f.Seq, &f.RulesVersion); err != nil { + rows.Close() + return nil, fmt.Errorf("scanning friction finding: %w", err) + } + if ord.Valid { + f.MessageOrdinal = new(int(ord.Int64)) + } + if call.Valid { + f.CallIndex = new(int(call.Int64)) + } + if occurred.Valid { + f.OccurredAt = new(occurred.Time.UTC()) + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + } + // PG has no rowid tiebreak; seq is unique within a session's findings. + // Sort in Go with byte order so collation never differs from SQLite. + sort.SliceStable(out, func(i, j int) bool { + if out[i].SessionID != out[j].SessionID { + return out[i].SessionID < out[j].SessionID + } + return out[i].Seq < out[j].Seq + }) + return out, nil +} + +func (s *Store) SaveFrictionDigest( + ctx context.Context, d db.FrictionDigest, + subjects []db.FrictionDigestSubject, patterns []db.FrictionPatternUpdate, +) error { + tx, err := s.pg.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("beginning friction digest tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + var res sql.Result + if d.Revision <= 1 { + res, err = tx.ExecContext(ctx, ` + INSERT INTO friction_digests (date, timezone, rules_version, built_at, + revision, sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id) + VALUES ($1, $2, $3, $4, 1, $5, $6, $7, $8, $9, $10) + ON CONFLICT (date) DO NOTHING`, + d.Date, d.Timezone, d.RulesVersion, d.BuiltAt.UTC(), d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID) + } else { + res, err = tx.ExecContext(ctx, ` + UPDATE friction_digests SET timezone = $1, rules_version = $2, + built_at = $3, revision = $4, sessions_scanned = $5, + snapshot_json = $6, summary_json = $7, markdown = $8, + markdown_sha256 = $9, run_id = $10 + WHERE date = $11 AND revision = $12`, + d.Timezone, d.RulesVersion, d.BuiltAt.UTC(), d.Revision, d.SessionsScanned, + string(d.SnapshotJSON), string(d.SummaryJSON), string(d.Markdown), + d.MarkdownSHA256, d.RunID, d.Date, d.Revision-1) + } + if err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("writing friction digest %s: %w", d.Date, err) + } else if n == 0 { + return db.ErrFrictionDigestConflict + } + for _, sub := range subjects { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_digest_sessions (subject_id, date, subject_kind) + VALUES ($1, $2, $3) ON CONFLICT (subject_id) DO NOTHING`, + sub.SubjectID, sub.Date, sub.SubjectKind); err != nil { + return fmt.Errorf("recording friction subject %s: %w", sub.SubjectID, err) + } + } + for _, p := range patterns { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO friction_patterns (fingerprint, kind, title, + first_seen_date, last_seen_date, occurrence_count, session_count, + last_subject_id, last_ordinal) + VALUES ($1, $2, $3, $4, $4, $5, 1, $6, $7) + ON CONFLICT (fingerprint) DO UPDATE SET + kind = EXCLUDED.kind, + title = EXCLUDED.title, + occurrence_count = friction_patterns.occurrence_count + EXCLUDED.occurrence_count, + session_count = friction_patterns.session_count + 1, + first_seen_date = LEAST(friction_patterns.first_seen_date, EXCLUDED.first_seen_date), + last_subject_id = CASE WHEN EXCLUDED.last_seen_date >= friction_patterns.last_seen_date + THEN EXCLUDED.last_subject_id ELSE friction_patterns.last_subject_id END, + last_ordinal = CASE WHEN EXCLUDED.last_seen_date >= friction_patterns.last_seen_date + THEN EXCLUDED.last_ordinal ELSE friction_patterns.last_ordinal END, + last_seen_date = GREATEST(friction_patterns.last_seen_date, EXCLUDED.last_seen_date)`, + p.Fingerprint, p.Kind, p.Title, p.Date, p.Occurrences, p.SubjectID, p.Ordinal); err != nil { + return fmt.Errorf("updating friction pattern %s: %w", p.Fingerprint, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing friction digest %s: %w", d.Date, err) + } + return nil +} + +func (s *Store) GetFrictionDigest(ctx context.Context, date string) (*db.FrictionDigest, error) { + var ( + d db.FrictionDigest + snapshot, summary, md string + ) + err := s.pg.QueryRowContext(ctx, ` + SELECT date, timezone, rules_version, built_at, revision, + sessions_scanned, snapshot_json, summary_json, markdown, + markdown_sha256, run_id + FROM friction_digests WHERE date = $1`, date, + ).Scan(&d.Date, &d.Timezone, &d.RulesVersion, &d.BuiltAt, &d.Revision, + &d.SessionsScanned, &snapshot, &summary, &md, &d.MarkdownSHA256, &d.RunID) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading friction digest %s: %w", date, err) + } + d.BuiltAt = d.BuiltAt.UTC() + d.SnapshotJSON, d.SummaryJSON, d.Markdown = []byte(snapshot), []byte(summary), []byte(md) + return &d, nil +} + +func (s *Store) LatestFrictionDigestDate(ctx context.Context) (string, error) { + var date string + if err := s.pg.QueryRowContext(ctx, + `SELECT COALESCE(MAX(date), '') FROM friction_digests`, + ).Scan(&date); err != nil { + return "", fmt.Errorf("reading latest friction digest date: %w", err) + } + return date, nil +} + +func (s *Store) EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) { + var earliest sql.NullTime + if err := s.pg.QueryRowContext(ctx, ` + SELECT MIN(COALESCE(ended_at, started_at)) FROM sessions + WHERE deleted_at IS NULL`, + ).Scan(&earliest); err != nil { + return "", fmt.Errorf("reading earliest session date: %w", err) + } + if !earliest.Valid { + return "", nil + } + return earliest.Time.In(loc).Format("2006-01-02"), nil +} + +func (s *Store) UpdateFrictionDigestRender( + ctx context.Context, date string, markdown, summaryJSON []byte, revision int, +) error { + sum := sha256.Sum256(markdown) + res, err := s.pg.ExecContext(ctx, ` + UPDATE friction_digests + SET markdown = $1, summary_json = $2, markdown_sha256 = $3, revision = $4 + WHERE date = $5 AND revision = $6`, + string(markdown), string(summaryJSON), hex.EncodeToString(sum[:]), + revision, date, revision-1) + if err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } + if n, err := res.RowsAffected(); err != nil { + return fmt.Errorf("re-rendering friction digest %s: %w", date, err) + } else if n == 0 { + return db.ErrFrictionDigestConflict + } + return nil +} + +func (s *Store) FrictionUsageForSessions( + ctx context.Context, sessionIDs []string, +) (map[string]friction.SessionUsage, error) { + return db.FrictionUsageForSessionsFrom(ctx, s, sessionIDs) +} + +func (s *Store) FrictionArchiveSpend( + ctx context.Context, from, to string, loc *time.Location, +) (*friction.ArchiveSpend, error) { + return db.FrictionArchiveSpendFrom(ctx, s, from, to, loc) +} diff --git a/internal/postgres/friction_digest_pgtest_test.go b/internal/postgres/friction_digest_pgtest_test.go new file mode 100644 index 0000000000..f50df50137 --- /dev/null +++ b/internal/postgres/friction_digest_pgtest_test.go @@ -0,0 +1,148 @@ +//go:build pgtest + +package postgres + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" +) + +type frictionReviewStore interface { + FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) + FrictionFindingsForSubjects(ctx context.Context, ids []string) ([]db.FrictionFinding, error) + SaveFrictionDigest(ctx context.Context, d db.FrictionDigest, s []db.FrictionDigestSubject, p []db.FrictionPatternUpdate) error + GetFrictionDigest(ctx context.Context, date string) (*db.FrictionDigest, error) + LatestFrictionDigestDate(ctx context.Context) (string, error) + EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) + UpdateFrictionDigestRender(ctx context.Context, date string, md, summary []byte, revision int) error +} + +func openFrictionParityStores(t *testing.T) (*db.DB, *Store) { + t.Helper() + pgURL := testPGURL(t) + const schema = "agentsview_friction_digest_test" + cleanNamedPGSchema(t, pgURL, schema) + t.Cleanup(func() { cleanNamedPGSchema(t, pgURL, schema) }) + ctx := t.Context() + pg, err := Open(pgURL, schema, true) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Close() }) + require.NoError(t, EnsureSchema(ctx, pg, schema)) + + local, err := db.Open(ctx, filepath.Join(t.TempDir(), "local.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = local.Close() }) + seed := func(id, ended string, parent *string) { + s := db.Session{ + ID: id, Project: "p", Machine: "machine", Agent: "claude", + EndedAt: &ended, MessageCount: 1, UserMessageCount: 1, ParentSessionID: parent, + } + if parent != nil { + s.RelationshipType = "subagent" + } + require.NoError(t, local.UpsertSession(ctx, s)) + require.NoError(t, local.InsertMessages(ctx, []db.Message{{ + SessionID: id, Ordinal: 0, + Role: "user", Content: "hi", ContentLength: 2, Timestamp: ended, + }})) + occurred := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + require.NoError(t, local.ReplaceSessionFriction(ctx, id, []db.FrictionFinding{{ + SessionID: id, Kind: "deferral", Detector: "deferral", MessageOrdinal: new(0), + Label: "next session", Title: "[friction/deferral] " + id + ": next session", + Fingerprint: "fl1:" + id, OccurredAt: &occurred, Seq: 0, RulesVersion: "friction-v1", + }}, &db.FrictionSessionDims{SessionID: id, Seat: "seat-02"}, "friction-v1", "h-"+id)) + } + seed("a", "2026-09-15T01:00:00Z", nil) + root := "a" + seed("b", "2026-09-15T23:30:00Z", &root) + seed("c", "2026-09-16T00:30:00Z", nil) + seed("excluded", "2026-09-15T02:00:00Z", nil) + require.NoError(t, local.ReplaceSessionFriction(ctx, "excluded", nil, + &db.FrictionSessionDims{SessionID: "excluded", ReviewExcluded: true}, "friction-v1", "h-excluded")) + + syncer := &Sync{pg: pg, local: local, machine: "machine", schema: schema, schemaDone: true} + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + + store, err := NewStore(pgURL, schema, true) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + return local, store +} + +func TestFrictionReviewStoreParity(t *testing.T) { + local, pgStore := openFrictionParityStores(t) + for name, s := range map[string]frictionReviewStore{"sqlite": local, "postgres": pgStore} { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + subjects, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false) + require.NoError(t, err) + ids := []string{} + for _, sub := range subjects { + ids = append(ids, sub.SubjectID) + if sub.SubjectID == "b" { + assert.True(t, sub.IsSubAgent) + } + assert.Equal(t, "seat-02", sub.Dims.Seat) + assert.Equal(t, "friction-v1", sub.RulesVersion) + } + assert.ElementsMatch(t, []string{"a", "b"}, ids) + + findings, err := s.FrictionFindingsForSubjects(ctx, []string{"b", "a"}) + require.NoError(t, err) + require.Len(t, findings, 2) + assert.Equal(t, "a", findings[0].SessionID) + assert.Equal(t, "next session", findings[0].Label) + + earliest, err := s.EarliestSessionDate(ctx, time.UTC) + require.NoError(t, err) + assert.Equal(t, "2026-09-15", earliest) + + d := db.FrictionDigest{ + Date: "2026-09-15", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2026, 9, 22, 1, 0, 0, 0, time.UTC), Revision: 1, SessionsScanned: 2, + SnapshotJSON: []byte(`{"date":"2026-09-15"}`), SummaryJSON: []byte("{}\n"), + Markdown: []byte("md\n"), MarkdownSHA256: "abc", RunID: "run-1", + } + pats := []db.FrictionPatternUpdate{ + {Fingerprint: "fl1:a", Kind: "deferral", Title: "t", Date: "2026-09-15", SubjectID: "a", Ordinal: new(0), Occurrences: 1}, + } + require.NoError(t, s.SaveFrictionDigest(ctx, d, + []db.FrictionDigestSubject{{SubjectID: "a", Date: "2026-09-15", SubjectKind: friction.SubjectSession}}, pats)) + require.ErrorIs(t, s.SaveFrictionDigest(ctx, d, nil, pats), db.ErrFrictionDigestConflict) + got, err := s.GetFrictionDigest(ctx, "2026-09-15") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, d, *got) + + after, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false) + require.NoError(t, err) + require.Len(t, after, 1) + assert.Equal(t, "b", after[0].SubjectID) + + latest, err := s.LatestFrictionDigestDate(ctx) + require.NoError(t, err) + assert.Equal(t, "2026-09-15", latest) + require.NoError(t, s.UpdateFrictionDigestRender(ctx, "2026-09-15", []byte("md2\n"), []byte("{}\n"), 2)) + require.ErrorIs(t, s.UpdateFrictionDigestRender(ctx, "2026-09-15", []byte("x"), []byte("{}"), 2), db.ErrFrictionDigestConflict) + missing, err := s.GetFrictionDigest(ctx, "2020-01-01") + require.NoError(t, err) + assert.Nil(t, missing) + }) + } +} + +func TestFrictionAvailabilityProbe(t *testing.T) { + _, store := openFrictionParityStores(t) + store.DetectFrictionAvailability(t.Context()) + assert.True(t, store.FrictionAvailable()) + assert.True(t, pushSchemaCurrent(t.Context(), store.DB())) +} diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index a3b795726f..520d6250d4 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -658,6 +658,44 @@ CREATE INDEX IF NOT EXISTS idx_insights_lookup CREATE INDEX IF NOT EXISTS idx_insights_cache ON insights (cache_key, created_at DESC) WHERE cache_key <> ''; + +CREATE TABLE IF NOT EXISTS friction_digests ( + date TEXT PRIMARY KEY, + timezone TEXT NOT NULL, + rules_version TEXT NOT NULL, + built_at TIMESTAMPTZ NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + sessions_scanned INTEGER NOT NULL, + snapshot_json TEXT NOT NULL, + summary_json TEXT NOT NULL, + markdown TEXT NOT NULL, + markdown_sha256 TEXT NOT NULL, + run_id TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS friction_digest_sessions ( + subject_id TEXT PRIMARY KEY, + date TEXT NOT NULL, + subject_kind TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_friction_digest_sessions_date + ON friction_digest_sessions (date); + +CREATE TABLE IF NOT EXISTS friction_patterns ( + fingerprint TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + first_seen_date TEXT NOT NULL, + last_seen_date TEXT NOT NULL, + occurrence_count INTEGER NOT NULL, + session_count INTEGER NOT NULL, + last_subject_id TEXT NOT NULL, + last_ordinal INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_friction_patterns_last_seen + ON friction_patterns (last_seen_date); ` func migrateMoneyColumnsPG( @@ -2834,6 +2872,9 @@ func pushSchemaCurrent(ctx context.Context, db *sql.DB) bool { !pgHasTable(ctx, db, "source_session_project_identity_snapshot_scopes") || !pgHasTable(ctx, db, "source_worktree_project_mappings") || !pgHasTable(ctx, db, "source_worktree_project_mapping_scopes") || + !pgHasTable(ctx, db, "friction_digests") || + !pgHasTable(ctx, db, "friction_digest_sessions") || + !pgHasTable(ctx, db, "friction_patterns") || !pgHasTable(ctx, db, "cursor_usage_events") || !pgHasTable(ctx, db, "friction_findings") || !pgHasTable(ctx, db, "friction_session_dims") { diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 28faafd7ef..f3cdcafc29 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -11,6 +11,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "go.kenn.io/agentsview/internal/config" @@ -26,6 +27,7 @@ type Store struct { insightCapabilityMu sync.RWMutex insightGenerationAvailable bool + frictionAvailable atomic.Bool pricingMu sync.Mutex pricingLoadMu sync.Mutex From 7b8248450334d197435e54959a0026b5a4e54214 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:27:05 +0000 Subject: [PATCH 44/62] feat(db): expose friction review state on every store --- internal/clickhouse/friction_stubs_test.go | 29 +++++++++++++++++ internal/clickhouse/stubs.go | 38 ++++++++++++++++++++++ internal/db/store.go | 14 ++++++++ internal/duckdb/friction_stubs_test.go | 29 +++++++++++++++++ internal/duckdb/stubs.go | 38 ++++++++++++++++++++++ 5 files changed, 148 insertions(+) create mode 100644 internal/clickhouse/friction_stubs_test.go create mode 100644 internal/duckdb/friction_stubs_test.go diff --git a/internal/clickhouse/friction_stubs_test.go b/internal/clickhouse/friction_stubs_test.go new file mode 100644 index 0000000000..317dc83074 --- /dev/null +++ b/internal/clickhouse/friction_stubs_test.go @@ -0,0 +1,29 @@ +package clickhouse + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestFrictionReviewStubsAreReadOnly(t *testing.T) { + s := &Store{} + ctx := t.Context() + calls := map[string]func() error{ + "subjects": func() error { _, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false); return err }, + "findings": func() error { _, err := s.FrictionFindingsForSubjects(ctx, []string{"a"}); return err }, + "usage": func() error { _, err := s.FrictionUsageForSessions(ctx, []string{"a"}); return err }, + "archive": func() error { _, err := s.FrictionArchiveSpend(ctx, "2026-09-08", "2026-09-14", time.UTC); return err }, + "save": func() error { return s.SaveFrictionDigest(ctx, db.FrictionDigest{}, nil, nil) }, + "get": func() error { _, err := s.GetFrictionDigest(ctx, "2026-09-15"); return err }, + "latest": func() error { _, err := s.LatestFrictionDigestDate(ctx); return err }, + "earliest": func() error { _, err := s.EarliestSessionDate(ctx, time.UTC); return err }, + "render": func() error { return s.UpdateFrictionDigestRender(ctx, "2026-09-15", nil, nil, 2) }, + } + for name, call := range calls { + t.Run(name, func(t *testing.T) { require.ErrorIs(t, call(), db.ErrReadOnly) }) + } +} diff --git a/internal/clickhouse/stubs.go b/internal/clickhouse/stubs.go index 64a07c3df8..e3c850aad4 100644 --- a/internal/clickhouse/stubs.go +++ b/internal/clickhouse/stubs.go @@ -2,8 +2,10 @@ package clickhouse import ( "context" + "time" "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" ) func (s *Store) InsertInsight(_ context.Context, _ db.Insight) (int64, error) { @@ -42,3 +44,39 @@ func (s *Store) WriteSessionBatchAtomic(_ context.Context, ) (db.SessionBatchResult, error) { return db.SessionBatchResult{}, db.ErrReadOnly } + +func (s *Store) FrictionSubjectsForDate(_ context.Context, _ string, _ *time.Location, _ bool) ([]db.FrictionSubject, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) FrictionFindingsForSubjects(_ context.Context, _ []string) ([]db.FrictionFinding, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) FrictionUsageForSessions(_ context.Context, _ []string) (map[string]friction.SessionUsage, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) FrictionArchiveSpend(_ context.Context, _, _ string, _ *time.Location) (*friction.ArchiveSpend, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) SaveFrictionDigest(_ context.Context, _ db.FrictionDigest, _ []db.FrictionDigestSubject, _ []db.FrictionPatternUpdate) error { + return db.ErrReadOnly +} + +func (s *Store) GetFrictionDigest(_ context.Context, _ string) (*db.FrictionDigest, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) LatestFrictionDigestDate(_ context.Context) (string, error) { + return "", db.ErrReadOnly +} + +func (s *Store) EarliestSessionDate(_ context.Context, _ *time.Location) (string, error) { + return "", db.ErrReadOnly +} + +func (s *Store) UpdateFrictionDigestRender(_ context.Context, _ string, _, _ []byte, _ int) error { + return db.ErrReadOnly +} diff --git a/internal/db/store.go b/internal/db/store.go index 978a6053f8..2dbbae0727 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -3,9 +3,11 @@ package db import ( "context" "io" + "time" "go.kenn.io/agentsview/internal/activity" "go.kenn.io/agentsview/internal/export" + "go.kenn.io/agentsview/internal/friction" ) // ErrReadOnly is returned by write methods on read-only store @@ -155,6 +157,18 @@ type Store interface { beforeCommit ...func() error, ) (SessionBatchResult, error) + // Friction review state. Each store builds its own digests; read-only + // mirrors return ErrReadOnly. + FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]FrictionSubject, error) + FrictionFindingsForSubjects(ctx context.Context, subjectIDs []string) ([]FrictionFinding, error) + FrictionUsageForSessions(ctx context.Context, sessionIDs []string) (map[string]friction.SessionUsage, error) + FrictionArchiveSpend(ctx context.Context, from, to string, loc *time.Location) (*friction.ArchiveSpend, error) + SaveFrictionDigest(ctx context.Context, d FrictionDigest, subjects []FrictionDigestSubject, patterns []FrictionPatternUpdate) error + GetFrictionDigest(ctx context.Context, date string) (*FrictionDigest, error) + LatestFrictionDigestDate(ctx context.Context) (string, error) + EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) + UpdateFrictionDigestRender(ctx context.Context, date string, markdown, summaryJSON []byte, revision int) error + // ReadOnly returns true for remote/PG-backed stores. ReadOnly() bool } diff --git a/internal/duckdb/friction_stubs_test.go b/internal/duckdb/friction_stubs_test.go new file mode 100644 index 0000000000..19128d31a0 --- /dev/null +++ b/internal/duckdb/friction_stubs_test.go @@ -0,0 +1,29 @@ +package duckdb + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestFrictionReviewStubsAreReadOnly(t *testing.T) { + s := &Store{} + ctx := t.Context() + calls := map[string]func() error{ + "subjects": func() error { _, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, false); return err }, + "findings": func() error { _, err := s.FrictionFindingsForSubjects(ctx, []string{"a"}); return err }, + "usage": func() error { _, err := s.FrictionUsageForSessions(ctx, []string{"a"}); return err }, + "archive": func() error { _, err := s.FrictionArchiveSpend(ctx, "2026-09-08", "2026-09-14", time.UTC); return err }, + "save": func() error { return s.SaveFrictionDigest(ctx, db.FrictionDigest{}, nil, nil) }, + "get": func() error { _, err := s.GetFrictionDigest(ctx, "2026-09-15"); return err }, + "latest": func() error { _, err := s.LatestFrictionDigestDate(ctx); return err }, + "earliest": func() error { _, err := s.EarliestSessionDate(ctx, time.UTC); return err }, + "render": func() error { return s.UpdateFrictionDigestRender(ctx, "2026-09-15", nil, nil, 2) }, + } + for name, call := range calls { + t.Run(name, func(t *testing.T) { require.ErrorIs(t, call(), db.ErrReadOnly) }) + } +} diff --git a/internal/duckdb/stubs.go b/internal/duckdb/stubs.go index a7b78b2cda..93ce8ced0d 100644 --- a/internal/duckdb/stubs.go +++ b/internal/duckdb/stubs.go @@ -2,8 +2,10 @@ package duckdb import ( "context" + "time" "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" ) func (s *Store) InsertInsight(ctx context.Context, _ db.Insight) (int64, error) { @@ -44,3 +46,39 @@ func (s *Store) WriteSessionBatchAtomic(ctx context.Context, ) (db.SessionBatchResult, error) { return db.SessionBatchResult{}, db.ErrReadOnly } + +func (s *Store) FrictionSubjectsForDate(_ context.Context, _ string, _ *time.Location, _ bool) ([]db.FrictionSubject, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) FrictionFindingsForSubjects(_ context.Context, _ []string) ([]db.FrictionFinding, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) FrictionUsageForSessions(_ context.Context, _ []string) (map[string]friction.SessionUsage, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) FrictionArchiveSpend(_ context.Context, _, _ string, _ *time.Location) (*friction.ArchiveSpend, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) SaveFrictionDigest(_ context.Context, _ db.FrictionDigest, _ []db.FrictionDigestSubject, _ []db.FrictionPatternUpdate) error { + return db.ErrReadOnly +} + +func (s *Store) GetFrictionDigest(_ context.Context, _ string) (*db.FrictionDigest, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) LatestFrictionDigestDate(_ context.Context) (string, error) { + return "", db.ErrReadOnly +} + +func (s *Store) EarliestSessionDate(_ context.Context, _ *time.Location) (string, error) { + return "", db.ErrReadOnly +} + +func (s *Store) UpdateFrictionDigestRender(_ context.Context, _ string, _, _ []byte, _ int) error { + return db.ErrReadOnly +} From 32f4361dfcd1ee77e8d67c006cc6b9f108d68140 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:38:24 +0000 Subject: [PATCH 45/62] feat(sync): keep friction digests across full resync --- internal/db/friction_digest.go | 44 +++++++++++++++++ internal/db/friction_digest_test.go | 53 ++++++++++++++++++++ internal/sync/engine.go | 18 +++++++ internal/sync/engine_integration_test.go | 62 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+) diff --git a/internal/db/friction_digest.go b/internal/db/friction_digest.go index c64690c8ff..2df17ac804 100644 --- a/internal/db/friction_digest.go +++ b/internal/db/friction_digest.go @@ -365,3 +365,47 @@ func (db *DB) UpdateFrictionDigestRender( } return nil } + +// CopyFrictionStateFrom carries friction review state (digests, membership +// and recurrence) across a full resync, like CopyInsightsFrom. Tables absent +// from an older source archive are skipped. +func (db *DB) CopyFrictionStateFrom(sourcePath string) error { + if err := db.requireDerivedTextStorage("friction review state"); err != nil { + return err + } + db.mu.Lock() + defer db.mu.Unlock() + ctx := context.Background() + conn, err := db.getWriter().Conn(ctx) + if err != nil { + return fmt.Errorf("acquiring connection: %w", err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, "ATTACH DATABASE ? AS old_db", sourcePath); err != nil { + return fmt.Errorf("attaching source db: %w", err) + } + defer func() { _, _ = conn.ExecContext(ctx, "DETACH DATABASE old_db") }() + + copies := []struct{ table, columns string }{ + {"friction_digests", "date, timezone, rules_version, built_at, revision, sessions_scanned, snapshot_json, summary_json, markdown, markdown_sha256, run_id"}, + {"friction_digest_sessions", "subject_id, date, subject_kind"}, + {"friction_patterns", "fingerprint, kind, title, first_seen_date, last_seen_date, occurrence_count, session_count, last_subject_id, last_ordinal"}, + } + for _, c := range copies { + var present int + if err := conn.QueryRowContext(ctx, + `SELECT count(*) FROM old_db.sqlite_master WHERE type = 'table' AND name = ?`, + c.table).Scan(&present); err != nil { + return fmt.Errorf("probing source %s: %w", c.table, err) + } + if present == 0 { + continue + } + if _, err := conn.ExecContext(ctx, + "INSERT OR IGNORE INTO "+c.table+" ("+c.columns+") SELECT "+c.columns+" FROM old_db."+c.table, + ); err != nil { + return fmt.Errorf("copying %s: %w", c.table, err) + } + } + return nil +} diff --git a/internal/db/friction_digest_test.go b/internal/db/friction_digest_test.go index ef1fe43545..872225e045 100644 --- a/internal/db/friction_digest_test.go +++ b/internal/db/friction_digest_test.go @@ -3,12 +3,14 @@ package db import ( "crypto/sha256" "encoding/hex" + "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/friction" ) @@ -348,3 +350,54 @@ func countRows(t *testing.T, d *DB, table string) int { require.NoError(t, d.getReader().QueryRowContext(t.Context(), "SELECT count(*) FROM "+table).Scan(&n)) return n } + +func TestCopyFrictionStateFrom(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "src.db") + src := testDBAtPath(t, srcPath, "src") + require.NoError(t, src.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), + []FrictionDigestSubject{{SubjectID: "s1", Date: "2026-09-15", SubjectKind: friction.SubjectSession}}, + []FrictionPatternUpdate{{Fingerprint: "fl1:a", Kind: "error", Title: "t", Date: "2026-09-15", SubjectID: "s1", Occurrences: 3}})) + src.Close() + + dst := testDBAtPath(t, filepath.Join(dir, "dst.db"), "dst") + defer dst.Close() + require.NoError(t, dst.CopyFrictionStateFrom(srcPath)) + require.NoError(t, dst.CopyFrictionStateFrom(srcPath), "copy is idempotent") + + got, err := dst.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, testDigest("2026-09-15", 1), *got) + assert.Equal(t, 1, countRows(t, dst, "friction_digest_sessions")) + assert.Equal(t, 3, frictionPatternCount(t, dst, "fl1:a")) +} + +func TestCopyFrictionStateFromOldArchive(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "old.db") + src := testDBAtPath(t, srcPath, "old") + _, err := src.getWriter().Exec(t.Context(), ` + DROP TABLE friction_digests; + DROP TABLE friction_digest_sessions; + DROP TABLE friction_patterns;`) + require.NoError(t, err) + src.Close() + dst := testDBAtPath(t, filepath.Join(dir, "dst.db"), "dst") + defer dst.Close() + require.NoError(t, dst.CopyFrictionStateFrom(srcPath)) + assert.Equal(t, 0, countRows(t, dst, "friction_digests")) +} + +func TestCopyFrictionStateFromRejectsUsageOnly(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "src.db") + src := testDBAtPath(t, srcPath, "src") + require.NoError(t, src.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, nil)) + require.NoError(t, src.Close()) + + dst := testDBAtPath(t, filepath.Join(dir, "dst.db"), "dst") + dst.SetArchiveContent(config.ArchiveContentUsage) + require.ErrorIs(t, dst.CopyFrictionStateFrom(srcPath), ErrArchiveContentExcluded) + assert.Equal(t, 0, countRows(t, dst, "friction_digests")) +} diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 81e179b525..7927512441 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -3568,6 +3568,24 @@ func (e *Engine) resyncBuildLocked( time.Since(tInsights).Round(time.Millisecond), ) } + // Digest snapshots and Markdown may contain transcript text. Preserve + // them only when the rebuilt archive stores that content. + if copyDerivedText { + if err := newDB.CopyFrictionStateFrom(origPath); err != nil { + log.Printf("resync: copy friction state: %v", err) + stats.Aborted = true + stats.Warnings = append(stats.Warnings, + "friction state copy failed, aborting swap: "+err.Error(), + ) + newDB.Close() + removeTempDB(tempPath) + restoreSkipCache() + e.mu.Lock() + e.lastSyncStats = stats + e.mu.Unlock() + return stats, err + } + } // Copy model pricing so usage costs survive the swap. The // startup seed only runs once per daemon lifetime, so a diff --git a/internal/sync/engine_integration_test.go b/internal/sync/engine_integration_test.go index ba42d17945..6c3f0e7777 100644 --- a/internal/sync/engine_integration_test.go +++ b/internal/sync/engine_integration_test.go @@ -25,6 +25,7 @@ import ( "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/dbtest" "go.kenn.io/agentsview/internal/export" + "go.kenn.io/agentsview/internal/friction" "go.kenn.io/agentsview/internal/money" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/sync" @@ -17617,3 +17618,64 @@ func TestSyncAllPreservesUnprovenClaudeMissingRows(t *testing.T) { }) } } + +func TestResyncAllPreservesFrictionDigests(t *testing.T) { + env := setupTestEnv(t) + content := testjsonl.NewSessionBuilder(). + AddClaudeUser(tsEarly, "Hello"). + AddClaudeAssistant(tsEarlyS5, "Hi there!"). + String() + env.writeClaudeSession(t, "test-proj", "friction-test.jsonl", content) + env.engine.SyncAll(t.Context(), nil) + before, err := env.db.FrictionSubjectsForDate(t.Context(), "2024-01-01", time.UTC, false) + require.NoError(t, err) + require.Len(t, before, 1, "fixture session must be eligible before the digest") + assert.Equal(t, "friction-test", before[0].SubjectID) + + require.NoError(t, env.db.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2024-01-01", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), + Markdown: []byte("# Friction Log — 2024-01-01\n"), MarkdownSHA256: "x", RunID: "r", + }, []db.FrictionDigestSubject{{SubjectID: "friction-test", Date: "2024-01-01", SubjectKind: friction.SubjectSession}}, nil)) + + stats := env.engine.ResyncAll(t.Context(), nil) + require.NotZero(t, stats.Synced) + + got, err := env.db.GetFrictionDigest(t.Context(), "2024-01-01") + require.NoError(t, err) + require.NotNil(t, got, "digest must survive a full resync") + assert.Equal(t, "# Friction Log — 2024-01-01\n", string(got.Markdown)) + subjects, err := env.db.FrictionSubjectsForDate(t.Context(), "2024-01-01", time.UTC, false) + require.NoError(t, err) + assert.Empty(t, subjects, "membership survives, so the session is not re-reviewed") +} + +func TestResyncAllUsageOnlyDropsFrictionDigestText(t *testing.T) { + env := setupTestEnv(t) + content := testjsonl.NewSessionBuilder(). + AddClaudeUser(tsEarly, "private prompt"). + AddClaudeAssistant(tsEarlyS5, "private reply"). + String() + env.writeClaudeSession(t, "test-proj", "friction-private.jsonl", content) + require.Equal(t, 1, env.engine.SyncAll(t.Context(), nil).Synced) + require.NoError(t, env.db.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2024-01-01", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), Revision: 1, + SnapshotJSON: []byte(`{"prompt":"private prompt"}`), SummaryJSON: []byte("{}"), + Markdown: []byte("private reply"), MarkdownSHA256: "x", RunID: "r", + }, nil, nil)) + + env.engine.Close() + env.db.SetArchiveContent(config.ArchiveContentUsage) + usageEngine := sync.NewEngine(t.Context(), env.db, sync.EngineConfig{ + AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {env.claudeDir}}, + Machine: "local", ArchiveContent: config.ArchiveContentUsage, + }) + t.Cleanup(usageEngine.Close) + stats := usageEngine.ResyncAll(t.Context(), nil) + require.False(t, stats.Aborted, "resync aborted: %v", stats.Warnings) + got, err := env.db.GetFrictionDigest(t.Context(), "2024-01-01") + require.NoError(t, err) + assert.Nil(t, got, "usage-only archive must not retain digest text") +} From cc397602f356253695886f3f0c936d467fd8625a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:41:39 +0000 Subject: [PATCH 46/62] feat(friction): review snapshot codec and spend fold --- internal/friction/review/dates.go | 23 ++ internal/friction/review/dates_test.go | 29 +++ internal/friction/review/fold.go | 111 +++++++++ internal/friction/review/fold_test.go | 66 +++++ internal/friction/review/snapshot.go | 281 ++++++++++++++++++++++ internal/friction/review/snapshot_test.go | 89 +++++++ 6 files changed, 599 insertions(+) create mode 100644 internal/friction/review/dates.go create mode 100644 internal/friction/review/dates_test.go create mode 100644 internal/friction/review/fold.go create mode 100644 internal/friction/review/fold_test.go create mode 100644 internal/friction/review/snapshot.go create mode 100644 internal/friction/review/snapshot_test.go diff --git a/internal/friction/review/dates.go b/internal/friction/review/dates.go new file mode 100644 index 0000000000..789d0e145e --- /dev/null +++ b/internal/friction/review/dates.go @@ -0,0 +1,23 @@ +// Package review builds dated Friction Log digests from stored findings. +// It ports jilog's run_review (crates/jilog-review/src/digest.rs:208-706) +// onto database state: subjects come from the archive, the processed set is +// friction_digest_sessions, and "nightly" becomes an hourly catch-up. +package review + +import "time" + +const dateLayout = "2006-01-02" + +func localDate(t time.Time, loc *time.Location) string { + return t.In(loc).Format(dateLayout) +} + +// addDays shifts a YYYY-MM-DD date. Inputs are validated at the API edge +// (BuildDate) or produced by localDate, so a parse failure is a bug. +func addDays(date string, n int) string { + d, err := time.Parse(dateLayout, date) + if err != nil { + panic("review: malformed internal date " + date) + } + return d.AddDate(0, 0, n).Format(dateLayout) +} diff --git a/internal/friction/review/dates_test.go b/internal/friction/review/dates_test.go new file mode 100644 index 0000000000..b5a02f0113 --- /dev/null +++ b/internal/friction/review/dates_test.go @@ -0,0 +1,29 @@ +package review + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocalDateAndAddDays(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + require.NoError(t, err) + tests := []struct { + name string + t time.Time + loc *time.Location + want string + }{ + {name: "utc", t: time.Date(2026, 9, 22, 23, 59, 0, 0, time.UTC), loc: time.UTC, want: "2026-09-22"}, + {name: "tokyo rolls forward", t: time.Date(2026, 9, 22, 15, 30, 0, 0, time.UTC), loc: tokyo, want: "2026-09-23"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, localDate(tt.t, tt.loc)) }) + } + assert.Equal(t, "2026-03-01", addDays("2026-02-28", 1)) + assert.Equal(t, "2026-09-15", addDays("2026-09-22", -7)) + assert.Equal(t, "2027-01-01", addDays("2026-12-31", 1)) +} diff --git a/internal/friction/review/fold.go b/internal/friction/review/fold.go new file mode 100644 index 0000000000..99ca0f219a --- /dev/null +++ b/internal/friction/review/fold.go @@ -0,0 +1,111 @@ +package review + +import "go.kenn.io/agentsview/internal/friction" + +// spendFold ports fold_session_stats/accumulate_stats (digest.rs:1134-1203) +// and the persona rollup (digest.rs:385-424). In the archive every subject +// has messages, so a subject is always counted through countSession and +// addUsage folds with jilog's count_session=false. +type spendFold struct { + spend friction.SpendSummary + personas map[friction.PersonaKey]*friction.PersonaCounts +} + +func newSpendFold() *spendFold { + return &spendFold{ + spend: friction.SpendSummary{ + RoleCosts: map[string]friction.USD{}, + ModelCosts: map[string]friction.USD{}, + }, + personas: map[friction.PersonaKey]*friction.PersonaCounts{}, + } +} + +func addUSD(m map[string]friction.USD, key string, v friction.USD) { + if cur, ok := m[key]; ok { + m[key] = cur.Add(v) + return + } + m[key] = v +} + +func sumUSD(p *friction.USD, v friction.USD) *friction.USD { + if p == nil { + c := v + return &c + } + s := p.Add(v) + return &s +} + +func (f *spendFold) persona(key *friction.PersonaKey) *friction.PersonaCounts { + pc := f.personas[*key] + if pc == nil { + pc = &friction.PersonaCounts{} + f.personas[*key] = pc + } + return pc +} + +// countSession folds one subject's signal counts into its persona. Sessions +// with no signals still count (digest.rs:411-424). +func (f *spendFold) countSession(key *friction.PersonaKey, sigs []friction.Signal) { + if key == nil { + return + } + pc := f.persona(key) + pc.Sessions++ + for _, s := range sigs { + switch s.Kind { + case friction.KindCorrection: + pc.Corrections++ + case friction.KindError: + pc.Errors++ + case friction.KindWorkaround: + pc.Workarounds++ + case friction.KindDeferral: + pc.Deferrals++ + case friction.KindPattern: + pc.Patterns++ + case friction.KindFrustration, friction.KindInterruption: + // Not in jilog's persona line (spec §9.1); the digest's own + // frontmatter and sections count them. + } + } +} + +// addUsage is accumulate_stats plus the persona usage part of +// fold_session_stats. +func (f *spendFold) addUsage(key *friction.PersonaKey, u friction.SessionUsage) { + f.spend.SessionsWithStats++ + f.spend.InputTokens += u.InputTokens + f.spend.OutputTokens += u.OutputTokens + for model, c := range u.ModelCosts { + addUSD(f.spend.ModelCosts, model, c) + } + if u.CostUSD != nil { + f.spend.SessionsWithCost++ + f.spend.Total = sumUSD(f.spend.Total, *u.CostUSD) + role := u.Role + if role == "" { + role = "(root)" + } + addUSD(f.spend.RoleCosts, role, *u.CostUSD) + } + if key != nil { + pc := f.persona(key) + pc.InputTokens += u.InputTokens + pc.OutputTokens += u.OutputTokens + if u.CostUSD != nil { + pc.CostUSD = sumUSD(pc.CostUSD, *u.CostUSD) + } + } +} + +func (f *spendFold) summary() *friction.SpendSummary { + if f.spend.SessionsWithStats == 0 { + return nil + } + s := f.spend + return &s +} diff --git a/internal/friction/review/fold_test.go b/internal/friction/review/fold_test.go new file mode 100644 index 0000000000..55d50f549e --- /dev/null +++ b/internal/friction/review/fold_test.go @@ -0,0 +1,66 @@ +package review + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/friction" +) + +func TestSpendFold(t *testing.T) { + cost := func(micros int64) *friction.USD { u := friction.USDFromMicros(micros); return &u } + t.Run("absent_without_stats", func(t *testing.T) { + assert.Nil(t, newSpendFold().summary()) + }) + t.Run("roles_models_and_optional_cost", func(t *testing.T) { + f := newSpendFold() + f.addUsage(nil, friction.SessionUsage{ + Role: "(root)", InputTokens: 100, OutputTokens: 10, + CostUSD: cost(3100000), ModelCosts: map[string]friction.USD{"m1": *cost(3100000)}, + }) + f.addUsage(nil, friction.SessionUsage{ + Role: "subagent", InputTokens: 5, OutputTokens: 1, + CostUSD: cost(1100000), ModelCosts: map[string]friction.USD{"m1": *cost(1100000)}, + }) + f.addUsage(nil, friction.SessionUsage{Role: "(root)", InputTokens: 7, OutputTokens: 2}) + s := f.summary() + require.NotNil(t, s) + assert.Equal(t, 3, s.SessionsWithStats) + assert.Equal(t, 2, s.SessionsWithCost) + assert.Equal(t, uint64(112), s.InputTokens) + assert.Equal(t, uint64(13), s.OutputTokens) + assert.Equal(t, "4.200000", s.Total.String()) + assert.Equal(t, "3.100000", s.RoleCosts["(root)"].String()) + assert.Equal(t, "1.100000", s.RoleCosts["subagent"].String()) + assert.Equal(t, "4.200000", s.ModelCosts["m1"].String()) + }) + t.Run("all_unpriced_has_no_total", func(t *testing.T) { + f := newSpendFold() + f.addUsage(nil, friction.SessionUsage{Role: "(root)", InputTokens: 10, OutputTokens: 5}) + s := f.summary() + require.NotNil(t, s) + assert.Nil(t, s.Total) + assert.Equal(t, 0, s.SessionsWithCost) + }) + t.Run("persona_counts_sessions_and_usage", func(t *testing.T) { + f := newSpendFold() + key := &friction.PersonaKey{Persona: "helper", Channel: "general"} + // Persona lines stay jilog-exact: frustration and interruption are + // not counted there (spec §9.1). + f.countSession(key, []friction.Signal{ + {Kind: friction.KindCorrection}, + {Kind: friction.KindPattern}, + {Kind: friction.KindFrustration}, + {Kind: friction.KindInterruption}, + }) + f.addUsage(key, friction.SessionUsage{Role: "(root)", InputTokens: 12000, OutputTokens: 300}) + pc := f.personas[*key] + require.NotNil(t, pc) + assert.Equal(t, friction.PersonaCounts{ + Sessions: 1, Corrections: 1, Patterns: 1, + InputTokens: 12000, OutputTokens: 300, + }, *pc) + }) +} diff --git a/internal/friction/review/snapshot.go b/internal/friction/review/snapshot.go new file mode 100644 index 0000000000..e72930935e --- /dev/null +++ b/internal/friction/review/snapshot.go @@ -0,0 +1,281 @@ +package review + +import ( + "encoding/json/v2" + "fmt" + "sort" + "time" + + "go.kenn.io/agentsview/internal/friction" +) + +// snapshot_json is the canonical frozen input of a digest (spec §5.4). The +// wire form flattens struct-keyed maps and stores USD as decimal strings so +// scale survives the round trip. +type snapshotWire struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + RulesVersion string `json:"rules_version"` + Signals []signalWire `json:"signals"` + P0Alerts map[string][]string `json:"p0_alerts"` + Personas []personaWire `json:"personas"` + Spend *spendWire `json:"spend"` + ArchiveSpend *archiveWire `json:"archive_spend"` + SessionsScanned int `json:"sessions_scanned"` + RecurrenceCosts map[string]string `json:"recurrence_costs"` +} + +type signalWire struct { + Kind string `json:"kind"` + SubjectID string `json:"subject_id"` + SubjectKind string `json:"subject_kind"` + Seat string `json:"seat"` + Agent string `json:"agent"` + Machine string `json:"machine"` + Persona string `json:"persona"` + Channel string `json:"channel"` + Detector string `json:"detector"` + Text string `json:"text"` + ToolName string `json:"tool_name"` + Label string `json:"label"` + Evidence string `json:"evidence"` + Ordinal *int `json:"ordinal"` + CallIndex *int `json:"call_index"` + OccurredAt string `json:"occurred_at"` + Seq int `json:"seq"` +} + +type personaWire struct { + Persona string `json:"persona"` + Channel string `json:"channel"` + Sessions int `json:"sessions"` + Corrections int `json:"corrections"` + Errors int `json:"errors"` + Workarounds int `json:"workarounds"` + Deferrals int `json:"deferrals"` + Patterns int `json:"patterns"` + InputTokens uint64 `json:"input_tokens"` + OutputTokens uint64 `json:"output_tokens"` + CostUSD *string `json:"cost_usd"` +} + +type spendWire struct { + Total *string `json:"total"` + SessionsWithStats int `json:"sessions_with_stats"` + SessionsWithCost int `json:"sessions_with_cost"` + InputTokens uint64 `json:"input_tokens"` + OutputTokens uint64 `json:"output_tokens"` + RoleCosts map[string]string `json:"role_costs"` + ModelCosts map[string]string `json:"model_costs"` +} + +type periodWire struct { + Total string `json:"total"` + Days int `json:"days"` + Agents map[string]string `json:"agents"` + Models map[string]string `json:"models"` +} + +type archiveWire struct { + Yesterday *periodWire `json:"yesterday"` + Week periodWire `json:"week"` + WeekFrom string `json:"week_from"` + WeekTo string `json:"week_to"` + Timezone string `json:"timezone"` +} + +func usdStrings(m map[string]friction.USD) map[string]string { + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v.String() + } + return out +} + +func parseUSDMap(m map[string]string) (map[string]friction.USD, error) { + out := make(map[string]friction.USD, len(m)) + for k, v := range m { + u, err := friction.ParseUSD(v) + if err != nil { + return nil, fmt.Errorf("snapshot usd %q: %w", k, err) + } + out[k] = u + } + return out, nil +} + +func usdPtrString(u *friction.USD) *string { + if u == nil { + return nil + } + s := u.String() + return &s +} + +func parseUSDPtr(s *string) (*friction.USD, error) { + if s == nil { + return nil, nil + } + u, err := friction.ParseUSD(*s) + if err != nil { + return nil, err + } + return &u, nil +} + +func periodToWire(p friction.PeriodSpend) periodWire { + return periodWire{Total: p.Total.String(), Days: p.Days, Agents: usdStrings(p.Agents), Models: usdStrings(p.Models)} +} + +func periodFromWire(w periodWire) (friction.PeriodSpend, error) { + total, err := friction.ParseUSD(w.Total) + if err != nil { + return friction.PeriodSpend{}, err + } + agents, err := parseUSDMap(w.Agents) + if err != nil { + return friction.PeriodSpend{}, err + } + models, err := parseUSDMap(w.Models) + if err != nil { + return friction.PeriodSpend{}, err + } + return friction.PeriodSpend{Total: total, Days: w.Days, Agents: agents, Models: models}, nil +} + +func EncodeSnapshot(s friction.DigestSnapshot) ([]byte, error) { + w := snapshotWire{ + Date: s.Date, Timezone: s.Timezone, RulesVersion: s.RulesVersion, + Signals: make([]signalWire, 0, len(s.Signals)), P0Alerts: s.P0Alerts, + Personas: make([]personaWire, 0, len(s.Personas)), SessionsScanned: s.SessionsScanned, + RecurrenceCosts: usdStrings(s.RecurrenceCosts), + } + for _, sig := range s.Signals { + sw := signalWire{ + Kind: string(sig.Kind), SubjectID: sig.SubjectID, SubjectKind: sig.SubjectKind, + Seat: sig.Dims.Seat, + Agent: sig.Dims.Agent, Machine: sig.Dims.Machine, Persona: sig.Dims.Persona, + Channel: sig.Dims.Channel, Detector: sig.Detector, Text: sig.Text, + ToolName: sig.ToolName, Label: sig.Label, Evidence: sig.Evidence, + Ordinal: sig.Ordinal, CallIndex: sig.CallIndex, Seq: sig.Seq, + } + if !sig.OccurredAt.IsZero() { + sw.OccurredAt = sig.OccurredAt.UTC().Format(time.RFC3339Nano) + } + w.Signals = append(w.Signals, sw) + } + for k, pc := range s.Personas { + w.Personas = append(w.Personas, personaWire{ + Persona: k.Persona, Channel: k.Channel, Sessions: pc.Sessions, + Corrections: pc.Corrections, Errors: pc.Errors, Workarounds: pc.Workarounds, + Deferrals: pc.Deferrals, Patterns: pc.Patterns, InputTokens: pc.InputTokens, + OutputTokens: pc.OutputTokens, CostUSD: usdPtrString(pc.CostUSD), + }) + } + sort.Slice(w.Personas, func(i, j int) bool { + if w.Personas[i].Persona != w.Personas[j].Persona { + return w.Personas[i].Persona < w.Personas[j].Persona + } + return w.Personas[i].Channel < w.Personas[j].Channel + }) + if s.Spend != nil { + w.Spend = &spendWire{ + Total: usdPtrString(s.Spend.Total), SessionsWithStats: s.Spend.SessionsWithStats, + SessionsWithCost: s.Spend.SessionsWithCost, InputTokens: s.Spend.InputTokens, + OutputTokens: s.Spend.OutputTokens, RoleCosts: usdStrings(s.Spend.RoleCosts), + ModelCosts: usdStrings(s.Spend.ModelCosts), + } + } + if a := s.ArchiveSpend; a != nil { + aw := &archiveWire{Week: periodToWire(a.Week), WeekFrom: a.WeekFrom, WeekTo: a.WeekTo, Timezone: a.Timezone} + if a.Yesterday != nil { + y := periodToWire(*a.Yesterday) + aw.Yesterday = &y + } + w.ArchiveSpend = aw + } + b, err := json.Marshal(w, json.Deterministic(true)) + if err != nil { + return nil, fmt.Errorf("encoding friction snapshot: %w", err) + } + return b, nil +} + +func DecodeSnapshot(b []byte) (friction.DigestSnapshot, error) { + var w snapshotWire + if err := json.Unmarshal(b, &w); err != nil { + return friction.DigestSnapshot{}, fmt.Errorf("decoding friction snapshot: %w", err) + } + s := friction.DigestSnapshot{ + Date: w.Date, Timezone: w.Timezone, RulesVersion: w.RulesVersion, + P0Alerts: w.P0Alerts, SessionsScanned: w.SessionsScanned, + Personas: make(map[friction.PersonaKey]*friction.PersonaCounts, len(w.Personas)), + } + for _, sw := range w.Signals { + sig := friction.Signal{ + Kind: friction.Kind(sw.Kind), SubjectID: sw.SubjectID, SubjectKind: sw.SubjectKind, + Dims: friction.Dims{Seat: sw.Seat, Agent: sw.Agent, Machine: sw.Machine, Persona: sw.Persona, Channel: sw.Channel}, + Detector: sw.Detector, Text: sw.Text, ToolName: sw.ToolName, Label: sw.Label, + Evidence: sw.Evidence, Ordinal: sw.Ordinal, CallIndex: sw.CallIndex, Seq: sw.Seq, + } + if sw.OccurredAt != "" { + ts, err := time.Parse(time.RFC3339Nano, sw.OccurredAt) + if err != nil { + return friction.DigestSnapshot{}, fmt.Errorf("snapshot occurred_at: %w", err) + } + sig.OccurredAt = ts.UTC() + } + s.Signals = append(s.Signals, sig) + } + for _, pw := range w.Personas { + cost, err := parseUSDPtr(pw.CostUSD) + if err != nil { + return friction.DigestSnapshot{}, err + } + s.Personas[friction.PersonaKey{Persona: pw.Persona, Channel: pw.Channel}] = &friction.PersonaCounts{ + Sessions: pw.Sessions, Corrections: pw.Corrections, Errors: pw.Errors, + Workarounds: pw.Workarounds, Deferrals: pw.Deferrals, Patterns: pw.Patterns, + InputTokens: pw.InputTokens, OutputTokens: pw.OutputTokens, CostUSD: cost, + } + } + if sw := w.Spend; sw != nil { + total, err := parseUSDPtr(sw.Total) + if err != nil { + return friction.DigestSnapshot{}, err + } + roles, err := parseUSDMap(sw.RoleCosts) + if err != nil { + return friction.DigestSnapshot{}, err + } + models, err := parseUSDMap(sw.ModelCosts) + if err != nil { + return friction.DigestSnapshot{}, err + } + s.Spend = &friction.SpendSummary{ + Total: total, SessionsWithStats: sw.SessionsWithStats, + SessionsWithCost: sw.SessionsWithCost, InputTokens: sw.InputTokens, + OutputTokens: sw.OutputTokens, RoleCosts: roles, ModelCosts: models, + } + } + if aw := w.ArchiveSpend; aw != nil { + week, err := periodFromWire(aw.Week) + if err != nil { + return friction.DigestSnapshot{}, err + } + a := &friction.ArchiveSpend{Week: week, WeekFrom: aw.WeekFrom, WeekTo: aw.WeekTo, Timezone: aw.Timezone} + if aw.Yesterday != nil { + y, err := periodFromWire(*aw.Yesterday) + if err != nil { + return friction.DigestSnapshot{}, err + } + a.Yesterday = &y + } + s.ArchiveSpend = a + } + costs, err := parseUSDMap(w.RecurrenceCosts) + if err != nil { + return friction.DigestSnapshot{}, err + } + s.RecurrenceCosts = costs + return s, nil +} diff --git a/internal/friction/review/snapshot_test.go b/internal/friction/review/snapshot_test.go new file mode 100644 index 0000000000..05848d3254 --- /dev/null +++ b/internal/friction/review/snapshot_test.go @@ -0,0 +1,89 @@ +package review + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/friction" +) + +func sampleSnapshot(t *testing.T) friction.DigestSnapshot { + t.Helper() + cost := friction.USDFromMicros(1500000) + total, err := friction.ParseUSD("4.2") + require.NoError(t, err) + ord := 3 + return friction.DigestSnapshot{ + Date: "2026-09-15", Timezone: "Asia/Tokyo", RulesVersion: "friction-v1", + Signals: []friction.Signal{ + { + Kind: friction.KindCorrection, SubjectID: "s1", Detector: "correction.chat", + Dims: friction.Dims{Agent: "claude", Machine: "laptop-01", Persona: "helper", Channel: "general"}, + Text: "no, don't do that", Ordinal: &ord, Seq: 0, SubjectKind: friction.SubjectSession, + OccurredAt: time.Date(2026, 9, 15, 1, 2, 3, 400000000, time.UTC), + }, + { + Kind: friction.KindError, SubjectID: "s2", Detector: "error", ToolName: "Bash", + Text: "exit 1", CallIndex: &ord, Seq: 1, Dims: friction.Dims{Seat: "seat-02"}, + }, + { + Kind: friction.KindFrustration, SubjectID: "s2", SubjectKind: friction.SubjectSession, + Detector: "frustration", Text: "this is still broken", Ordinal: &ord, Seq: 2, + }, + { + Kind: friction.KindInterruption, SubjectID: "s2", SubjectKind: friction.SubjectSession, + Detector: "interruption", Ordinal: &ord, Seq: 3, + }, + { + Kind: friction.KindError, SubjectID: "ci-run-7:nightly_check", SubjectKind: friction.SubjectDiagnostic, + Detector: "error", ToolName: "nightly_check", Text: "ci-run-7:nightly_check: failed", Seq: 0, + }, + }, + P0Alerts: map[string][]string{"bash": {"s1", "s2", "s3"}}, + Personas: map[friction.PersonaKey]*friction.PersonaCounts{ + {Persona: "helper", Channel: "general"}: {Sessions: 1, Corrections: 1, InputTokens: 12000, OutputTokens: 300}, + {Persona: "concierge"}: {Sessions: 1, InputTokens: 800, OutputTokens: 40, CostUSD: &cost}, + }, + Spend: &friction.SpendSummary{ + Total: &total, SessionsWithStats: 3, SessionsWithCost: 2, + InputTokens: 12345, OutputTokens: 678, + RoleCosts: map[string]friction.USD{"(root)": total}, + ModelCosts: map[string]friction.USD{"claude-opus-4-8": total}, + }, + ArchiveSpend: &friction.ArchiveSpend{ + Week: friction.PeriodSpend{Total: cost, Days: 1, Agents: map[string]friction.USD{"codex": cost}, Models: map[string]friction.USD{}}, + WeekFrom: "2026-09-08", WeekTo: "2026-09-14", Timezone: "Asia/Tokyo", + }, + SessionsScanned: 3, + RecurrenceCosts: map[string]friction.USD{"fl1:abc": cost}, + } +} + +func TestSnapshotRoundTrip(t *testing.T) { + in := sampleSnapshot(t) + encoded, err := EncodeSnapshot(in) + require.NoError(t, err) + again, err := EncodeSnapshot(in) + require.NoError(t, err) + assert.Equal(t, encoded, again, "encoding is deterministic") + + out, err := DecodeSnapshot(encoded) + require.NoError(t, err) + assert.Equal(t, string(friction.RenderMarkdown(in, friction.RenderLinks{})), + string(friction.RenderMarkdown(out, friction.RenderLinks{})), + "a decoded snapshot re-renders byte-identically") + require.Len(t, out.Signals, 5) + assert.Equal(t, in.Signals[0].OccurredAt, out.Signals[0].OccurredAt) + assert.Equal(t, friction.KindInterruption, out.Signals[3].Kind) + assert.Equal(t, friction.SubjectDiagnostic, out.Signals[4].SubjectKind) + assert.Equal(t, "1.500000", out.Personas[friction.PersonaKey{Persona: "concierge"}].CostUSD.String()) + assert.Equal(t, "4.2", out.Spend.Total.String(), "scale preserved") + assert.Equal(t, "1.500000", out.RecurrenceCosts["fl1:abc"].String()) + assert.True(t, out.Signals[1].OccurredAt.IsZero()) + + _, err = DecodeSnapshot([]byte("{")) + require.Error(t, err) +} From a474c29c3b4b0a9e49a2e48a7badcc72d986894d Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:52:43 +0000 Subject: [PATCH 47/62] feat(friction): build dated digests from stored findings --- go.mod | 2 +- internal/db/friction_digest.go | 2 + internal/db/friction_digest_test.go | 14 + internal/db/friction_usage.go | 2 +- internal/db/friction_usage_test.go | 7 + internal/friction/review/runner.go | 387 +++++++++++++ internal/friction/review/runner_test.go | 521 ++++++++++++++++++ internal/postgres/friction_digest.go | 13 +- .../postgres/friction_digest_pgtest_test.go | 8 + 9 files changed, 949 insertions(+), 7 deletions(-) create mode 100644 internal/friction/review/runner.go create mode 100644 internal/friction/review/runner_test.go diff --git a/go.mod b/go.mod index 1ca5db00e3..73924f9a34 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/gofrs/flock v0.13.1 github.com/google/go-cmp v0.7.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/jellydator/ttlcache/v3 v3.4.1 github.com/klauspost/compress v1.20.0 @@ -107,7 +108,6 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/google/flatbuffers v25.12.19+incompatible // indirect github.com/google/jsonschema-go v0.4.3 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/internal/db/friction_digest.go b/internal/db/friction_digest.go index 2df17ac804..2c8d76c07d 100644 --- a/internal/db/friction_digest.go +++ b/internal/db/friction_digest.go @@ -22,6 +22,7 @@ var ErrFrictionDigestConflict = errors.New("friction digest was written concurre type FrictionSubject struct { SubjectID, SubjectKind, Machine, FilePath, Agent string IsSubAgent bool + AlreadyDigested bool LastActivity time.Time Dims FrictionSessionDims RulesVersion string @@ -131,6 +132,7 @@ func (db *DB) FrictionSubjectsForDate( } s.SubjectKind = friction.SubjectSession s.IsSubAgent = sub != 0 + s.AlreadyDigested = digestDate != "" s.Dims.SessionID = s.SubjectID s.Dims.ReviewExcluded = excluded != 0 s.Machine = machine diff --git a/internal/db/friction_digest_test.go b/internal/db/friction_digest_test.go index 872225e045..29a259d0e4 100644 --- a/internal/db/friction_digest_test.go +++ b/internal/db/friction_digest_test.go @@ -169,6 +169,20 @@ func TestFrictionSubjectsCarryDimsAndRole(t *testing.T) { assert.False(t, byID["root"].IsSubAgent) } +func TestFrictionSubjectsIdentifyPriorMembership(t *testing.T) { + d := testDB(t) + seedFrictionSession(t, d, "old", "", "2026-09-15T01:00:00Z", nil) + seedFrictionSession(t, d, "new", "", "2026-09-15T02:00:00Z", nil) + require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), + []FrictionDigestSubject{{SubjectID: "old", Date: "2026-09-15", SubjectKind: friction.SubjectSession}}, nil)) + got, err := d.FrictionSubjectsForDate(t.Context(), "2026-09-15", time.UTC, true) + require.NoError(t, err) + require.Len(t, got, 2) + byID := map[string]FrictionSubject{got[0].SubjectID: got[0], got[1].SubjectID: got[1]} + assert.True(t, byID["old"].AlreadyDigested) + assert.False(t, byID["new"].AlreadyDigested) +} + func testDigest(date string, revision int) FrictionDigest { md := []byte("# Friction Log — " + date + "\n") sum := sha256.Sum256(md) diff --git a/internal/db/friction_usage.go b/internal/db/friction_usage.go index d37a0d15b7..5852523436 100644 --- a/internal/db/friction_usage.go +++ b/internal/db/friction_usage.go @@ -76,7 +76,7 @@ func FrictionArchiveSpendFromDaily( // review's stats form. The bool is false when the session carries no // token or cost data (jilog load_stats → None). func FrictionUsageFromSessionUsage(u *SessionUsage) (friction.SessionUsage, bool) { - if u == nil || (!u.HasTokenData && !u.HasCost) { + if u == nil || (!u.HasTokenData && !u.HasCost && len(u.Breakdown) == 0) { return friction.SessionUsage{}, false } out := friction.SessionUsage{SubjectID: u.SessionID, ModelCosts: map[string]friction.USD{}} diff --git a/internal/db/friction_usage_test.go b/internal/db/friction_usage_test.go index 0c3a19048f..29b9976fba 100644 --- a/internal/db/friction_usage_test.go +++ b/internal/db/friction_usage_test.go @@ -89,6 +89,13 @@ func TestFrictionUsageFromSessionUsage(t *testing.T) { }{ {name: "nil", in: nil}, {name: "no token or cost data", in: &SessionUsage{}}, + { + name: "breakdown without session summary counters", + in: &SessionUsage{Breakdown: []SessionUsageBreakdownEntry{ + {Model: "unpriced", InputTokens: 12, OutputTokens: 3}, + }}, + ok: true, input: 12, output: 3, models: map[string]string{}, + }, { name: "tokens only", in: &SessionUsage{HasTokenData: true, Breakdown: []SessionUsageBreakdownEntry{ diff --git a/internal/friction/review/runner.go b/internal/friction/review/runner.go new file mode 100644 index 0000000000..9ca2e7d845 --- /dev/null +++ b/internal/friction/review/runner.go @@ -0,0 +1,387 @@ +package review + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/google/uuid" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/timeutil" +) + +// Store is the persistence the review needs (spec §26.5). Both primary +// backends implement it; read-only mirrors return db.ErrReadOnly. +type Store interface { + FrictionSubjectsForDate(ctx context.Context, date string, loc *time.Location, includeDigested bool) ([]db.FrictionSubject, error) + FrictionFindingsForSubjects(ctx context.Context, subjectIDs []string) ([]db.FrictionFinding, error) + FrictionUsageForSessions(ctx context.Context, sessionIDs []string) (map[string]friction.SessionUsage, error) + FrictionArchiveSpend(ctx context.Context, from, to string, loc *time.Location) (*friction.ArchiveSpend, error) + SaveFrictionDigest(ctx context.Context, d db.FrictionDigest, subjects []db.FrictionDigestSubject, patterns []db.FrictionPatternUpdate) error + GetFrictionDigest(ctx context.Context, date string) (*db.FrictionDigest, error) + LatestFrictionDigestDate(ctx context.Context) (string, error) + EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) + UpdateFrictionDigestRender(ctx context.Context, date string, markdown, summaryJSON []byte, revision int) error +} + +// DiagnosticSource yields generic diagnostic error signals dated on a local +// date (spec §11.4). Contract: SubjectID is the diagnostic identity, and the +// source never returns an identity already recorded in +// friction_digest_sessions for a different date. +type DiagnosticSource interface { + DiagnosticSignalsForDate(ctx context.Context, date string, loc *time.Location) ([]friction.Signal, error) +} + +// Runner builds digests. Filer, Ledger and AutoFile from spec §26.5 arrive +// with the Kata and ledger PRs; Diagnostics is nil until PR 17 wires the +// ledger-backed source. +type Runner struct { + Store Store + Diagnostics DiagnosticSource + Loc *time.Location + Now func() time.Time + BackfillDays int + PublicURL string +} + +type BuildOptions struct{ Rebuild, DryRun bool } + +type Report struct { + Date string + Snapshot friction.DigestSnapshot + Meta friction.SummaryMeta + Written bool +} + +var ( + ErrIncompleteDate = errors.New("friction: date is not complete in the review time zone") + ErrStaleSubjects = errors.New("friction: findings not yet computed for some sessions") +) + +// StaleGrace is how long after a date ends the build waits for sessions +// whose findings are not computed yet before building without them. +const StaleGrace = 48 * time.Hour + +func (r *Runner) loc() *time.Location { + if r.Loc == nil { + return time.UTC + } + return r.Loc +} + +func (r *Runner) now() time.Time { + if r.Now == nil { + return time.Now() + } + return r.Now() +} + +// DigestURL is summary_json's digest_path for a written digest (spec §9.2). +func DigestURL(publicURL, date string) string { + if u := strings.TrimRight(strings.TrimSpace(publicURL), "/"); u != "" { + return u + "/friction/" + date + } + return "friction:" + date +} + +func (r *Runner) meta(date string, dryRun bool) friction.SummaryMeta { + if dryRun { + return friction.SummaryMeta{} + } + p := DigestURL(r.PublicURL, date) + return friction.SummaryMeta{DigestPath: &p} +} + +func sortSubjects(subjects []db.FrictionSubject) { + sort.SliceStable(subjects, func(i, j int) bool { + a, b := subjects[i], subjects[j] + if a.Machine != b.Machine { + return a.Machine < b.Machine + } + if a.FilePath != b.FilePath { + return a.FilePath < b.FilePath + } + return a.SubjectID < b.SubjectID + }) +} + +func signalFromFinding(f db.FrictionFinding, s db.FrictionSubject) friction.Signal { + dims := friction.Dims{Seat: s.Dims.Seat, Agent: s.Agent, Machine: s.Machine} + if s.Dims.Persona != "" { + dims.Persona, dims.Channel = s.Dims.Persona, s.Dims.Channel + } + sig := friction.Signal{ + Kind: friction.Kind(f.Kind), SubjectID: f.SessionID, SubjectKind: s.SubjectKind, Dims: dims, + Detector: f.Detector, Text: f.Text, ToolName: f.ToolName, Label: f.Label, + Evidence: f.Evidence, Ordinal: f.MessageOrdinal, CallIndex: f.CallIndex, Seq: f.Seq, + } + if f.OccurredAt != nil { + sig.OccurredAt = *f.OccurredAt + } + return sig +} + +// patternUpdates emits one update per (fingerprint, subject), skipping +// subjects already counted by an earlier build of the same date. +func patternUpdates(date string, sigs []friction.Signal, skip map[string]bool) []db.FrictionPatternUpdate { + type key struct{ fp, subject string } + idx := map[key]int{} + var out []db.FrictionPatternUpdate + for _, s := range sigs { + if skip[s.SubjectID] { + continue + } + k := key{s.Fingerprint(), s.SubjectID} + if i, ok := idx[k]; ok { + out[i].Occurrences++ + if s.Ordinal != nil { + out[i].Ordinal = s.Ordinal + } + continue + } + idx[k] = len(out) + out = append(out, db.FrictionPatternUpdate{ + Fingerprint: k.fp, Kind: string(s.Kind), Title: s.Title(), Date: date, + SubjectID: s.SubjectID, Ordinal: s.Ordinal, Occurrences: 1, + }) + } + return out +} + +// diagnosticSignals returns the date's diagnostic signals ordered by +// identity then Seq, forcing SubjectKind. On a rebuild, identities recorded +// by the earlier build are kept even if the source no longer returns them. +// A source error fails the build (the poller retries): skipping would drop +// those diagnostics from a date that is then never rebuilt. +func (r *Runner) diagnosticSignals( + ctx context.Context, date string, loc *time.Location, prior *friction.DigestSnapshot, +) ([]friction.Signal, error) { + var out []friction.Signal + if r.Diagnostics != nil { + sigs, err := r.Diagnostics.DiagnosticSignalsForDate(ctx, date, loc) + if err != nil { + return nil, fmt.Errorf("friction diagnostics for %s: %w", date, err) + } + out = append(out, sigs...) + } + fresh := map[string]bool{} + for _, s := range out { + fresh[s.SubjectID] = true + } + if prior != nil { + for _, s := range prior.Signals { + if s.SubjectKind == friction.SubjectDiagnostic && !fresh[s.SubjectID] { + out = append(out, s) + } + } + } + for i := range out { + out[i].SubjectKind = friction.SubjectDiagnostic + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].SubjectID != out[j].SubjectID { + return out[i].SubjectID < out[j].SubjectID + } + return out[i].Seq < out[j].Seq + }) + return out, nil +} + +// BuildDate ports run_review for one completed local date (spec §8.3). +func (r *Runner) BuildDate(ctx context.Context, date string, opts BuildOptions) (Report, error) { + loc := r.loc() + if !timeutil.IsValidDate(date) { + return Report{}, fmt.Errorf("friction: invalid date %q (want YYYY-MM-DD)", date) + } + if date >= localDate(r.now(), loc) && !opts.DryRun { + return Report{}, fmt.Errorf("%w: %s", ErrIncompleteDate, date) + } + existing, err := r.Store.GetFrictionDigest(ctx, date) + if err != nil { + return Report{}, err + } + if existing != nil && !opts.Rebuild && !opts.DryRun { + snap, err := DecodeSnapshot(existing.SnapshotJSON) + if err != nil { + return Report{}, err + } + return Report{Date: date, Snapshot: snap, Meta: r.meta(date, false)}, nil + } + + all, err := r.Store.FrictionSubjectsForDate(ctx, date, loc, opts.Rebuild) + if err != nil { + return Report{}, err + } + sortSubjects(all) + subjects := make([]db.FrictionSubject, 0, len(all)) + stale := 0 + for _, s := range all { + switch { + case s.Dims.ReviewExcluded: + case s.RulesVersion != friction.RulesVersion: + stale++ + default: + subjects = append(subjects, s) + } + } + if stale > 0 && !opts.DryRun { + _, dayEnd, err := db.FrictionDayBounds(date, loc) + if err != nil { + return Report{}, err + } + if r.now().Before(dayEnd.Add(StaleGrace)) { + return Report{}, fmt.Errorf("%w: %d session(s) on %s", ErrStaleSubjects, stale, date) + } + log.Printf("friction review: %s: building without %d session(s) whose findings are not computed", date, stale) + } + + ids := make([]string, len(subjects)) + for i, s := range subjects { + ids[i] = s.SubjectID + } + findings, err := r.Store.FrictionFindingsForSubjects(ctx, ids) + if err != nil { + return Report{}, err + } + bySubject := map[string][]db.FrictionFinding{} + for _, f := range findings { + bySubject[f.SessionID] = append(bySubject[f.SessionID], f) + } + usage, err := r.Store.FrictionUsageForSessions(ctx, ids) + if err != nil { + // load_stats failures only warn (digest.rs:471-491). + log.Printf("friction review: %s: usage unavailable: %v", date, err) + usage = nil + } + + fold := newSpendFold() + subAgent := map[string]bool{} + var signals, errorSignals []friction.Signal + for _, s := range subjects { + subAgent[s.SubjectID] = s.IsSubAgent + rows := bySubject[s.SubjectID] + sort.SliceStable(rows, func(i, j int) bool { return rows[i].Seq < rows[j].Seq }) + sigs := make([]friction.Signal, 0, len(rows)) + for _, f := range rows { + sig := signalFromFinding(f, s) + sigs = append(sigs, sig) + if sig.Kind == friction.KindError { + errorSignals = append(errorSignals, sig) + } + } + var key *friction.PersonaKey + if s.Dims.Persona != "" { + key = &friction.PersonaKey{Persona: s.Dims.Persona, Channel: s.Dims.Channel} + } + fold.countSession(key, sigs) + if u, ok := usage[s.SubjectID]; ok { + u.Role = "(root)" + if s.IsSubAgent { + u.Role = "subagent" + } + fold.addUsage(key, u) + } + signals = append(signals, sigs...) + } + + // Diagnostic subjects come last, ordered by identity (spec §8.3 step 1). + // A rebuild keeps the diagnostic members the first build recorded. + var prior *friction.DigestSnapshot + if existing != nil { + snap, err := DecodeSnapshot(existing.SnapshotJSON) + if err != nil { + return Report{}, err + } + prior = &snap + } + diagnostics, err := r.diagnosticSignals(ctx, date, loc, prior) + if err != nil { + return Report{}, err + } + diagIDs := []string{} + for _, sig := range diagnostics { + if len(diagIDs) == 0 || diagIDs[len(diagIDs)-1] != sig.SubjectID { + diagIDs = append(diagIDs, sig.SubjectID) + } + } + signals = append(signals, diagnostics...) + + from, to, err := db.FrictionArchiveWindow(date) + if err != nil { + return Report{}, err + } + archive, err := r.Store.FrictionArchiveSpend(ctx, from, to, loc) + if err != nil { + log.Printf("friction review: %s: archive spend hidden: %v", date, err) + archive = nil + } + + snap := friction.DigestSnapshot{ + Date: date, Timezone: loc.String(), RulesVersion: friction.RulesVersion, + Signals: signals, + P0Alerts: friction.DetectP0Alerts(errorSignals, func(id string) bool { return subAgent[id] }), + Personas: fold.personas, Spend: fold.summary(), ArchiveSpend: archive, + SessionsScanned: len(subjects) + len(diagIDs), + } + meta := r.meta(date, opts.DryRun) + if opts.DryRun { + return Report{Date: date, Snapshot: snap, Meta: meta}, nil + } + + md := friction.RenderMarkdown(snap, friction.RenderLinks{}) + summary := friction.RenderSummaryJSON(snap, meta) + snapJSON, err := EncodeSnapshot(snap) + if err != nil { + return Report{}, err + } + runID, err := uuid.NewV7() + if err != nil { + return Report{}, fmt.Errorf("friction run id: %w", err) + } + revision := 1 + counted := map[string]bool{} + for _, subject := range subjects { + if subject.AlreadyDigested { + counted[subject.SubjectID] = true + } + } + if existing != nil { + revision = existing.Revision + 1 + for _, s := range prior.Signals { + if s.SubjectKind == friction.SubjectDiagnostic { + counted[s.SubjectID] = true + } + } + } + sum := sha256.Sum256(md) + digest := db.FrictionDigest{ + Date: date, Timezone: loc.String(), RulesVersion: friction.RulesVersion, + BuiltAt: r.now().UTC(), Revision: revision, SessionsScanned: snap.SessionsScanned, + SnapshotJSON: snapJSON, SummaryJSON: summary, Markdown: md, + MarkdownSHA256: hex.EncodeToString(sum[:]), RunID: runID.String(), + } + members := make([]db.FrictionDigestSubject, 0, len(subjects)+len(diagIDs)) + for _, s := range subjects { + members = append(members, db.FrictionDigestSubject{SubjectID: s.SubjectID, Date: date, SubjectKind: s.SubjectKind}) + } + for _, id := range diagIDs { + members = append(members, db.FrictionDigestSubject{SubjectID: id, Date: date, SubjectKind: friction.SubjectDiagnostic}) + } + err = r.Store.SaveFrictionDigest(ctx, digest, members, patternUpdates(date, signals, counted)) + if errors.Is(err, db.ErrFrictionDigestConflict) { + log.Printf("friction review: %s: another builder wrote this digest first", date) + return Report{Date: date, Snapshot: snap, Meta: meta}, nil + } + if err != nil { + return Report{}, err + } + return Report{Date: date, Snapshot: snap, Meta: meta, Written: true}, nil +} diff --git a/internal/friction/review/runner_test.go b/internal/friction/review/runner_test.go new file mode 100644 index 0000000000..08167d5dd3 --- /dev/null +++ b/internal/friction/review/runner_test.go @@ -0,0 +1,521 @@ +package review_test + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/money" +) + +var reviewNow = time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + +type subject struct { + id, ended, machine, agent string + parent string + dims *db.FrictionSessionDims + findings []db.FrictionFinding + rules string + usage []db.UsageEvent +} + +func seed(t *testing.T, d *db.DB, subjects ...subject) { + t.Helper() + for _, s := range subjects { + ended := s.ended + machine, agent := s.machine, s.agent + if machine == "" { + machine = "machine-a" + } + if agent == "" { + agent = "claude" + } + dbtest.SeedSession(t, d, s.id, "proj", func(sess *db.Session) { + sess.EndedAt = &ended + sess.Machine = machine + sess.Agent = agent + if s.parent != "" { + sess.ParentSessionID = dbtest.Ptr(s.parent) + sess.RelationshipType = "subagent" + } + }) + rules := s.rules + if rules == "" { + rules = friction.RulesVersion + } + dims := s.dims + if dims != nil { + dims.SessionID = s.id + } + require.NoError(t, d.ReplaceSessionFriction(t.Context(), s.id, s.findings, dims, rules, "h-"+s.id)) + if len(s.usage) > 0 { + require.NoError(t, d.ReplaceSessionUsageEvents(t.Context(), s.id, s.usage)) + } + } +} + +func finding(sid string, kind friction.Kind, detector, text, label, tool string, ord, seq int) db.FrictionFinding { + sig := friction.Signal{ + Kind: kind, SubjectID: sid, Detector: detector, Text: text, + Label: label, ToolName: tool, Ordinal: dbtest.Ptr(ord), Seq: seq, + } + return db.FrictionFinding{ + SessionID: sid, Kind: string(kind), Detector: detector, + MessageOrdinal: dbtest.Ptr(ord), ToolName: tool, Label: label, Text: text, + Title: sig.Title(), Fingerprint: sig.Fingerprint(), Seq: seq, RulesVersion: friction.RulesVersion, + } +} + +func correction(sid, text string) db.FrictionFinding { + return finding(sid, friction.KindCorrection, "correction.coding", text, "", "", 1, 0) +} + +func tokens(in, out int, cost string) []db.UsageEvent { + e := db.UsageEvent{ + Source: "shutdown", Model: "unpriced-test-model", InputTokens: in, + OutputTokens: out, OccurredAt: "2026-09-15T01:00:00Z", DedupKey: "k", + } + if cost != "" { + c := money.MustParseDollars(cost) + e.Cost, e.CostStatus = &c, "exact" + } + return []db.UsageEvent{e} +} + +func newRunner(d *db.DB) *review.Runner { + return &review.Runner{Store: d, Loc: time.UTC, Now: func() time.Time { return reviewNow }, BackfillDays: 7} +} + +func rowCount(t *testing.T, d *db.DB, table string) int { + t.Helper() + raw, err := sql.Open("sqlite3", d.Path()) + require.NoError(t, err) + defer raw.Close() + var n int + require.NoError(t, raw.QueryRowContext(t.Context(), "SELECT count(*) FROM "+table).Scan(&n)) + return n +} + +func stored(t *testing.T, d *db.DB, date string) *db.FrictionDigest { + t.Helper() + got, err := d.GetFrictionDigest(t.Context(), date) + require.NoError(t, err) + return got +} + +func TestBuildDate(t *testing.T) { + const day = "2026-09-15" + tests := []struct { + name string + run func(t *testing.T, d *db.DB, r *review.Runner) + }{ + {name: "run_review_writes_digest_when_file_absent_even_with_no_sessions", run: func(t *testing.T, d *db.DB, r *review.Runner) { + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Equal(t, 0, rep.Snapshot.SessionsScanned) + got := stored(t, d, day) + require.NotNil(t, got, "first build of a date writes even an empty digest") + assert.Contains(t, string(got.Markdown), "signals_captured: 0") + assert.True(t, strings.HasSuffix(string(got.Markdown), "## Interruptions\n\n_No interruptions detected._\n\n"), + "with no spend the digest ends with the Interruptions placeholder (spec §9.1)") + assert.Equal(t, 1, got.Revision) + assert.NotEmpty(t, got.RunID) + }}, + {name: "run_review_preserves_existing_digest_when_nothing_scanned", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seeded := db.FrictionDigest{ + Date: day, Timezone: "UTC", RulesVersion: friction.RulesVersion, + BuiltAt: reviewNow, Revision: 1, SnapshotJSON: []byte(`{"date":"` + day + `"}`), + SummaryJSON: []byte("{}\n"), Markdown: []byte("SEEDED-FROM-EARLIER-RUN\n"), + MarkdownSHA256: "x", RunID: "r", + } + require.NoError(t, d.SaveFrictionDigest(t.Context(), seeded, nil, nil)) + seed(t, d, subject{id: "late", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("late", "no, use the other file please")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.False(t, rep.Written, "a plain build of an existing date is a no-op") + assert.Equal(t, "SEEDED-FROM-EARLIER-RUN\n", string(stored(t, d, day).Markdown)) + }}, + {name: "processed_sessions_persist_across_load", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, rep.Snapshot.SessionsScanned) + // The session is resumed; its last activity moves to the next date. + seed(t, d, subject{id: "s1", ended: "2026-09-16T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + next, err := r.BuildDate(t.Context(), "2026-09-16", review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 0, next.Snapshot.SessionsScanned, "a subject enters one digest only") + }}, + {name: "rebuild_keeps_membership", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "a", ended: "2026-09-15T10:00:00Z", findings: []db.FrictionFinding{correction("a", "no, use the other file please")}}) + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + seed(t, d, + subject{id: "a", ended: "2026-09-17T10:00:00Z", findings: []db.FrictionFinding{correction("a", "no, use the other file please")}}, + subject{id: "b", ended: "2026-09-15T11:00:00Z", findings: []db.FrictionFinding{correction("b", "that is wrong, revert it now")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{Rebuild: true}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Equal(t, 2, rep.Snapshot.SessionsScanned) + got := stored(t, d, day) + assert.Equal(t, 2, got.Revision) + assert.Contains(t, string(got.Markdown), "`a`") + assert.Contains(t, string(got.Markdown), "`b`") + // Pattern counts were not double-counted for the kept member. + var occ int + raw, err := sql.Open("sqlite3", d.Path()) + require.NoError(t, err) + defer raw.Close() + fp := correction("a", "no, use the other file please").Fingerprint + require.NoError(t, raw.QueryRowContext(t.Context(), + `SELECT occurrence_count FROM friction_patterns WHERE fingerprint = ?`, fp).Scan(&occ)) + assert.Equal(t, 1, occ) + }}, + {name: "rebuild_does_not_count_new_findings_on_old_member", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "quiet", ended: "2026-09-15T10:00:00Z"}) + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns")) + require.NoError(t, d.ReplaceSessionFriction(t.Context(), "quiet", + []db.FrictionFinding{correction("quiet", "no, use the other file please")}, nil, + friction.RulesVersion, "h-quiet-new")) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{Rebuild: true}) + require.NoError(t, err) + require.Len(t, rep.Snapshot.Signals, 1) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns"), "old member's new findings never change recurrence") + }}, + {name: "dry_run_writes_nothing", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{DryRun: true}) + require.NoError(t, err) + assert.False(t, rep.Written) + assert.Nil(t, rep.Meta.DigestPath, "digest_path is null on dry run") + assert.Len(t, rep.Snapshot.Signals, 1) + assert.Nil(t, stored(t, d, day)) + assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns")) + real, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, real.Snapshot.SessionsScanned, "dry run did not consume the session") + }}, + {name: "run_review_stamps_dims_and_renders_personas_section", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, + subject{ + id: "sess-helper", ended: "2026-09-15T01:00:00Z", + dims: &db.FrictionSessionDims{Persona: "helper", Channel: "general", DimsSource: "nanoclaw"}, + findings: []db.FrictionFinding{finding("sess-helper", friction.KindCorrection, "correction.chat", "no, use the gh cli for calendar", "", "", 1, 0)}, + }, + subject{ + id: "sess-director", ended: "2026-09-15T02:00:00Z", + dims: &db.FrictionSessionDims{Persona: "director", Channel: "Event Group", DimsSource: "nanoclaw"}, + }, + subject{ + id: "sess-coding", ended: "2026-09-15T03:00:00Z", + findings: []db.FrictionFinding{correction("sess-coding", "no, use the gh cli for calendar")}, + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + var fleet, code friction.Signal + for _, s := range rep.Snapshot.Signals { + switch s.SubjectID { + case "sess-helper": + fleet = s + case "sess-coding": + code = s + } + } + assert.Equal(t, "helper", fleet.Dims.Persona) + assert.Equal(t, "general", fleet.Dims.Channel) + assert.Empty(t, code.Dims.Persona) + require.Len(t, rep.Snapshot.Personas, 2) + h := rep.Snapshot.Personas[friction.PersonaKey{Persona: "helper", Channel: "general"}] + assert.Equal(t, [2]int{1, 1}, [2]int{h.Sessions, h.Corrections}) + dir := rep.Snapshot.Personas[friction.PersonaKey{Persona: "director", Channel: "Event Group"}] + assert.Equal(t, [2]int{1, 0}, [2]int{dir.Sessions, dir.Corrections}) + body := string(stored(t, d, day).Markdown) + assert.Contains(t, body, "## Personas") + assert.Contains(t, body, "- `helper@general`: 1 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns (1 session(s))") + assert.Contains(t, body, "- `director@Event Group`: no signals (1 session(s))") + assert.Contains(t, body, "- `helper@general` `agent:claude` `machine:machine-a` `sess-helper` — ") + assert.Contains(t, body, "- `agent:claude` `machine:machine-a` `sess-coding` — ") + }}, + {name: "personas_rollup_carries_tokens_and_optional_cost", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, + subject{ + id: "sess-cell", ended: "2026-09-15T01:00:00Z", + dims: &db.FrictionSessionDims{Persona: "helper", Channel: "general", DimsSource: "nanoclaw"}, + findings: []db.FrictionFinding{finding("sess-cell", friction.KindCorrection, "correction.chat", "helper no, answer in the thread", "", "", 1, 0)}, + usage: tokens(12000, 300, ""), + }, + subject{ + id: "sess-priced", ended: "2026-09-15T02:00:00Z", + dims: &db.FrictionSessionDims{Persona: "concierge", DimsSource: "nanoclaw"}, + usage: tokens(800, 40, "1.5"), + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + h := rep.Snapshot.Personas[friction.PersonaKey{Persona: "helper", Channel: "general"}] + require.NotNil(t, h) + assert.Equal(t, uint64(12000), h.InputTokens) + assert.Equal(t, uint64(300), h.OutputTokens) + assert.Nil(t, h.CostUSD, "unpriced sessions stay tokens-only") + c := rep.Snapshot.Personas[friction.PersonaKey{Persona: "concierge"}] + require.NotNil(t, c) + assert.Equal(t, 1, c.Sessions) + require.NotNil(t, c.CostUSD) + assert.Equal(t, "1.500000", c.CostUSD.String()) + body := string(stored(t, d, day).Markdown) + assert.Contains(t, body, "12000 in / 300 out tokens") + // Archive costs are microdollars (scale 6); jilog printed $1.50 + // for a transcript-reported "1.5". Documented delta. + assert.Contains(t, body, "800 in / 40 out tokens, $1.500000") + }}, + {name: "stats_only_session_counts_toward_spend_and_personas", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{ + id: "sess-counters", ended: "2026-09-15T01:00:00Z", + dims: &db.FrictionSessionDims{Persona: "meter", DimsSource: "nanoclaw"}, usage: tokens(500, 25, ""), + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 1, rep.Snapshot.SessionsScanned) + require.NotNil(t, rep.Snapshot.Spend) + assert.Equal(t, uint64(500), rep.Snapshot.Spend.InputTokens) + m := rep.Snapshot.Personas[friction.PersonaKey{Persona: "meter"}] + require.NotNil(t, m) + assert.Equal(t, 1, m.Sessions) + assert.Equal(t, uint64(500), m.InputTokens) + }}, + {name: "agent_and_machine_tags_are_stamped_and_rendered", run: func(t *testing.T, d *db.DB, r *review.Runner) { + const id = "a37ffc87-2799-4a09-830b-a92fde71d768" + seed(t, d, subject{ + id: id, ended: "2026-09-15T01:00:00Z", machine: "laptop-01", + findings: []db.FrictionFinding{correction(id, "no, use the other path")}, + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + require.Len(t, rep.Snapshot.Signals, 1) + assert.Equal(t, "claude", rep.Snapshot.Signals[0].Dims.Agent) + assert.Equal(t, "laptop-01", rep.Snapshot.Signals[0].Dims.Machine) + assert.Contains(t, string(stored(t, d, day).Markdown), + "- `agent:claude` `machine:laptop-01` `"+id+"` — 'no, use the other path'") + }}, + {name: "subagent_cost_lands_under_subagent_role", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, + subject{id: "root", ended: "2026-09-15T01:00:00Z", usage: tokens(10, 1, "3.1")}, + subject{id: "child", ended: "2026-09-15T02:00:00Z", parent: "root", usage: tokens(10, 1, "1.1")}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + require.NotNil(t, rep.Snapshot.Spend) + assert.Equal(t, "3.100000", rep.Snapshot.Spend.RoleCosts["(root)"].String()) + assert.Equal(t, "1.100000", rep.Snapshot.Spend.RoleCosts["subagent"].String()) + }}, + {name: "p0_alert_needs_three_top_level_subjects", run: func(t *testing.T, d *db.DB, r *review.Runner) { + errFinding := func(sid string) db.FrictionFinding { + return finding(sid, friction.KindError, "error", "exit 2: boom", "", "Bash", 2, 0) + } + seed(t, d, + subject{id: "e1", ended: "2026-09-15T01:00:00Z", findings: []db.FrictionFinding{errFinding("e1")}}, + subject{id: "e2", ended: "2026-09-15T02:00:00Z", findings: []db.FrictionFinding{errFinding("e2")}}, + subject{id: "e3", ended: "2026-09-15T03:00:00Z", parent: "e1", findings: []db.FrictionFinding{errFinding("e3")}}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Empty(t, rep.Snapshot.P0Alerts, "sub-agent subjects never count toward P0") + }}, + {name: "review_excluded_sessions_are_skipped_and_not_recorded", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{ + id: "x", ended: "2026-09-15T01:00:00Z", + dims: &db.FrictionSessionDims{ReviewExcluded: true, DimsSource: "nanoclaw"}, + }) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.Equal(t, 0, rep.Snapshot.SessionsScanned) + assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) + }}, + {name: "stale_rules_defer_then_skip_after_grace", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "stale", ended: "2026-09-15T01:00:00Z", rules: "friction-old"}) + r.Now = func() time.Time { return time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC) } + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.ErrorIs(t, err, review.ErrStaleSubjects) + assert.Nil(t, stored(t, d, day)) + r.Now = func() time.Time { return time.Date(2026, 9, 18, 0, 1, 0, 0, time.UTC) } + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Equal(t, 0, rep.Snapshot.SessionsScanned) + assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) + }}, + {name: "today_is_refused_unless_dry_run", run: func(t *testing.T, d *db.DB, r *review.Runner) { + _, err := r.BuildDate(t.Context(), "2026-09-22", review.BuildOptions{}) + require.ErrorIs(t, err, review.ErrIncompleteDate) + _, err = r.BuildDate(t.Context(), "2026-09-22", review.BuildOptions{DryRun: true}) + require.NoError(t, err) + _, err = r.BuildDate(t.Context(), "2026-9-1", review.BuildOptions{}) + require.Error(t, err) + }}, + {name: "digest_path_uses_public_url", run: func(t *testing.T, d *db.DB, r *review.Runner) { + r.PublicURL = "https://av.example/" + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + require.NotNil(t, rep.Meta.DigestPath) + assert.Equal(t, "https://av.example/friction/"+day, *rep.Meta.DigestPath) + assert.Contains(t, string(stored(t, d, day).SummaryJSON), `"digest_path": "https://av.example/friction/`+day+`"`) + assert.Equal(t, "friction:"+day, review.DigestURL("", day)) + }}, + {name: "frustration_and_interruption_kinds_flow_through", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{ + correction("s1", "no, use the other file please"), + finding("s1", friction.KindFrustration, "frustration", "this is still broken", "", "", 3, 1), + finding("s1", friction.KindInterruption, "interruption", "", "", "", 4, 2), + }}) + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + kinds := []friction.Kind{} + for _, s := range rep.Snapshot.Signals { + kinds = append(kinds, s.Kind) + assert.Equal(t, friction.SubjectSession, s.SubjectKind) + } + assert.Equal(t, []friction.Kind{friction.KindCorrection, friction.KindFrustration, friction.KindInterruption}, kinds) + got := stored(t, d, day) + md, summary := string(got.Markdown), string(got.SummaryJSON) + assert.Contains(t, md, "frustrations: 1") + assert.Contains(t, md, "interruptions: 1") + assert.Contains(t, md, "signals_captured: 3") + assert.Contains(t, md, "## Frustration") + assert.Contains(t, md, "- `agent:claude` `machine:machine-a` `s1` interruptions=1") + assert.Contains(t, summary, `"frustrations": 1`) + assert.Contains(t, summary, `"interruptions": 1`) + assert.Contains(t, summary, `"schema_version": 3`) + assert.Equal(t, 3, rowCount(t, d, "friction_patterns"), "every kind feeds local recurrence") + }}, + {name: "diagnostics_come_last_are_recorded_and_never_raise_p0", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + diag := func(id string) friction.Signal { + return friction.Signal{ + Kind: friction.KindError, SubjectID: id, Detector: "error", + ToolName: "nightly_check", Text: id + ": check failed", Dims: friction.Dims{Machine: "ci-runner"}, + } + } + r.Diagnostics = fakeDiagnostics{sigs: []friction.Signal{diag("run-9:nightly_check"), diag("run-1:nightly_check"), diag("run-5:nightly_check")}} + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + ids := []string{} + for _, s := range rep.Snapshot.Signals { + ids = append(ids, s.SubjectID) + } + assert.Equal(t, []string{"s1", "run-1:nightly_check", "run-5:nightly_check", "run-9:nightly_check"}, ids) + assert.Equal(t, friction.SubjectDiagnostic, rep.Snapshot.Signals[1].SubjectKind) + assert.Empty(t, rep.Snapshot.P0Alerts, "diagnostics never raise P0 (spec §6.5)") + assert.Equal(t, 4, rep.Snapshot.SessionsScanned) + assert.Equal(t, 4, stored(t, d, day).SessionsScanned) + raw, err := sql.Open("sqlite3", d.Path()) + require.NoError(t, err) + defer raw.Close() + var n int + require.NoError(t, raw.QueryRowContext(t.Context(), + `SELECT count(*) FROM friction_digest_sessions WHERE subject_kind = 'diagnostic'`).Scan(&n)) + assert.Equal(t, 3, n) + + r.Diagnostics = fakeDiagnostics{} + rebuilt, err := r.BuildDate(t.Context(), day, review.BuildOptions{Rebuild: true}) + require.NoError(t, err) + assert.Len(t, rebuilt.Snapshot.Signals, 4, "rebuild keeps diagnostic members") + }}, + {name: "diagnostic_source_error_fails_the_build", run: func(t *testing.T, d *db.DB, r *review.Runner) { + r.Diagnostics = fakeDiagnostics{err: assert.AnError} + _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.ErrorIs(t, err, assert.AnError) + assert.Nil(t, stored(t, d, day), "no partial digest") + }}, + {name: "concurrent_builder_loses_cleanly", run: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + racing := &raceStore{Store: d, beforeSave: func() { + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: day, Timezone: "UTC", + RulesVersion: friction.RulesVersion, BuiltAt: reviewNow, Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), Markdown: []byte("OTHER\n"), + MarkdownSHA256: "x", RunID: "other", + }, nil, nil)) + }} + r.Store = racing + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.False(t, rep.Written) + assert.Equal(t, "OTHER\n", string(stored(t, d, day).Markdown)) + assert.Equal(t, 0, rowCount(t, d, "friction_patterns"), "losing save rolled back") + }}, + {name: "archive_spend_failure_hides_block", run: func(t *testing.T, d *db.DB, r *review.Runner) { + r.Store = &raceStore{Store: d, archiveErr: assert.AnError} + rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + require.NoError(t, err) + assert.True(t, rep.Written) + assert.Nil(t, rep.Snapshot.ArchiveSpend) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := dbtest.OpenTestDB(t) + tt.run(t, d, newRunner(d)) + }) + } +} + +// raceStore wraps a real store to inject a competing writer or a failing +// archive-spend query. +type raceStore struct { + review.Store + beforeSave func() + archiveErr error +} + +func (s *raceStore) SaveFrictionDigest(ctx context.Context, d db.FrictionDigest, subj []db.FrictionDigestSubject, p []db.FrictionPatternUpdate) error { + if s.beforeSave != nil { + s.beforeSave() + } + return s.Store.SaveFrictionDigest(ctx, d, subj, p) +} + +func (s *raceStore) FrictionArchiveSpend(ctx context.Context, from, to string, loc *time.Location) (*friction.ArchiveSpend, error) { + if s.archiveErr != nil { + return nil, s.archiveErr + } + return s.Store.FrictionArchiveSpend(ctx, from, to, loc) +} + +type fakeDiagnostics struct { + sigs []friction.Signal + err error +} + +func (f fakeDiagnostics) DiagnosticSignalsForDate(context.Context, string, *time.Location) ([]friction.Signal, error) { + return f.sigs, f.err +} + +func TestSignalsKeepSubjectOrderThenSeq(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, + subject{id: "b-session", ended: "2026-09-15T01:00:00Z", machine: "m1", findings: []db.FrictionFinding{ + finding("b-session", friction.KindWorkaround, "workaround", "hack two", "hack", "", 5, 1), + finding("b-session", friction.KindWorkaround, "workaround", "hack one", "hack", "", 2, 0), + }}, + subject{id: "a-session", ended: "2026-09-15T02:00:00Z", machine: "m2", findings: []db.FrictionFinding{ + finding("a-session", friction.KindWorkaround, "workaround", "for now", "for now", "", 1, 0), + }}) + rep, err := newRunner(d).BuildDate(t.Context(), "2026-09-15", review.BuildOptions{}) + require.NoError(t, err) + got := []string{} + for _, s := range rep.Snapshot.Signals { + got = append(got, s.Text) + } + assert.Equal(t, []string{"hack one", "hack two", "for now"}, got, "(machine, file_path, id) then seq") + assert.True(t, strings.HasPrefix(string(stored(t, d, "2026-09-15").Markdown), "---\n")) +} diff --git a/internal/postgres/friction_digest.go b/internal/postgres/friction_digest.go index 9df4be0e10..ffae09b866 100644 --- a/internal/postgres/friction_digest.go +++ b/internal/postgres/friction_digest.go @@ -94,7 +94,8 @@ func (s *Store) FrictionSubjectsForDate( (s.parent_session_id IS NOT NULL AND s.relationship_type = 'subagent'), COALESCE(s.ended_at, s.started_at), s.friction_rules_version, COALESCE(d.seat, ''), COALESCE(d.persona, ''), COALESCE(d.channel, ''), - COALESCE(d.dims_source, ''), COALESCE(d.review_excluded, FALSE) + COALESCE(d.dims_source, ''), COALESCE(d.review_excluded, FALSE), + COALESCE(ds.date, '') FROM sessions s LEFT JOIN friction_session_dims d ON d.session_id = s.id LEFT JOIN friction_digest_sessions ds ON ds.subject_id = s.id @@ -112,20 +113,22 @@ func (s *Store) FrictionSubjectsForDate( out := []db.FrictionSubject{} for rows.Next() { var ( - sub db.FrictionSubject - machine string - last sql.NullTime + sub db.FrictionSubject + machine string + last sql.NullTime + digestDate string ) if err := rows.Scan(&sub.SubjectID, &machine, &sub.FilePath, &sub.Agent, &sub.IsSubAgent, &last, &sub.RulesVersion, &sub.Dims.Seat, &sub.Dims.Persona, &sub.Dims.Channel, &sub.Dims.DimsSource, - &sub.Dims.ReviewExcluded); err != nil { + &sub.Dims.ReviewExcluded, &digestDate); err != nil { return nil, fmt.Errorf("scanning friction subject: %w", err) } if last.Valid { sub.LastActivity = last.Time.UTC() } sub.SubjectKind = friction.SubjectSession + sub.AlreadyDigested = digestDate != "" sub.Dims.SessionID = sub.SubjectID sub.Machine = machine if label := labels[machine]; label != "" { diff --git a/internal/postgres/friction_digest_pgtest_test.go b/internal/postgres/friction_digest_pgtest_test.go index f50df50137..9f298e90fd 100644 --- a/internal/postgres/friction_digest_pgtest_test.go +++ b/internal/postgres/friction_digest_pgtest_test.go @@ -127,6 +127,14 @@ func TestFrictionReviewStoreParity(t *testing.T) { require.NoError(t, err) require.Len(t, after, 1) assert.Equal(t, "b", after[0].SubjectID) + all, err := s.FrictionSubjectsForDate(ctx, "2026-09-15", time.UTC, true) + require.NoError(t, err) + require.Len(t, all, 2) + membership := map[string]bool{} + for _, subject := range all { + membership[subject.SubjectID] = subject.AlreadyDigested + } + assert.Equal(t, map[string]bool{"a": true, "b": false}, membership) latest, err := s.LatestFrictionDigestDate(ctx) require.NoError(t, err) From 9d729f99ab1ab51c75e5b967707095a820c74a45 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:56:00 +0000 Subject: [PATCH 48/62] feat(friction): catch up complete local dates --- internal/friction/review/catchup_test.go | 164 +++++++++++++++++++++++ internal/friction/review/runner.go | 81 +++++++++++ 2 files changed, 245 insertions(+) create mode 100644 internal/friction/review/catchup_test.go diff --git a/internal/friction/review/catchup_test.go b/internal/friction/review/catchup_test.go new file mode 100644 index 0000000000..951fe664fd --- /dev/null +++ b/internal/friction/review/catchup_test.go @@ -0,0 +1,164 @@ +package review_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction/review" +) + +func reportDates(reps []review.Report) []string { + out := []string{} + for _, r := range reps { + out = append(out, r.Date) + } + return out +} + +func TestCatchUp(t *testing.T) { + tests := []struct { + name string + seed func(t *testing.T, d *db.DB, r *review.Runner) + backfill int + want []string + }{ + { + name: "builds_exactly_the_missing_complete_dates_within_backfill", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}, + subject{id: "today", ended: "2026-09-22T09:00:00Z"}) + }, + backfill: 7, + want: []string{"2026-09-15", "2026-09-16", "2026-09-17", "2026-09-18", "2026-09-19", "2026-09-20", "2026-09-21"}, + }, + { + name: "starts_after_latest_digest", + seed: func(t *testing.T, d *db.DB, r *review.Runner) { + seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}) + _, err := r.BuildDate(t.Context(), "2026-09-18", review.BuildOptions{}) + require.NoError(t, err) + }, + backfill: 7, + want: []string{"2026-09-19", "2026-09-20", "2026-09-21"}, + }, + { + name: "starts_at_earliest_session", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + seed(t, d, subject{id: "new", ended: "2026-09-20T10:00:00Z"}) + }, + backfill: 7, + want: []string{"2026-09-20", "2026-09-21"}, + }, + { + name: "empty_archive_builds_nothing", + seed: func(*testing.T, *db.DB, *review.Runner) {}, + backfill: 7, + want: []string{}, + }, + { + name: "never_today", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + seed(t, d, subject{id: "today", ended: "2026-09-22T09:00:00Z"}) + }, + backfill: 7, + want: []string{}, + }, + { + name: "backfill_of_one_builds_yesterday_only", + seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}) + }, + backfill: 1, + want: []string{"2026-09-21"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := dbtest.OpenTestDB(t) + r := newRunner(d) + r.BackfillDays = tt.backfill + tt.seed(t, d, r) + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, tt.want, reportDates(reps)) + for _, rep := range reps { + assert.True(t, rep.Written, rep.Date) + } + again, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Empty(t, again, "a second catch-up is a no-op") + }) + } +} + +func TestCatchUpZoneDecidesToday(t *testing.T) { + tokyo, err := time.LoadLocation("Asia/Tokyo") + require.NoError(t, err) + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s", ended: "2026-09-21T10:00:00Z"}) + r := newRunner(d) + r.Loc = tokyo + r.Now = func() time.Time { return time.Date(2026, 9, 21, 16, 0, 0, 0, time.UTC) } // 01:00 on the 22nd in Tokyo + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"2026-09-21"}, reportDates(reps)) + assert.Equal(t, "Asia/Tokyo", reps[0].Snapshot.Timezone) +} + +func TestStaleSubjects(t *testing.T) { + t.Run("stale_subjects_defer_then_build_after_grace", func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, + subject{id: "ready", ended: "2026-09-21T10:00:00Z", findings: []db.FrictionFinding{correction("ready", "no, use the other file please")}}, + subject{id: "pending", ended: "2026-09-21T11:00:00Z", rules: "friction-v0"}) + r := newRunner(d) // now = 2026-09-22T10:00Z, 10h after the 21st ended + r.BackfillDays = 1 + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err, "a deferred date is not a job failure") + assert.Empty(t, reps) + got, err := d.GetFrictionDigest(t.Context(), "2026-09-21") + require.NoError(t, err) + assert.Nil(t, got) + + r.Now = func() time.Time { return time.Date(2026, 9, 24, 1, 0, 0, 0, time.UTC) } + r.BackfillDays = 3 + reps, err = r.CatchUp(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, reps) + assert.Equal(t, "2026-09-21", reps[0].Date) + assert.Equal(t, 1, reps[0].Snapshot.SessionsScanned, "the stale session is left out after the grace period") + }) + t.Run("dry_run_ignores_staleness", func(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "pending", ended: "2026-09-21T11:00:00Z", rules: "friction-v0"}) + _, err := newRunner(d).BuildDate(t.Context(), "2026-09-21", review.BuildOptions{DryRun: true}) + require.NoError(t, err) + }) +} + +func TestRerender(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) + r := newRunner(d) + _, err := r.BuildDate(t.Context(), "2026-09-15", review.BuildOptions{}) + require.NoError(t, err) + before := stored(t, d, "2026-09-15") + + require.NoError(t, r.Rerender(t.Context(), "2026-09-15")) + assert.Equal(t, before.Revision, stored(t, d, "2026-09-15").Revision, "identical bytes do not bump revision") + + r.PublicURL = "https://av.example" + require.NoError(t, r.Rerender(t.Context(), "2026-09-15")) + after := stored(t, d, "2026-09-15") + assert.Equal(t, before.Revision+1, after.Revision) + assert.Equal(t, before.Markdown, after.Markdown, "markdown comes from the frozen snapshot") + assert.Contains(t, string(after.SummaryJSON), "https://av.example/friction/2026-09-15") + assert.Equal(t, before.SnapshotJSON, after.SnapshotJSON) + + require.ErrorIs(t, r.Rerender(t.Context(), "2020-01-01"), review.ErrDigestNotFound) +} diff --git a/internal/friction/review/runner.go b/internal/friction/review/runner.go index 9ca2e7d845..23618f4300 100644 --- a/internal/friction/review/runner.go +++ b/internal/friction/review/runner.go @@ -1,6 +1,7 @@ package review import ( + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -385,3 +386,83 @@ func (r *Runner) BuildDate(ctx context.Context, date string, opts BuildOptions) } return Report{Date: date, Snapshot: snap, Meta: meta, Written: true}, nil } + +var ErrDigestNotFound = errors.New("friction: no digest for date") + +func (r *Runner) backfillDays() int { + if r.BackfillDays < 1 { + return 7 + } + return r.BackfillDays +} + +// CatchUp builds every complete local date without a digest, from the +// latest of (last digest + 1, today − backfill_days, earliest session) to +// yesterday. Today is never built. A date still waiting on findings stops +// the pass without error; later dates wait for it so no date is skipped. +func (r *Runner) CatchUp(ctx context.Context) ([]Report, error) { + loc := r.loc() + today := localDate(r.now(), loc) + yesterday := addDays(today, -1) + earliest, err := r.Store.EarliestSessionDate(ctx, loc) + if err != nil { + return nil, err + } + if earliest == "" { + return nil, nil + } + start := addDays(today, -r.backfillDays()) + if earliest > start { + start = earliest + } + latest, err := r.Store.LatestFrictionDigestDate(ctx) + if err != nil { + return nil, err + } + if latest != "" { + if next := addDays(latest, 1); next > start { + start = next + } + } + var reports []Report + for d := start; d <= yesterday; d = addDays(d, 1) { + if err := ctx.Err(); err != nil { + return reports, err + } + rep, err := r.BuildDate(ctx, d, BuildOptions{}) + if errors.Is(err, ErrStaleSubjects) { + log.Printf("friction review: deferring %s: %v", d, err) + return reports, nil + } + if err != nil { + return reports, fmt.Errorf("building friction digest %s: %w", d, err) + } + if rep.Written { + reports = append(reports, rep) + } + } + return reports, nil +} + +// Rerender rebuilds a digest's Markdown and summary JSON from its frozen +// snapshot and bumps revision when either changed (spec §8.6). Links come +// from the Kata filer once PR 10 adds it; until then none are rendered. +func (r *Runner) Rerender(ctx context.Context, date string) error { + existing, err := r.Store.GetFrictionDigest(ctx, date) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("%w: %s", ErrDigestNotFound, date) + } + snap, err := DecodeSnapshot(existing.SnapshotJSON) + if err != nil { + return err + } + md := friction.RenderMarkdown(snap, friction.RenderLinks{}) + summary := friction.RenderSummaryJSON(snap, r.meta(date, false)) + if bytes.Equal(md, existing.Markdown) && bytes.Equal(summary, existing.SummaryJSON) { + return nil + } + return r.Store.UpdateFrictionDigestRender(ctx, date, md, summary, existing.Revision+1) +} From 704dc2ea93875e89f5f66ab2dcb2fe1d057c6a4e Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:15:01 +0000 Subject: [PATCH 49/62] feat(friction): schedule the friction-review catch-up job --- cmd/agentsview/friction_schedule.go | 92 ++++++++++ cmd/agentsview/friction_schedule_test.go | 160 ++++++++++++++++++ cmd/agentsview/main.go | 13 ++ cmd/agentsview/pg.go | 14 +- .../lib/api/generated/models/versionInfo.ts | 1 + internal/apiclient/client.gen.go | 1 + internal/friction/review/job.go | 37 ++++ internal/friction/review/job_test.go | 34 ++++ internal/server/friction_capability_test.go | 34 ++++ internal/server/huma_routes_metadata.go | 1 + internal/server/server.go | 13 ++ openapi.yaml | 3 + 12 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 cmd/agentsview/friction_schedule.go create mode 100644 cmd/agentsview/friction_schedule_test.go create mode 100644 internal/friction/review/job.go create mode 100644 internal/friction/review/job_test.go create mode 100644 internal/server/friction_capability_test.go diff --git a/cmd/agentsview/friction_schedule.go b/cmd/agentsview/friction_schedule.go new file mode 100644 index 0000000000..78c5333e5e --- /dev/null +++ b/cmd/agentsview/friction_schedule.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "log" + "os" + "sync" + "time" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/poller" +) + +// frictionRunnerStore is a review store that can report whether it is writable. +type frictionRunnerStore interface { + review.Store + ReadOnly() bool +} + +type workTracker interface { + BeginWork() (func(), bool) +} + +func newFrictionRunner(cfg config.Config, store frictionRunnerStore, now func() time.Time) (*review.Runner, error) { + if !cfg.Friction.Enabled || store == nil { + return nil, nil + } + if capable, ok := store.(interface{ FrictionAvailable() bool }); ok { + if !capable.FrictionAvailable() { + log.Printf("friction review disabled: this store cannot write friction tables") + return nil, nil + } + } else if store.ReadOnly() { + return nil, nil + } + loc, err := friction.ResolveZone(os.Getenv(friction.ZoneEnvVar), cfg.Friction.Timezone) + if err != nil { + return nil, err + } + return &review.Runner{ + Store: store, Loc: loc, Now: now, + BackfillDays: cfg.Friction.BackfillDays, PublicURL: cfg.PublicURL, + }, nil +} + +// frictionExclusive keeps a detached daemon alive and serializes digest work +// with local sync and resync. +func frictionExclusive(tracker workTracker, runner remoteSyncExclusiveRunner) func(func() error) error { + return func(work func() error) error { + if tracker != nil { + done, ok := tracker.BeginWork() + if !ok { + return nil + } + defer done() + } + if runner == nil { + return work() + } + return runner.RunExclusive(work) + } +} + +// serialExclusive serializes digest work within one pg serve process. +func serialExclusive() func(func() error) error { + var mu sync.Mutex + return func(work func() error) error { + mu.Lock() + defer mu.Unlock() + return work() + } +} + +// startFrictionReview starts the catch-up job when review writes are available. +// The caller cancels ctx before invoking the returned wait function. +func startFrictionReview( + ctx context.Context, cfg config.Config, store frictionRunnerStore, + exclusive func(func() error) error, +) (*review.Runner, func()) { + runner, err := newFrictionRunner(cfg, store, time.Now) + if err != nil { + log.Printf("friction review disabled: %v", err) + return nil, func() {} + } + if runner == nil { + return nil, func() {} + } + scheduler := poller.Start(ctx, review.Job(runner, exclusive)) + return runner, scheduler.Wait +} diff --git a/cmd/agentsview/friction_schedule_test.go b/cmd/agentsview/friction_schedule_test.go new file mode 100644 index 0000000000..54ef154a4a --- /dev/null +++ b/cmd/agentsview/friction_schedule_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction/review" +) + +type readOnlyFrictionStore struct { + review.Store + readOnly bool + available *bool +} + +func (s readOnlyFrictionStore) ReadOnly() bool { return s.readOnly } + +type availableFrictionStore struct { + readOnlyFrictionStore +} + +func (s availableFrictionStore) FrictionAvailable() bool { return *s.available } + +func TestNewFrictionRunner(t *testing.T) { + d := dbtest.OpenTestDB(t) + yes, no := true, false + base := config.Config{Friction: config.FrictionConfig{Enabled: true, Timezone: "Asia/Tokyo", BackfillDays: 30}, + PublicURL: "https://av.example"} + tests := []struct { + name string + cfg func() config.Config + store frictionRunnerStore + env string + wantNil bool + wantTZ string + }{ + {name: "disabled", cfg: func() config.Config { c := base; c.Friction.Enabled = false; return c }, store: d, wantNil: true}, + {name: "sqlite writable", cfg: func() config.Config { return base }, store: d, wantTZ: "Asia/Tokyo"}, + {name: "read-only sqlite", cfg: func() config.Config { return base }, store: readOnlyFrictionStore{Store: d, readOnly: true}, wantNil: true}, + {name: "pg without write access", cfg: func() config.Config { return base }, + store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &no}}, wantNil: true}, + {name: "pg with write access", cfg: func() config.Config { return base }, + store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &yes}}, wantTZ: "Asia/Tokyo"}, + {name: "env overrides zone", cfg: func() config.Config { return base }, store: d, env: "Europe/Berlin", wantTZ: "Europe/Berlin"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AGENTSVIEW_FRICTION_TZ", tt.env) + r, err := newFrictionRunner(tt.cfg(), tt.store, time.Now) + require.NoError(t, err) + if tt.wantNil { + assert.Nil(t, r) + return + } + require.NotNil(t, r) + assert.Equal(t, tt.wantTZ, r.Loc.String()) + assert.Equal(t, 30, r.BackfillDays) + assert.Equal(t, "https://av.example", r.PublicURL) + }) + } + t.Setenv("AGENTSVIEW_FRICTION_TZ", "Not/AZone") + _, err := newFrictionRunner(base, d, time.Now) + require.Error(t, err) +} + +func TestFrictionOnByDefault(t *testing.T) { + t.Setenv("AGENTSVIEW_FRICTION_TZ", "") + cfg, err := config.Default() + require.NoError(t, err) + r, err := newFrictionRunner(cfg, dbtest.OpenTestDB(t), time.Now) + require.NoError(t, err) + assert.NotNil(t, r, "[friction] is on by default (D34)") + assert.Nil(t, r.Diagnostics, "diagnostics stay off until a source is configured") + + cfg.Friction.Enabled = false + r, err = newFrictionRunner(cfg, dbtest.OpenTestDB(t), time.Now) + require.NoError(t, err) + assert.Nil(t, r, "explicit enabled = false disables the job and FrictionAvailable") +} + +type fakeTracker struct { + active, max int + draining bool +} + +func (f *fakeTracker) BeginWork() (func(), bool) { + if f.draining { + return func() {}, false + } + f.active++ + f.max = max(f.max, f.active) + return func() { f.active-- }, true +} + +type fakeExclusive struct{ calls int } + +func (f *fakeExclusive) RunExclusive(work func() error) error { f.calls++; return work() } + +func TestFrictionExclusiveKeepsDaemonAlive(t *testing.T) { + tracker := &fakeTracker{} + lock := &fakeExclusive{} + excl := frictionExclusive(tracker, lock) + sawActive := false + require.NoError(t, excl(func() error { sawActive = tracker.active == 1; return nil })) + assert.True(t, sawActive, "the idle tracker counts the job as work while it runs") + assert.Equal(t, 0, tracker.active) + assert.Equal(t, 1, lock.calls, "work runs under engine.RunExclusive") + + boom := errors.New("boom") + require.ErrorIs(t, excl(func() error { return boom }), boom) + + tracker.draining = true + ran := false + require.NoError(t, excl(func() error { ran = true; return nil })) + assert.False(t, ran, "a draining daemon skips the job") + + direct := frictionExclusive(nil, nil) + require.NoError(t, direct(func() error { ran = true; return nil })) + assert.True(t, ran) +} + +func TestSerialExclusive(t *testing.T) { + excl := serialExclusive() + inside := make(chan struct{}) + release := make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + go func() { _ = excl(func() error { close(inside); <-release; return nil }) }() + <-inside + second := make(chan struct{}) + go func() { _ = excl(func() error { close(second); return nil }) }() + assert.Never(t, func() bool { + select { + case <-second: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond, "second call entered while the first held the lock") + close(release) + require.Eventually(t, func() bool { + select { + case <-second: + return true + default: + return false + } + }, time.Second, 10*time.Millisecond, "second call never ran") +} diff --git a/cmd/agentsview/main.go b/cmd/agentsview/main.go index 62248046cf..b8430fc22d 100644 --- a/cmd/agentsview/main.go +++ b/cmd/agentsview/main.go @@ -495,6 +495,16 @@ func runServe(ctx context.Context, cfg config.Config, opts serveOptions, restart fatal("%v", prepErr) } cfg = preparedCfg + var frictionEngine remoteSyncExclusiveRunner + if engine != nil { + frictionEngine = engine + } + frictionExcl := frictionExclusive(idleTracker, frictionEngine) + frictionRunner, waitFriction := startFrictionReview(ctx, cfg, database, frictionExcl) + defer func() { + stop() + waitFriction() + }() srvOpts := []server.Option{ server.WithVersion(server.VersionInfo{ @@ -541,6 +551,9 @@ func runServe(ctx context.Context, cfg config.Config, opts serveOptions, restart srvOpts = append(srvOpts, server.WithArtifactExchangeRunner( newDaemonArtifactExchangeRunner(cfg, database, engine, emitter), )) + if frictionRunner != nil { + srvOpts = append(srvOpts, server.WithFriction(frictionRunner, frictionExcl)) + } srv := server.New(cfg, database, engine, srvOpts...) startupProgress.SetPhase("starting HTTP server") diff --git a/cmd/agentsview/pg.go b/cmd/agentsview/pg.go index 7a25dfd4a1..29266f55ad 100644 --- a/cmd/agentsview/pg.go +++ b/cmd/agentsview/pg.go @@ -48,5 +48,17 @@ func (pgReplica) serveOptions( if rawSyncOption != nil { opts = append(opts, rawSyncOption) } - return opts, closeRawSync, nil + frictionExcl := serialExclusive() + frictionRunner, waitFriction := startFrictionReview(ctx, appCfg, pgStore, frictionExcl) + if frictionRunner != nil { + opts = append(opts, server.WithFriction(frictionRunner, frictionExcl)) + } + closeAll := func() error { + waitFriction() + if closeRawSync != nil { + return closeRawSync() + } + return nil + } + return opts, closeAll, nil } diff --git a/frontend/src/lib/api/generated/models/versionInfo.ts b/frontend/src/lib/api/generated/models/versionInfo.ts index ecc991916f..5c703dbd2d 100644 --- a/frontend/src/lib/api/generated/models/versionInfo.ts +++ b/frontend/src/lib/api/generated/models/versionInfo.ts @@ -7,6 +7,7 @@ export interface VersionInfo { build_date: string; commit: string; data_version: number; + friction_available: boolean; insight_generation_available: boolean; read_only?: boolean; version: string; diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 4531c05361..2d212ccea2 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -20608,6 +20608,7 @@ type VersionInfo struct { BuildDate string `json:"build_date" validate:"required"` Commit string `json:"commit" validate:"required"` DataVersion int64 `json:"data_version"` + FrictionAvailable bool `json:"friction_available"` InsightGenerationAvailable bool `json:"insight_generation_available"` ReadOnly *bool `json:"read_only,omitempty"` Version string `json:"version" validate:"required"` diff --git a/internal/friction/review/job.go b/internal/friction/review/job.go new file mode 100644 index 0000000000..4e16326b27 --- /dev/null +++ b/internal/friction/review/job.go @@ -0,0 +1,37 @@ +package review + +import ( + "context" + "time" + + "go.kenn.io/agentsview/internal/poller" +) + +// JobName is the poller job that keeps digests caught up (spec §8.1). +const JobName = "friction-review" + +// Job runs CatchUp hourly. exclusive serializes it with sync and resync on +// SQLite (engine.RunExclusive) or with manual runs on pg serve; nil runs +// the work directly. +func Job(r *Runner, exclusive func(func() error) error) poller.Job { + return poller.Job{ + Name: JobName, + Interval: time.Hour, + Jitter: 5 * time.Minute, + Cooldown: 10 * time.Minute, + RunAtStart: true, + Run: func(ctx context.Context) error { + work := func() error { + if err := ctx.Err(); err != nil { + return err + } + _, err := r.CatchUp(ctx) + return err + } + if exclusive == nil { + return work() + } + return exclusive(work) + }, + } +} diff --git a/internal/friction/review/job_test.go b/internal/friction/review/job_test.go new file mode 100644 index 0000000000..c8a2662cc1 --- /dev/null +++ b/internal/friction/review/job_test.go @@ -0,0 +1,34 @@ +package review_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction/review" +) + +func TestJob(t *testing.T) { + d := dbtest.OpenTestDB(t) + seed(t, d, subject{id: "s", ended: "2026-09-21T10:00:00Z"}) + r := newRunner(d) + calls := 0 + job := review.Job(r, func(work func() error) error { calls++; return work() }) + assert.Equal(t, "friction-review", job.Name) + assert.Equal(t, review.JobName, job.Name) + assert.Equal(t, time.Hour, job.Interval) + assert.Equal(t, 5*time.Minute, job.Jitter) + assert.Equal(t, 10*time.Minute, job.Cooldown) + assert.True(t, job.RunAtStart) + require.NoError(t, job.Run(t.Context())) + assert.Equal(t, 1, calls, "the whole catch-up runs under one exclusive section") + got, err := d.GetFrictionDigest(t.Context(), "2026-09-21") + require.NoError(t, err) + assert.NotNil(t, got) + + direct := review.Job(r, nil) + require.NoError(t, direct.Run(t.Context()), "nil exclusive runs the work directly") +} diff --git a/internal/server/friction_capability_test.go b/internal/server/friction_capability_test.go new file mode 100644 index 0000000000..bd4b75ae67 --- /dev/null +++ b/internal/server/friction_capability_test.go @@ -0,0 +1,34 @@ +package server_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/server" +) + +func TestVersionAdvertisesFriction(t *testing.T) { + tests := []struct { + name string + enable bool + }{ + {name: "disabled", enable: false}, + {name: "enabled", enable: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var opts []server.Option + te := setupWithServerOpts(t, nil) + if tt.enable { + runner := &review.Runner{Store: te.db, Loc: time.UTC, Now: time.Now, BackfillDays: 7} + opts = append(opts, server.WithFriction(runner, nil)) + te = setupWithServerOpts(t, opts) + } + version := decode[map[string]any](t, te.get(t, "/api/v1/version")) + assert.Equal(t, tt.enable, version["friction_available"]) + }) + } +} diff --git a/internal/server/huma_routes_metadata.go b/internal/server/huma_routes_metadata.go index 7b0c5d0641..0d42c01be4 100644 --- a/internal/server/huma_routes_metadata.go +++ b/internal/server/huma_routes_metadata.go @@ -178,6 +178,7 @@ func (s *Server) humaGetVersion( ) (*jsonOutput[VersionInfo], error) { version := s.version version.InsightGenerationAvailable = supportsInsightGeneration(s.db) + version.FrictionAvailable = s.frictionRunner != nil return &jsonOutput[VersionInfo]{Body: version}, nil } diff --git a/internal/server/server.go b/internal/server/server.go index 8b8fcdf458..b024d00b26 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,7 @@ import ( "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction/review" "go.kenn.io/agentsview/internal/insight" "go.kenn.io/agentsview/internal/parser" "go.kenn.io/agentsview/internal/pricingrefresh" @@ -44,6 +45,7 @@ type VersionInfo struct { BuildDate string `json:"build_date"` ReadOnly bool `json:"read_only,omitempty"` InsightGenerationAvailable bool `json:"insight_generation_available"` + FrictionAvailable bool `json:"friction_available"` APIVersion int `json:"api_version"` DataVersion int `json:"data_version"` } @@ -184,6 +186,8 @@ type Server struct { localCompactRunner LocalCompactRunner artifactExchangeRunner ArtifactExchangeRunner + frictionRunner *review.Runner + frictionExclusive func(func() error) error rawSyncDeviceAuth RawSyncDeviceAuth rawSyncCustody RawSyncCustody rawSyncStatus RawSyncStatusReader @@ -478,6 +482,15 @@ func WithGenerateStreamFunc(f insight.GenerateStreamFunc) Option { } } +// WithFriction attaches the Friction Log review runner. A nil runner leaves +// the feature unavailable. The run route uses the same serialization as the job. +func WithFriction(runner *review.Runner, exclusive func(func() error) error) Option { + return func(s *Server) { + s.frictionRunner = runner + s.frictionExclusive = exclusive + } +} + // WithInsightLogDrainTimeouts overrides SSE insight log stream drain timeouts. // Zero or negative values keep the production defaults. func WithInsightLogDrainTimeouts(drain, stopWait time.Duration) Option { diff --git a/openapi.yaml b/openapi.yaml index 7ece56084b..93f0ea88db 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7181,6 +7181,8 @@ components: data_version: format: int64 type: integer + friction_available: + type: boolean insight_generation_available: type: boolean read_only: @@ -7192,6 +7194,7 @@ components: - commit - build_date - insight_generation_available + - friction_available - api_version - data_version type: object From 0d09fe20f05acfdfec36ffec44ffee01fbc89b72 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:32:23 +0000 Subject: [PATCH 50/62] test(frontend): include friction capability in version fixtures --- frontend/src/App.test.ts | 1 + frontend/src/lib/components/layout/AppHeader.test.ts | 2 ++ frontend/src/lib/components/layout/ThreeColumnLayout.test.ts | 2 ++ frontend/src/lib/components/recall/RecallPage.test.ts | 2 ++ 4 files changed, 7 insertions(+) diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index b03932ddb5..904b8cdbaf 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -214,6 +214,7 @@ describe("App Recall availability", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/frontend/src/lib/components/layout/AppHeader.test.ts b/frontend/src/lib/components/layout/AppHeader.test.ts index d0d92a8087..54d06e7f01 100644 --- a/frontend/src/lib/components/layout/AppHeader.test.ts +++ b/frontend/src/lib/components/layout/AppHeader.test.ts @@ -274,6 +274,7 @@ describe("AppHeader export actions", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", @@ -298,6 +299,7 @@ describe("AppHeader export actions", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts b/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts index b677934493..b1ee02b345 100644 --- a/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts +++ b/frontend/src/lib/components/layout/ThreeColumnLayout.test.ts @@ -193,6 +193,7 @@ describe("ThreeColumnLayout", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", @@ -220,6 +221,7 @@ describe("ThreeColumnLayout", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", diff --git a/frontend/src/lib/components/recall/RecallPage.test.ts b/frontend/src/lib/components/recall/RecallPage.test.ts index 9ecd9c7877..ecd5c00f32 100644 --- a/frontend/src/lib/components/recall/RecallPage.test.ts +++ b/frontend/src/lib/components/recall/RecallPage.test.ts @@ -19,6 +19,7 @@ describe("RecallPage", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", @@ -70,6 +71,7 @@ describe("RecallPage", () => { sync.serverVersion = { api_version: 1, data_version: 1, + friction_available: false, insight_generation_available: false, version: "dev", commit: "unknown", From 4f5d7be519b7be61466d33731d32d4ee020217b5 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:35:06 +0000 Subject: [PATCH 51/62] test(postgres): include review tables in schema probes --- internal/postgres/schema_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/postgres/schema_test.go b/internal/postgres/schema_test.go index 5b841486ac..dbe8fdeddf 100644 --- a/internal/postgres/schema_test.go +++ b/internal/postgres/schema_test.go @@ -598,6 +598,9 @@ func TestSyncEnsureSchemaSkipsLegacyDDLWhenSchemaCompatible(t *testing.T) { "cursor_usage_events": true, "friction_findings": true, "friction_session_dims": true, + "friction_digests": true, + "friction_digest_sessions": true, + "friction_patterns": true, } state.existingIndexes = map[string]bool{ "idx_cursor_usage_events_dedup": true, @@ -640,6 +643,9 @@ func TestEnsureSchemaScrubsProjectIdentityGitRemoteCredentials(t *testing.T) { "cursor_usage_events": true, "friction_findings": true, "friction_session_dims": true, + "friction_digests": true, + "friction_digest_sessions": true, + "friction_patterns": true, } state.existingIndexes = map[string]bool{ "idx_cursor_usage_events_dedup": true, From 5c42047873378bd958ae0ac83f537139ed9c41ed Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:52:31 +0000 Subject: [PATCH 52/62] fix(friction): close PG rows per batch and satisfy lint --- cmd/agentsview/friction_schedule_test.go | 18 ++++-- internal/db/friction_digest_test.go | 16 +++++ internal/friction/review/catchup_test.go | 5 ++ internal/friction/review/runner.go | 8 +-- internal/friction/review/runner_test.go | 25 +++++++- internal/postgres/friction_digest.go | 82 +++++++++++++----------- 6 files changed, 104 insertions(+), 50 deletions(-) diff --git a/cmd/agentsview/friction_schedule_test.go b/cmd/agentsview/friction_schedule_test.go index 54ef154a4a..86984b595b 100644 --- a/cmd/agentsview/friction_schedule_test.go +++ b/cmd/agentsview/friction_schedule_test.go @@ -30,8 +30,10 @@ func (s availableFrictionStore) FrictionAvailable() bool { return *s.available } func TestNewFrictionRunner(t *testing.T) { d := dbtest.OpenTestDB(t) yes, no := true, false - base := config.Config{Friction: config.FrictionConfig{Enabled: true, Timezone: "Asia/Tokyo", BackfillDays: 30}, - PublicURL: "https://av.example"} + base := config.Config{ + Friction: config.FrictionConfig{Enabled: true, Timezone: "Asia/Tokyo", BackfillDays: 30}, + PublicURL: "https://av.example", + } tests := []struct { name string cfg func() config.Config @@ -43,10 +45,14 @@ func TestNewFrictionRunner(t *testing.T) { {name: "disabled", cfg: func() config.Config { c := base; c.Friction.Enabled = false; return c }, store: d, wantNil: true}, {name: "sqlite writable", cfg: func() config.Config { return base }, store: d, wantTZ: "Asia/Tokyo"}, {name: "read-only sqlite", cfg: func() config.Config { return base }, store: readOnlyFrictionStore{Store: d, readOnly: true}, wantNil: true}, - {name: "pg without write access", cfg: func() config.Config { return base }, - store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &no}}, wantNil: true}, - {name: "pg with write access", cfg: func() config.Config { return base }, - store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &yes}}, wantTZ: "Asia/Tokyo"}, + { + name: "pg without write access", cfg: func() config.Config { return base }, + store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &no}}, wantNil: true, + }, + { + name: "pg with write access", cfg: func() config.Config { return base }, + store: availableFrictionStore{readOnlyFrictionStore{Store: d, readOnly: true, available: &yes}}, wantTZ: "Asia/Tokyo", + }, {name: "env overrides zone", cfg: func() config.Config { return base }, store: d, env: "Europe/Berlin", wantTZ: "Europe/Berlin"}, } for _, tt := range tests { diff --git a/internal/db/friction_digest_test.go b/internal/db/friction_digest_test.go index 29a259d0e4..9d6afc229c 100644 --- a/internal/db/friction_digest_test.go +++ b/internal/db/friction_digest_test.go @@ -55,6 +55,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "ended_at decides the date", loc: time.UTC, date: "2026-09-15", seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "a", "2026-09-14T23:00:00Z", "2026-09-15T00:30:00Z", nil) seedFrictionSession(t, d, "b", "2026-09-15T10:00:00Z", "2026-09-16T00:00:00Z", nil) }, @@ -64,6 +65,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "started_at_only", loc: time.UTC, date: "2026-09-15", seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "open", "2026-09-15T08:00:00Z", "", nil) }, want: []string{"open"}, @@ -72,6 +74,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "timestamp with offset crosses UTC date", loc: time.UTC, date: "2026-09-15", seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "offset", "", "2026-09-16T08:30:00+09:00", nil) }, want: []string{"offset"}, @@ -80,6 +83,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "dst_short_day_new_york", loc: ny, date: "2026-03-08", seed: func(t *testing.T, d *DB) { + t.Helper() // 23:30 EDT on 2026-03-08 is 03:30Z on 2026-03-09. seedFrictionSession(t, d, "late", "", "2026-03-09T03:30:00Z", nil) // 00:30 EDT on 2026-03-09 is 04:30Z: next local date. @@ -91,6 +95,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "digested subjects excluded", loc: time.UTC, date: "2026-09-15", seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "done", "", "2026-09-15T01:00:00Z", nil) seedFrictionSession(t, d, "fresh", "", "2026-09-15T02:00:00Z", nil) require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), @@ -102,6 +107,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "rebuild keeps membership even after activity moved", loc: time.UTC, date: "2026-09-15", includeDigested: true, seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "moved", "", "2026-09-17T01:00:00Z", nil) seedFrictionSession(t, d, "fresh", "", "2026-09-15T02:00:00Z", nil) require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), @@ -113,6 +119,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "trashed sessions excluded", loc: time.UTC, date: "2026-09-15", seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "gone", "", "2026-09-15T01:00:00Z", nil) require.NoError(t, d.SoftDeleteSession(t.Context(), "gone")) }, @@ -122,6 +129,7 @@ func TestFrictionSubjectsForDate(t *testing.T) { name: "review excluded sessions excluded", loc: time.UTC, date: "2026-09-15", seed: func(t *testing.T, d *DB) { + t.Helper() seedFrictionSession(t, d, "excluded", "", "2026-09-15T01:00:00Z", nil) require.NoError(t, d.ReplaceSessionFriction(t.Context(), "excluded", nil, &FrictionSessionDims{SessionID: "excluded", ReviewExcluded: true}, "friction-v1", "h2")) @@ -200,6 +208,7 @@ func TestSaveFrictionDigest(t *testing.T) { run func(t *testing.T, d *DB) }{ {name: "round_trip", run: func(t *testing.T, d *DB) { + t.Helper() want := testDigest("2026-09-15", 1) want.SessionsScanned = 3 require.NoError(t, d.SaveFrictionDigest(t.Context(), want, nil, nil)) @@ -209,11 +218,13 @@ func TestSaveFrictionDigest(t *testing.T) { assert.Equal(t, want, *got) }}, {name: "missing_date_is_nil", run: func(t *testing.T, d *DB) { + t.Helper() got, err := d.GetFrictionDigest(t.Context(), "2026-01-01") require.NoError(t, err) assert.Nil(t, got) }}, {name: "second_insert_conflicts", run: func(t *testing.T, d *DB) { + t.Helper() require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, []FrictionPatternUpdate{{Fingerprint: "fl1:a", Kind: "error", Title: "t", Date: "2026-09-15", SubjectID: "s1", Occurrences: 2}})) err := d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, @@ -222,6 +233,7 @@ func TestSaveFrictionDigest(t *testing.T) { assert.Equal(t, 2, frictionPatternCount(t, d, "fl1:a"), "rolled-back save must not count") }}, {name: "revision_update_requires_previous", run: func(t *testing.T, d *DB) { + t.Helper() require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, nil)) require.ErrorIs(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 3), nil, nil), ErrFrictionDigestConflict) second := testDigest("2026-09-15", 2) @@ -233,12 +245,14 @@ func TestSaveFrictionDigest(t *testing.T) { assert.Equal(t, 9, got.SessionsScanned) }}, {name: "subjects_are_recorded_once", run: func(t *testing.T, d *DB) { + t.Helper() subj := []FrictionDigestSubject{{SubjectID: "s1", Date: "2026-09-15", SubjectKind: friction.SubjectSession}} require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), subj, nil)) require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 2), subj, nil)) assert.Equal(t, 1, countRows(t, d, "friction_digest_sessions")) }}, {name: "patterns_accumulate_across_dates", run: func(t *testing.T, d *DB) { + t.Helper() require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, []FrictionPatternUpdate{ {Fingerprint: "fl1:a", Kind: "correction", Title: "t", Date: "2026-09-15", SubjectID: "s1", Ordinal: Ptr(4), Occurrences: 2}, {Fingerprint: "fl1:a", Kind: "correction", Title: "t", Date: "2026-09-15", SubjectID: "s2", Ordinal: Ptr(7), Occurrences: 1}, @@ -253,6 +267,7 @@ func TestSaveFrictionDigest(t *testing.T) { }, row) }}, {name: "latest_date", run: func(t *testing.T, d *DB) { + t.Helper() latest, err := d.LatestFrictionDigestDate(t.Context()) require.NoError(t, err) assert.Empty(t, latest) @@ -263,6 +278,7 @@ func TestSaveFrictionDigest(t *testing.T) { assert.Equal(t, "2026-09-16", latest) }}, {name: "update_render_bumps_revision", run: func(t *testing.T, d *DB) { + t.Helper() require.NoError(t, d.SaveFrictionDigest(t.Context(), testDigest("2026-09-15", 1), nil, nil)) require.NoError(t, d.UpdateFrictionDigestRender(t.Context(), "2026-09-15", []byte("new md\n"), []byte("{\"x\":1}\n"), 2)) got, err := d.GetFrictionDigest(t.Context(), "2026-09-15") diff --git a/internal/friction/review/catchup_test.go b/internal/friction/review/catchup_test.go index 951fe664fd..da86d251e0 100644 --- a/internal/friction/review/catchup_test.go +++ b/internal/friction/review/catchup_test.go @@ -30,6 +30,7 @@ func TestCatchUp(t *testing.T) { { name: "builds_exactly_the_missing_complete_dates_within_backfill", seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}, subject{id: "today", ended: "2026-09-22T09:00:00Z"}) }, @@ -39,6 +40,7 @@ func TestCatchUp(t *testing.T) { { name: "starts_after_latest_digest", seed: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}) _, err := r.BuildDate(t.Context(), "2026-09-18", review.BuildOptions{}) require.NoError(t, err) @@ -49,6 +51,7 @@ func TestCatchUp(t *testing.T) { { name: "starts_at_earliest_session", seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() seed(t, d, subject{id: "new", ended: "2026-09-20T10:00:00Z"}) }, backfill: 7, @@ -63,6 +66,7 @@ func TestCatchUp(t *testing.T) { { name: "never_today", seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() seed(t, d, subject{id: "today", ended: "2026-09-22T09:00:00Z"}) }, backfill: 7, @@ -71,6 +75,7 @@ func TestCatchUp(t *testing.T) { { name: "backfill_of_one_builds_yesterday_only", seed: func(t *testing.T, d *db.DB, _ *review.Runner) { + t.Helper() seed(t, d, subject{id: "old", ended: "2026-09-01T10:00:00Z"}) }, backfill: 1, diff --git a/internal/friction/review/runner.go b/internal/friction/review/runner.go index 23618f4300..b194f20d5d 100644 --- a/internal/friction/review/runner.go +++ b/internal/friction/review/runner.go @@ -412,17 +412,13 @@ func (r *Runner) CatchUp(ctx context.Context) ([]Report, error) { return nil, nil } start := addDays(today, -r.backfillDays()) - if earliest > start { - start = earliest - } + start = max(start, earliest) latest, err := r.Store.LatestFrictionDigestDate(ctx) if err != nil { return nil, err } if latest != "" { - if next := addDays(latest, 1); next > start { - start = next - } + start = max(start, addDays(latest, 1)) } var reports []Report for d := start; d <= yesterday; d = addDays(d, 1) { diff --git a/internal/friction/review/runner_test.go b/internal/friction/review/runner_test.go index 08167d5dd3..2de2870df5 100644 --- a/internal/friction/review/runner_test.go +++ b/internal/friction/review/runner_test.go @@ -119,6 +119,7 @@ func TestBuildDate(t *testing.T) { run func(t *testing.T, d *db.DB, r *review.Runner) }{ {name: "run_review_writes_digest_when_file_absent_even_with_no_sessions", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) assert.True(t, rep.Written) @@ -132,6 +133,7 @@ func TestBuildDate(t *testing.T) { assert.NotEmpty(t, got.RunID) }}, {name: "run_review_preserves_existing_digest_when_nothing_scanned", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seeded := db.FrictionDigest{ Date: day, Timezone: "UTC", RulesVersion: friction.RulesVersion, BuiltAt: reviewNow, Revision: 1, SnapshotJSON: []byte(`{"date":"` + day + `"}`), @@ -146,6 +148,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, "SEEDED-FROM-EARLIER-RUN\n", string(stored(t, d, day).Markdown)) }}, {name: "processed_sessions_persist_across_load", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) @@ -157,6 +160,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 0, next.Snapshot.SessionsScanned, "a subject enters one digest only") }}, {name: "rebuild_keeps_membership", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "a", ended: "2026-09-15T10:00:00Z", findings: []db.FrictionFinding{correction("a", "no, use the other file please")}}) _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) @@ -182,6 +186,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 1, occ) }}, {name: "rebuild_does_not_count_new_findings_on_old_member", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "quiet", ended: "2026-09-15T10:00:00Z"}) _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) @@ -195,6 +200,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 0, rowCount(t, d, "friction_patterns"), "old member's new findings never change recurrence") }}, {name: "dry_run_writes_nothing", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{DryRun: true}) require.NoError(t, err) @@ -204,11 +210,12 @@ func TestBuildDate(t *testing.T) { assert.Nil(t, stored(t, d, day)) assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) assert.Equal(t, 0, rowCount(t, d, "friction_patterns")) - real, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) + built, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) - assert.Equal(t, 1, real.Snapshot.SessionsScanned, "dry run did not consume the session") + assert.Equal(t, 1, built.Snapshot.SessionsScanned, "dry run did not consume the session") }}, {name: "run_review_stamps_dims_and_renders_personas_section", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{ id: "sess-helper", ended: "2026-09-15T01:00:00Z", @@ -250,6 +257,7 @@ func TestBuildDate(t *testing.T) { assert.Contains(t, body, "- `agent:claude` `machine:machine-a` `sess-coding` — ") }}, {name: "personas_rollup_carries_tokens_and_optional_cost", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{ id: "sess-cell", ended: "2026-09-15T01:00:00Z", @@ -281,6 +289,7 @@ func TestBuildDate(t *testing.T) { assert.Contains(t, body, "800 in / 40 out tokens, $1.500000") }}, {name: "stats_only_session_counts_toward_spend_and_personas", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{ id: "sess-counters", ended: "2026-09-15T01:00:00Z", dims: &db.FrictionSessionDims{Persona: "meter", DimsSource: "nanoclaw"}, usage: tokens(500, 25, ""), @@ -296,6 +305,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, uint64(500), m.InputTokens) }}, {name: "agent_and_machine_tags_are_stamped_and_rendered", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() const id = "a37ffc87-2799-4a09-830b-a92fde71d768" seed(t, d, subject{ id: id, ended: "2026-09-15T01:00:00Z", machine: "laptop-01", @@ -310,6 +320,7 @@ func TestBuildDate(t *testing.T) { "- `agent:claude` `machine:laptop-01` `"+id+"` — 'no, use the other path'") }}, {name: "subagent_cost_lands_under_subagent_role", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "root", ended: "2026-09-15T01:00:00Z", usage: tokens(10, 1, "3.1")}, subject{id: "child", ended: "2026-09-15T02:00:00Z", parent: "root", usage: tokens(10, 1, "1.1")}) @@ -320,6 +331,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, "1.100000", rep.Snapshot.Spend.RoleCosts["subagent"].String()) }}, {name: "p0_alert_needs_three_top_level_subjects", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() errFinding := func(sid string) db.FrictionFinding { return finding(sid, friction.KindError, "error", "exit 2: boom", "", "Bash", 2, 0) } @@ -332,6 +344,7 @@ func TestBuildDate(t *testing.T) { assert.Empty(t, rep.Snapshot.P0Alerts, "sub-agent subjects never count toward P0") }}, {name: "review_excluded_sessions_are_skipped_and_not_recorded", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{ id: "x", ended: "2026-09-15T01:00:00Z", dims: &db.FrictionSessionDims{ReviewExcluded: true, DimsSource: "nanoclaw"}, @@ -342,6 +355,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) }}, {name: "stale_rules_defer_then_skip_after_grace", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "stale", ended: "2026-09-15T01:00:00Z", rules: "friction-old"}) r.Now = func() time.Time { return time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC) } _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) @@ -355,6 +369,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 0, rowCount(t, d, "friction_digest_sessions")) }}, {name: "today_is_refused_unless_dry_run", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() _, err := r.BuildDate(t.Context(), "2026-09-22", review.BuildOptions{}) require.ErrorIs(t, err, review.ErrIncompleteDate) _, err = r.BuildDate(t.Context(), "2026-09-22", review.BuildOptions{DryRun: true}) @@ -363,6 +378,7 @@ func TestBuildDate(t *testing.T) { require.Error(t, err) }}, {name: "digest_path_uses_public_url", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() r.PublicURL = "https://av.example/" rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) @@ -372,6 +388,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, "friction:"+day, review.DigestURL("", day)) }}, {name: "frustration_and_interruption_kinds_flow_through", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{ correction("s1", "no, use the other file please"), finding("s1", friction.KindFrustration, "frustration", "this is still broken", "", "", 3, 1), @@ -398,6 +415,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 3, rowCount(t, d, "friction_patterns"), "every kind feeds local recurrence") }}, {name: "diagnostics_come_last_are_recorded_and_never_raise_p0", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) diag := func(id string) friction.Signal { return friction.Signal{ @@ -431,12 +449,14 @@ func TestBuildDate(t *testing.T) { assert.Len(t, rebuilt.Snapshot.Signals, 4, "rebuild keeps diagnostic members") }}, {name: "diagnostic_source_error_fails_the_build", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() r.Diagnostics = fakeDiagnostics{err: assert.AnError} _, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.ErrorIs(t, err, assert.AnError) assert.Nil(t, stored(t, d, day), "no partial digest") }}, {name: "concurrent_builder_loses_cleanly", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() seed(t, d, subject{id: "s1", ended: "2026-09-15T12:00:00Z", findings: []db.FrictionFinding{correction("s1", "no, use the other file please")}}) racing := &raceStore{Store: d, beforeSave: func() { require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ @@ -454,6 +474,7 @@ func TestBuildDate(t *testing.T) { assert.Equal(t, 0, rowCount(t, d, "friction_patterns"), "losing save rolled back") }}, {name: "archive_spend_failure_hides_block", run: func(t *testing.T, d *db.DB, r *review.Runner) { + t.Helper() r.Store = &raceStore{Store: d, archiveErr: assert.AnError} rep, err := r.BuildDate(t.Context(), day, review.BuildOptions{}) require.NoError(t, err) diff --git a/internal/postgres/friction_digest.go b/internal/postgres/friction_digest.go index ffae09b866..c5b8f7bf56 100644 --- a/internal/postgres/friction_digest.go +++ b/internal/postgres/friction_digest.go @@ -145,45 +145,11 @@ func (s *Store) FrictionFindingsForSubjects( out := []db.FrictionFinding{} for start := 0; start < len(subjectIDs); start += 500 { chunk := subjectIDs[start:min(start+500, len(subjectIDs))] - pb := ¶mBuilder{} - in := pgInPlaceholders(chunk, pb) - rows, err := s.pg.QueryContext(ctx, ` - SELECT session_id, kind, detector, message_ordinal, call_index, - tool_name, label, text, evidence, title, fingerprint, - occurred_at, seq, rules_version - FROM friction_findings - WHERE session_id IN `+in, pb.args...) + findings, err := s.frictionFindingsChunk(ctx, chunk) if err != nil { - return nil, fmt.Errorf("loading friction findings: %w", err) - } - for rows.Next() { - var ( - f db.FrictionFinding - ord, call sql.NullInt64 - occurred sql.NullTime - ) - if err := rows.Scan(&f.SessionID, &f.Kind, &f.Detector, &ord, &call, - &f.ToolName, &f.Label, &f.Text, &f.Evidence, &f.Title, &f.Fingerprint, - &occurred, &f.Seq, &f.RulesVersion); err != nil { - rows.Close() - return nil, fmt.Errorf("scanning friction finding: %w", err) - } - if ord.Valid { - f.MessageOrdinal = new(int(ord.Int64)) - } - if call.Valid { - f.CallIndex = new(int(call.Int64)) - } - if occurred.Valid { - f.OccurredAt = new(occurred.Time.UTC()) - } - out = append(out, f) - } - if err := rows.Err(); err != nil { - rows.Close() return nil, err } - rows.Close() + out = append(out, findings...) } // PG has no rowid tiebreak; seq is unique within a session's findings. // Sort in Go with byte order so collation never differs from SQLite. @@ -196,6 +162,50 @@ func (s *Store) FrictionFindingsForSubjects( return out, nil } +func (s *Store) frictionFindingsChunk( + ctx context.Context, subjectIDs []string, +) ([]db.FrictionFinding, error) { + pb := ¶mBuilder{} + in := pgInPlaceholders(subjectIDs, pb) + rows, err := s.pg.QueryContext(ctx, ` + SELECT session_id, kind, detector, message_ordinal, call_index, + tool_name, label, text, evidence, title, fingerprint, + occurred_at, seq, rules_version + FROM friction_findings + WHERE session_id IN `+in, pb.args...) + if err != nil { + return nil, fmt.Errorf("loading friction findings: %w", err) + } + defer rows.Close() + out := []db.FrictionFinding{} + for rows.Next() { + var ( + f db.FrictionFinding + ord, call sql.NullInt64 + occurred sql.NullTime + ) + if err := rows.Scan(&f.SessionID, &f.Kind, &f.Detector, &ord, &call, + &f.ToolName, &f.Label, &f.Text, &f.Evidence, &f.Title, &f.Fingerprint, + &occurred, &f.Seq, &f.RulesVersion); err != nil { + return nil, fmt.Errorf("scanning friction finding: %w", err) + } + if ord.Valid { + f.MessageOrdinal = new(int(ord.Int64)) + } + if call.Valid { + f.CallIndex = new(int(call.Int64)) + } + if occurred.Valid { + f.OccurredAt = new(occurred.Time.UTC()) + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + func (s *Store) SaveFrictionDigest( ctx context.Context, d db.FrictionDigest, subjects []db.FrictionDigestSubject, patterns []db.FrictionPatternUpdate, From 02ab9734edc64d8b9eb2a8cae3c5eb82560481f9 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:54:29 +0000 Subject: [PATCH 53/62] docs(friction): document dated digests and review state --- docs/changelog.md | 7 ++++ docs/configuration.md | 31 +++++++------- docs/internal/jilog-adaptation.md | 26 ++++++++++++ .../postgres/friction_review_pgtest_test.go | 42 +++++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 internal/postgres/friction_review_pgtest_test.go diff --git a/docs/changelog.md b/docs/changelog.md index 2a67a14917..f880ff6090 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,13 @@ The latest published release is **New features** +- AgentsView now builds a Friction Log digest for every completed local day, + including days the daemon was not running (up to `backfill_days`, default + 7). It is on by default; set `[friction] enabled = false` to turn it off. + Each session is reviewed once, the digest is kept across full resyncs, and + `pg serve` builds its own digests for the hub. Findings, including + frustration markers and interrupted turns, are deterministic heuristics; no + model is involved. - Coding agents can now consult prior conversation evidence proactively when earlier decisions or solutions may help. `agentsview skills install` upgrades the existing recall skill for Claude and Agents/Codex, and Claude diff --git a/docs/configuration.md b/docs/configuration.md index e0ac88a05e..469bfcad38 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1476,20 +1476,23 @@ its triggers. ### Schema tables -| Table | Purpose | -| -------------------- | ---------------------------------------------------------------------------- | -| `sessions` | Session metadata (project, agent, timestamps, file info, user message count) | -| `messages` | Message content with role, ordinal, timestamps | -| `tool_calls` | Tool invocations with normalized category taxonomy | -| `tool_result_events` | Chronological status events for tool calls (e.g. Codex subagent updates) | -| `insights` | AI-generated session analysis and summaries | -| `starred_sessions` | Server-side star persistence (replaces localStorage) | -| `pinned_messages` | Pinned message references with session linkage | -| `stats` | Aggregate counts (session_count, message_count) | -| `skipped_files` | Cache of non-interactive session files | -| `source_failures` | Cache of session files whose last parse failed | -| `messages_fts` | FTS5 virtual table for full-text search | -| `messages_cjk_fts` | Optional CJK FTS5 index using the `simple` character tokenizer | +| Table | Purpose | +| -------------------------- | ---------------------------------------------------------------------------------------- | +| `sessions` | Session metadata (project, agent, timestamps, file info, user message count) | +| `messages` | Message content with role, ordinal, timestamps | +| `tool_calls` | Tool invocations with normalized category taxonomy | +| `tool_result_events` | Chronological status events for tool calls (e.g. Codex subagent updates) | +| `insights` | AI-generated session analysis and summaries | +| `friction_digests` | One Friction Log digest per completed local date (Markdown, summary JSON, frozen inputs) | +| `friction_digest_sessions` | Which digest reviewed each session, so a session is reviewed once | +| `friction_patterns` | Local recurrence of each friction fingerprint across digests | +| `starred_sessions` | Server-side star persistence (replaces localStorage) | +| `pinned_messages` | Pinned message references with session linkage | +| `stats` | Aggregate counts (session_count, message_count) | +| `skipped_files` | Cache of non-interactive session files | +| `source_failures` | Cache of session files whose last parse failed | +| `messages_fts` | FTS5 virtual table for full-text search | +| `messages_cjk_fts` | Optional CJK FTS5 index using the `simple` character tokenizer | The database is automatically migrated on startup when the schema changes. When the stored data version is stale, AgentsView preserves the existing database and diff --git a/docs/internal/jilog-adaptation.md b/docs/internal/jilog-adaptation.md index 0859c2f5dd..f66fcdd1a9 100644 --- a/docs/internal/jilog-adaptation.md +++ b/docs/internal/jilog-adaptation.md @@ -217,6 +217,32 @@ Zone resolution differs from jilog `zone.rs`. `AGENTSVIEW_FRICTION_TZ` and back to the process zone instead of UTC. An empty configured timezone means unset rather than an error. +## Review orchestration (roadmap PR 5) + +Ported from jilog `9e8e094` `crates/jilog-review/src/digest.rs:208-706` +(`run_review`), `reader.rs:233-270` (processed sessions) and +`archive_spend.rs:83-109`. + +- **Added:** `frustration` and `interruption` kinds from agentsview's own + detectors flow through digests and recurrence (not counted in the persona + line); generic diagnostic subjects (`subject_kind = diagnostic`) replace + jilog's worker records and are excluded from P0 by kind, not by tool name. +- **Kept:** detector order and run order within each kind, dimension + stamping, persona rollup including sessions without signals, spend + accumulation with `(root)` role keys, P0 alerts over the digest's errors, + archive-spend window and summarizing, empty digest on the first build of a + date, dry run writes nothing. +- **Replaced:** the processed-sessions file is `friction_digest_sessions`; + "nightly" is an hourly catch-up over complete local dates; a date is built + once (jilog's same-date preservation rule becomes build-once plus explicit + rebuild); subjects are dated by last activity instead of the run day; + archive spend reads native daily usage instead of shelling out; per-session + cost is archive microdollars (scale 6), so a jilog `$1.50` renders + `$1.500000`; sub-agent spend is keyed `subagent`. +- **Dropped:** reader discovery windows and the retry sidecar (the filing + outbox replaces it in the Kata PRs); jilog's private worker collectors and + pool-seat conventions (seats come only from user `seat_patterns`). + ## License The adapted code is distributed under the MIT License: diff --git a/internal/postgres/friction_review_pgtest_test.go b/internal/postgres/friction_review_pgtest_test.go new file mode 100644 index 0000000000..9ed5b0e734 --- /dev/null +++ b/internal/postgres/friction_review_pgtest_test.go @@ -0,0 +1,42 @@ +//go:build pgtest + +package postgres + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/friction/review" +) + +// The same archive digested on SQLite and on its PostgreSQL hub produces +// identical Markdown and summary JSON. +func TestFrictionRunnerParitySQLiteAndPG(t *testing.T) { + local, pgStore := openFrictionParityStores(t) + now := func() time.Time { return time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) } + build := func(s review.Store) ([]byte, []byte) { + r := &review.Runner{Store: s, Loc: time.UTC, Now: now, BackfillDays: 30} + reps, err := r.CatchUp(t.Context()) + require.NoError(t, err) + require.Equal(t, []string{"2026-09-15", "2026-09-16", "2026-09-17", "2026-09-18", "2026-09-19", "2026-09-20", "2026-09-21"}, + func() []string { + out := []string{} + for _, rep := range reps { + out = append(out, rep.Date) + } + return out + }()) + d, err := s.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + require.NotNil(t, d) + return d.Markdown, d.SummaryJSON + } + sqliteMD, sqliteSummary := build(local) + pgMD, pgSummary := build(pgStore) + assert.Equal(t, string(sqliteMD), string(pgMD)) + assert.Equal(t, string(sqliteSummary), string(pgSummary)) + assert.Contains(t, string(pgMD), "`seat:seat-02`") +} From b508e194437c5bdd53ad9cd76e396fb45b8b0d71 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:01:35 +0000 Subject: [PATCH 54/62] fix(friction): make digest state guards explicit --- internal/friction/review/runner.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/friction/review/runner.go b/internal/friction/review/runner.go index b194f20d5d..379225c018 100644 --- a/internal/friction/review/runner.go +++ b/internal/friction/review/runner.go @@ -135,7 +135,7 @@ func signalFromFinding(f db.FrictionFinding, s db.FrictionSubject) friction.Sign func patternUpdates(date string, sigs []friction.Signal, skip map[string]bool) []db.FrictionPatternUpdate { type key struct{ fp, subject string } idx := map[key]int{} - var out []db.FrictionPatternUpdate + out := []db.FrictionPatternUpdate{} for _, s := range sigs { if skip[s.SubjectID] { continue @@ -356,6 +356,8 @@ func (r *Runner) BuildDate(ctx context.Context, date string, opts BuildOptions) } if existing != nil { revision = existing.Revision + 1 + } + if prior != nil { for _, s := range prior.Signals { if s.SubjectKind == friction.SubjectDiagnostic { counted[s.SubjectID] = true From 72d6835e5afc1bc8b0ae57b4d44b54152cf22bf8 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:23:46 +0000 Subject: [PATCH 55/62] feat(db): list friction findings, digests and patterns --- internal/clickhouse/friction_stub_test.go | 32 ++ internal/clickhouse/stubs.go | 16 + internal/db/friction_list.go | 286 ++++++++++++++++++ internal/db/friction_list_test.go | 228 ++++++++++++++ internal/db/store.go | 5 + internal/duckdb/friction_stub_test.go | 34 +++ internal/duckdb/stubs.go | 16 + internal/postgres/friction_list.go | 183 +++++++++++ .../postgres/friction_list_pgtest_test.go | 202 +++++++++++++ 9 files changed, 1002 insertions(+) create mode 100644 internal/clickhouse/friction_stub_test.go create mode 100644 internal/db/friction_list.go create mode 100644 internal/db/friction_list_test.go create mode 100644 internal/duckdb/friction_stub_test.go create mode 100644 internal/postgres/friction_list.go create mode 100644 internal/postgres/friction_list_pgtest_test.go diff --git a/internal/clickhouse/friction_stub_test.go b/internal/clickhouse/friction_stub_test.go new file mode 100644 index 0000000000..15a9ad7252 --- /dev/null +++ b/internal/clickhouse/friction_stub_test.go @@ -0,0 +1,32 @@ +package clickhouse + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestFrictionReadsAreReadOnly(t *testing.T) { + s := &Store{} + calls := map[string]func() error{ + "findings": func() error { + _, _, err := s.ListFrictionFindings(t.Context(), db.FrictionFindingFilter{}) + return err + }, + "digests": func() error { + _, err := s.ListFrictionDigests(t.Context(), "", "") + return err + }, + "patterns": func() error { + _, _, err := s.ListFrictionPatterns(t.Context(), db.FrictionPatternFilter{}) + return err + }, + } + for name, call := range calls { + t.Run(name, func(t *testing.T) { + require.ErrorIs(t, call(), db.ErrReadOnly) + }) + } +} diff --git a/internal/clickhouse/stubs.go b/internal/clickhouse/stubs.go index e3c850aad4..397a0490fb 100644 --- a/internal/clickhouse/stubs.go +++ b/internal/clickhouse/stubs.go @@ -80,3 +80,19 @@ func (s *Store) EarliestSessionDate(_ context.Context, _ *time.Location) (string func (s *Store) UpdateFrictionDigestRender(_ context.Context, _ string, _, _ []byte, _ int) error { return db.ErrReadOnly } + +func (s *Store) ListFrictionFindings( + _ context.Context, _ db.FrictionFindingFilter, +) ([]db.FrictionFinding, string, error) { + return nil, "", db.ErrReadOnly +} + +func (s *Store) ListFrictionDigests(_ context.Context, _, _ string) ([]db.FrictionDigest, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) ListFrictionPatterns( + _ context.Context, _ db.FrictionPatternFilter, +) ([]db.FrictionPattern, string, error) { + return nil, "", db.ErrReadOnly +} diff --git a/internal/db/friction_list.go b/internal/db/friction_list.go new file mode 100644 index 0000000000..8c4578f4d9 --- /dev/null +++ b/internal/db/friction_list.go @@ -0,0 +1,286 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + "time" +) + +// Friction list paging defaults. The API clamps to the same bounds. +const ( + DefaultFrictionListLimit = 100 + MaxFrictionListLimit = 1000 +) + +// Pattern link-state filter values understood before issue links exist. +// PR 10 adds the per-state values (pending, failed, needs_human, abandoned). +const ( + FrictionLinkStateLinked = "linked" + FrictionLinkStateUnlinked = "unlinked" +) + +// FrictionPattern is one local recurrence row from friction_patterns. +type FrictionPattern struct { + Fingerprint string `json:"fingerprint"` + Kind string `json:"kind"` + Title string `json:"title"` + FirstSeenDate string `json:"first_seen_date"` + LastSeenDate string `json:"last_seen_date"` + OccurrenceCount int `json:"occurrence_count"` + SessionCount int `json:"session_count"` + LastSubjectID string `json:"last_subject_id"` + LastOrdinal *int `json:"last_ordinal"` +} + +// FrictionFindingFilter narrows ListFrictionFindings. Date selects the +// sessions recorded in that digest's membership (friction_digest_sessions). +type FrictionFindingFilter struct { + Date string + Kind string + SessionID string + Fingerprint string + Limit int + Cursor string +} + +// FrictionPatternFilter narrows ListFrictionPatterns. Since keeps patterns +// whose last_seen_date is on or after the given date. +type FrictionPatternFilter struct { + Kind string + LinkState string + Since string + Limit int + Cursor string +} + +// NormalizeFrictionLimit maps an unset or out-of-range page size to the default. +func NormalizeFrictionLimit(n int) int { + if n <= 0 || n > MaxFrictionListLimit { + return DefaultFrictionListLimit + } + return n +} + +// DecodeFrictionCursor parses the opaque offset cursor used by friction lists. +func DecodeFrictionCursor(c string) (int, error) { + if c == "" { + return 0, nil + } + n, err := strconv.Atoi(c) + if err != nil || n < 0 { + return 0, fmt.Errorf("%w: friction cursor %q", ErrInvalidCursor, c) + } + return n, nil +} + +// EncodeFrictionCursor returns the cursor for offset, or "" at the start. +func EncodeFrictionCursor(offset int) string { + if offset <= 0 { + return "" + } + return strconv.Itoa(offset) +} + +// FrictionLinkFilterExcludesAll reports whether a link-state filter can match +// nothing because no issue links exist yet. "unlinked" matches every pattern. +func FrictionLinkFilterExcludesAll(state string) bool { + return state != "" && state != FrictionLinkStateUnlinked +} + +func parseFrictionTime(s string) (time.Time, error) { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{}, fmt.Errorf("parsing friction timestamp %q: %w", s, err) + } + return t, nil +} + +func nullIntPtr(v sql.NullInt64) *int { + if !v.Valid { + return nil + } + return new(int(v.Int64)) +} + +// ListFrictionFindings returns stored findings for live sessions. +func (db *DB) ListFrictionFindings( + ctx context.Context, f FrictionFindingFilter, +) ([]FrictionFinding, string, error) { + limit := NormalizeFrictionLimit(f.Limit) + offset, err := DecodeFrictionCursor(f.Cursor) + if err != nil { + return nil, "", err + } + preds := []string{"s.deleted_at IS NULL"} + var args []any + add := func(p string, v any) { preds = append(preds, p); args = append(args, v) } + if f.Date != "" { + add(`ff.session_id IN (SELECT subject_id FROM friction_digest_sessions + WHERE date = ? AND subject_kind = 'session')`, f.Date) + } + if f.Kind != "" { + add("ff.kind = ?", f.Kind) + } + if f.SessionID != "" { + add("ff.session_id = ?", f.SessionID) + } + if f.Fingerprint != "" { + add("ff.fingerprint = ?", f.Fingerprint) + } + query := ` + SELECT ff.session_id, ff.kind, ff.detector, ff.message_ordinal, + ff.call_index, ff.tool_name, ff.label, ff.text, ff.evidence, + ff.title, ff.fingerprint, ff.occurred_at, ff.seq, ff.rules_version + FROM friction_findings ff JOIN sessions s ON s.id = ff.session_id + WHERE ` + strings.Join(preds, " AND ") + ` + ORDER BY ff.session_id, ff.seq, ff.id + LIMIT ? OFFSET ?` + args = append(args, limit+1, offset) + rows, err := db.getReader().QueryContext(ctx, query, args...) + if err != nil { + return nil, "", fmt.Errorf("listing friction findings: %w", err) + } + defer rows.Close() + out := make([]FrictionFinding, 0, limit+1) + for rows.Next() { + var ( + r FrictionFinding + ordinal, callIndex sql.NullInt64 + occurred sql.NullString + ) + if err := rows.Scan(&r.SessionID, &r.Kind, &r.Detector, &ordinal, + &callIndex, &r.ToolName, &r.Label, &r.Text, &r.Evidence, + &r.Title, &r.Fingerprint, &occurred, &r.Seq, &r.RulesVersion); err != nil { + return nil, "", fmt.Errorf("scanning friction finding: %w", err) + } + r.MessageOrdinal = nullIntPtr(ordinal) + r.CallIndex = nullIntPtr(callIndex) + if occurred.Valid && occurred.String != "" { + ts, err := parseFrictionTime(occurred.String) + if err != nil { + return nil, "", err + } + r.OccurredAt = &ts + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("listing friction findings: %w", err) + } + if len(out) > limit { + return out[:limit], EncodeFrictionCursor(offset + limit), nil + } + return out, "", nil +} + +// ListFrictionDigests returns digest metadata, newest first, without the +// snapshot, summary and Markdown blobs. +func (db *DB) ListFrictionDigests( + ctx context.Context, from, to string, +) ([]FrictionDigest, error) { + var preds []string + var args []any + if from != "" { + preds = append(preds, "date >= ?") + args = append(args, from) + } + if to != "" { + preds = append(preds, "date <= ?") + args = append(args, to) + } + where := "" + if len(preds) > 0 { + where = "WHERE " + strings.Join(preds, " AND ") + } + rows, err := db.getReader().QueryContext(ctx, ` + SELECT date, timezone, rules_version, built_at, revision, + sessions_scanned, markdown_sha256, run_id + FROM friction_digests `+where+` + ORDER BY date DESC`, args...) + if err != nil { + return nil, fmt.Errorf("listing friction digests: %w", err) + } + defer rows.Close() + out := []FrictionDigest{} + for rows.Next() { + var ( + d FrictionDigest + built string + ) + if err := rows.Scan(&d.Date, &d.Timezone, &d.RulesVersion, &built, + &d.Revision, &d.SessionsScanned, &d.MarkdownSHA256, &d.RunID); err != nil { + return nil, fmt.Errorf("scanning friction digest: %w", err) + } + if d.BuiltAt, err = parseFrictionTime(built); err != nil { + return nil, err + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("listing friction digests: %w", err) + } + return out, nil +} + +// ListFrictionPatterns returns local recurrence rows ranked by occurrences. +func (db *DB) ListFrictionPatterns( + ctx context.Context, f FrictionPatternFilter, +) ([]FrictionPattern, string, error) { + limit := NormalizeFrictionLimit(f.Limit) + offset, err := DecodeFrictionCursor(f.Cursor) + if err != nil { + return nil, "", err + } + if FrictionLinkFilterExcludesAll(f.LinkState) { + return []FrictionPattern{}, "", nil + } + var preds []string + var args []any + if f.Kind != "" { + preds = append(preds, "kind = ?") + args = append(args, f.Kind) + } + if f.Since != "" { + preds = append(preds, "last_seen_date >= ?") + args = append(args, f.Since) + } + where := "" + if len(preds) > 0 { + where = "WHERE " + strings.Join(preds, " AND ") + } + args = append(args, limit+1, offset) + rows, err := db.getReader().QueryContext(ctx, ` + SELECT fingerprint, kind, title, first_seen_date, last_seen_date, + occurrence_count, session_count, last_subject_id, last_ordinal + FROM friction_patterns `+where+` + ORDER BY occurrence_count DESC, last_seen_date DESC, fingerprint ASC + LIMIT ? OFFSET ?`, args...) + if err != nil { + return nil, "", fmt.Errorf("listing friction patterns: %w", err) + } + defer rows.Close() + out := make([]FrictionPattern, 0, limit+1) + for rows.Next() { + var ( + p FrictionPattern + ordinal sql.NullInt64 + ) + if err := rows.Scan(&p.Fingerprint, &p.Kind, &p.Title, &p.FirstSeenDate, + &p.LastSeenDate, &p.OccurrenceCount, &p.SessionCount, + &p.LastSubjectID, &ordinal); err != nil { + return nil, "", fmt.Errorf("scanning friction pattern: %w", err) + } + p.LastOrdinal = nullIntPtr(ordinal) + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("listing friction patterns: %w", err) + } + if len(out) > limit { + return out[:limit], EncodeFrictionCursor(offset + limit), nil + } + return out, "", nil +} diff --git a/internal/db/friction_list_test.go b/internal/db/friction_list_test.go new file mode 100644 index 0000000000..1e2427b6ae --- /dev/null +++ b/internal/db/friction_list_test.go @@ -0,0 +1,228 @@ +package db + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedFrictionListFixture(t *testing.T, d *DB) { + t.Helper() + ctx := t.Context() + for _, id := range []string{"claude:s1", "claude:s2", "claude:gone"} { + insertSession(t, d, id, "proj") + } + at := time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + finding := func(sid, kind, detector, fp string, seq int, ord *int) FrictionFinding { + return FrictionFinding{ + SessionID: sid, Kind: kind, Detector: detector, + MessageOrdinal: ord, Title: "[friction/" + kind + "] " + sid, + Fingerprint: fp, OccurredAt: &at, Seq: seq, + RulesVersion: "friction-v1", + } + } + require.NoError(t, d.ReplaceSessionFriction(ctx, "claude:s1", []FrictionFinding{ + finding("claude:s1", "correction", "correction.coding", "fl1:aaa", 0, new(3)), + finding("claude:s1", "error", "error", "fl1:bbb", 1, new(5)), + }, nil, "friction-v1", "hash-s1")) + require.NoError(t, d.ReplaceSessionFriction(ctx, "claude:s2", []FrictionFinding{ + finding("claude:s2", "pattern", "pattern.retry_loop", "fl1:ccc", 0, nil), + }, nil, "friction-v1", "hash-s2")) + require.NoError(t, d.ReplaceSessionFriction(ctx, "claude:gone", []FrictionFinding{ + finding("claude:gone", "error", "error", "fl1:bbb", 0, new(1)), + }, nil, "friction-v1", "hash-gone")) + require.NoError(t, d.SoftDeleteSession(ctx, "claude:gone")) + + built := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + save := func(date, subject string, patterns []FrictionPatternUpdate) { + require.NoError(t, d.SaveFrictionDigest(ctx, FrictionDigest{ + Date: date, Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: built, Revision: 1, SessionsScanned: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), + Markdown: []byte("# Friction Log — " + date + "\n"), + MarkdownSHA256: "sha-" + date, RunID: "run-" + date, + }, []FrictionDigestSubject{{SubjectID: subject, Date: date, SubjectKind: "session"}}, patterns)) + } + save("2026-09-13", "claude:s2", []FrictionPatternUpdate{ + {Fingerprint: "fl1:ccc", Kind: "pattern", Title: "ccc", Date: "2026-09-13", SubjectID: "claude:s2", Occurrences: 1}, + }) + save("2026-09-14", "claude:s1", []FrictionPatternUpdate{ + {Fingerprint: "fl1:aaa", Kind: "correction", Title: "aaa", Date: "2026-09-14", SubjectID: "claude:s1", Ordinal: new(3), Occurrences: 1}, + {Fingerprint: "fl1:bbb", Kind: "error", Title: "bbb", Date: "2026-09-14", SubjectID: "claude:s1", Ordinal: new(5), Occurrences: 2}, + }) +} + +type findingKey struct { + SessionID string + Seq int +} + +func findingKeys(rows []FrictionFinding) []findingKey { + out := make([]findingKey, 0, len(rows)) + for _, r := range rows { + out = append(out, findingKey{r.SessionID, r.Seq}) + } + return out +} + +func TestListFrictionFindings(t *testing.T) { + d := testDB(t) + seedFrictionListFixture(t, d) + tests := []struct { + name string + filter FrictionFindingFilter + want []findingKey + wantCursor string + }{ + { + "all_hides_soft_deleted", + FrictionFindingFilter{}, + []findingKey{{"claude:s1", 0}, {"claude:s1", 1}, {"claude:s2", 0}}, + "", + }, + {"kind", FrictionFindingFilter{Kind: "error"}, []findingKey{{"claude:s1", 1}}, ""}, + {"session", FrictionFindingFilter{SessionID: "claude:s2"}, []findingKey{{"claude:s2", 0}}, ""}, + {"fingerprint", FrictionFindingFilter{Fingerprint: "fl1:bbb"}, []findingKey{{"claude:s1", 1}}, ""}, + { + "digest_date", + FrictionFindingFilter{Date: "2026-09-14"}, + []findingKey{{"claude:s1", 0}, {"claude:s1", 1}}, + "", + }, + {"digest_date_other", FrictionFindingFilter{Date: "2026-09-13"}, []findingKey{{"claude:s2", 0}}, ""}, + { + "page_one", + FrictionFindingFilter{Limit: 2}, + []findingKey{{"claude:s1", 0}, {"claude:s1", 1}}, + "2", + }, + {"page_two", FrictionFindingFilter{Limit: 2, Cursor: "2"}, []findingKey{{"claude:s2", 0}}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, next, err := d.ListFrictionFindings(t.Context(), tt.filter) + require.NoError(t, err) + assert.Equal(t, tt.want, findingKeys(rows)) + assert.Equal(t, tt.wantCursor, next) + }) + } + t.Run("fields_round_trip", func(t *testing.T) { + rows, _, err := d.ListFrictionFindings(t.Context(), FrictionFindingFilter{SessionID: "claude:s1", Kind: "correction"}) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "correction.coding", rows[0].Detector) + assert.Equal(t, "fl1:aaa", rows[0].Fingerprint) + require.NotNil(t, rows[0].MessageOrdinal) + assert.Equal(t, 3, *rows[0].MessageOrdinal) + require.NotNil(t, rows[0].OccurredAt) + assert.True(t, rows[0].OccurredAt.Equal(time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC))) + }) + for _, bad := range []string{"x", "-1", "1.5"} { + t.Run("bad_cursor_"+bad, func(t *testing.T) { + _, _, err := d.ListFrictionFindings(t.Context(), FrictionFindingFilter{Cursor: bad}) + require.ErrorIs(t, err, ErrInvalidCursor) + }) + } +} + +func TestListFrictionDigests(t *testing.T) { + d := testDB(t) + seedFrictionListFixture(t, d) + tests := []struct { + name string + from, to string + want []string + }{ + {"all_newest_first", "", "", []string{"2026-09-14", "2026-09-13"}}, + {"from", "2026-09-14", "", []string{"2026-09-14"}}, + {"to", "", "2026-09-13", []string{"2026-09-13"}}, + {"empty_range", "2026-10-01", "2026-10-02", []string{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, err := d.ListFrictionDigests(t.Context(), tt.from, tt.to) + require.NoError(t, err) + dates := make([]string, 0, len(rows)) + for _, r := range rows { + dates = append(dates, r.Date) + assert.Nil(t, r.SnapshotJSON) + assert.Nil(t, r.SummaryJSON) + assert.Nil(t, r.Markdown) + assert.Equal(t, "UTC", r.Timezone) + assert.Equal(t, 1, r.Revision) + } + assert.Equal(t, tt.want, dates) + }) + } +} + +func TestListFrictionPatterns(t *testing.T) { + d := testDB(t) + seedFrictionListFixture(t, d) + tests := []struct { + name string + filter FrictionPatternFilter + want []string + wantCursor string + }{ + {"ranked", FrictionPatternFilter{}, []string{"fl1:bbb", "fl1:aaa", "fl1:ccc"}, ""}, + {"kind", FrictionPatternFilter{Kind: "pattern"}, []string{"fl1:ccc"}, ""}, + {"since", FrictionPatternFilter{Since: "2026-09-14"}, []string{"fl1:bbb", "fl1:aaa"}, ""}, + {"linked_is_empty_before_links_exist", FrictionPatternFilter{LinkState: FrictionLinkStateLinked}, []string{}, ""}, + {"unlinked_is_everything", FrictionPatternFilter{LinkState: FrictionLinkStateUnlinked}, []string{"fl1:bbb", "fl1:aaa", "fl1:ccc"}, ""}, + {"page", FrictionPatternFilter{Limit: 1}, []string{"fl1:bbb"}, "1"}, + {"page_two", FrictionPatternFilter{Limit: 2, Cursor: "1"}, []string{"fl1:aaa", "fl1:ccc"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, next, err := d.ListFrictionPatterns(t.Context(), tt.filter) + require.NoError(t, err) + got := make([]string, 0, len(rows)) + for _, r := range rows { + got = append(got, r.Fingerprint) + } + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantCursor, next) + }) + } + t.Run("fields", func(t *testing.T) { + rows, _, err := d.ListFrictionPatterns(t.Context(), FrictionPatternFilter{Kind: "error"}) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "2026-09-14", rows[0].FirstSeenDate) + assert.Equal(t, "2026-09-14", rows[0].LastSeenDate) + assert.Equal(t, 2, rows[0].OccurrenceCount) + assert.Equal(t, "claude:s1", rows[0].LastSubjectID) + require.NotNil(t, rows[0].LastOrdinal) + assert.Equal(t, 5, *rows[0].LastOrdinal) + }) +} + +func TestFrictionListHelpers(t *testing.T) { + limits := []struct{ in, want int }{{0, 100}, {-3, 100}, {50, 50}, {1000, 1000}, {1001, 100}} + for _, tt := range limits { + assert.Equal(t, tt.want, NormalizeFrictionLimit(tt.in), "limit %d", tt.in) + } + cursors := []struct { + in string + want int + ok bool + }{{"", 0, true}, {"0", 0, true}, {"25", 25, true}, {"-1", 0, false}, {"abc", 0, false}} + for _, tt := range cursors { + got, err := DecodeFrictionCursor(tt.in) + if tt.ok { + require.NoError(t, err, tt.in) + assert.Equal(t, tt.want, got) + continue + } + require.ErrorIs(t, err, ErrInvalidCursor, tt.in) + } + assert.Empty(t, EncodeFrictionCursor(0)) + assert.Equal(t, "40", EncodeFrictionCursor(40)) + assert.False(t, FrictionLinkFilterExcludesAll("")) + assert.False(t, FrictionLinkFilterExcludesAll(FrictionLinkStateUnlinked)) + assert.True(t, FrictionLinkFilterExcludesAll(FrictionLinkStateLinked)) + assert.True(t, FrictionLinkFilterExcludesAll("pending")) +} diff --git a/internal/db/store.go b/internal/db/store.go index 2dbbae0727..7b611b06c1 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -169,6 +169,11 @@ type Store interface { EarliestSessionDate(ctx context.Context, loc *time.Location) (string, error) UpdateFrictionDigestRender(ctx context.Context, date string, markdown, summaryJSON []byte, revision int) error + // Friction Log reads. + ListFrictionFindings(ctx context.Context, f FrictionFindingFilter) ([]FrictionFinding, string, error) + ListFrictionDigests(ctx context.Context, from, to string) ([]FrictionDigest, error) + ListFrictionPatterns(ctx context.Context, f FrictionPatternFilter) ([]FrictionPattern, string, error) + // ReadOnly returns true for remote/PG-backed stores. ReadOnly() bool } diff --git a/internal/duckdb/friction_stub_test.go b/internal/duckdb/friction_stub_test.go new file mode 100644 index 0000000000..9ad980144d --- /dev/null +++ b/internal/duckdb/friction_stub_test.go @@ -0,0 +1,34 @@ +//go:build !(windows && arm64) + +package duckdb + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" +) + +func TestFrictionReadsAreReadOnly(t *testing.T) { + s := &Store{} + calls := map[string]func() error{ + "findings": func() error { + _, _, err := s.ListFrictionFindings(t.Context(), db.FrictionFindingFilter{}) + return err + }, + "digests": func() error { + _, err := s.ListFrictionDigests(t.Context(), "", "") + return err + }, + "patterns": func() error { + _, _, err := s.ListFrictionPatterns(t.Context(), db.FrictionPatternFilter{}) + return err + }, + } + for name, call := range calls { + t.Run(name, func(t *testing.T) { + require.ErrorIs(t, call(), db.ErrReadOnly) + }) + } +} diff --git a/internal/duckdb/stubs.go b/internal/duckdb/stubs.go index 93ce8ced0d..77b3332ad0 100644 --- a/internal/duckdb/stubs.go +++ b/internal/duckdb/stubs.go @@ -82,3 +82,19 @@ func (s *Store) EarliestSessionDate(_ context.Context, _ *time.Location) (string func (s *Store) UpdateFrictionDigestRender(_ context.Context, _ string, _, _ []byte, _ int) error { return db.ErrReadOnly } + +func (s *Store) ListFrictionFindings( + _ context.Context, _ db.FrictionFindingFilter, +) ([]db.FrictionFinding, string, error) { + return nil, "", db.ErrReadOnly +} + +func (s *Store) ListFrictionDigests(_ context.Context, _, _ string) ([]db.FrictionDigest, error) { + return nil, db.ErrReadOnly +} + +func (s *Store) ListFrictionPatterns( + _ context.Context, _ db.FrictionPatternFilter, +) ([]db.FrictionPattern, string, error) { + return nil, "", db.ErrReadOnly +} diff --git a/internal/postgres/friction_list.go b/internal/postgres/friction_list.go new file mode 100644 index 0000000000..8a3446e384 --- /dev/null +++ b/internal/postgres/friction_list.go @@ -0,0 +1,183 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "go.kenn.io/agentsview/internal/db" +) + +func pgNullIntPtr(v sql.NullInt64) *int { + if !v.Valid { + return nil + } + return new(int(v.Int64)) +} + +// ListFrictionFindings mirrors internal/db/friction_list.go. +func (s *Store) ListFrictionFindings( + ctx context.Context, f db.FrictionFindingFilter, +) ([]db.FrictionFinding, string, error) { + limit := db.NormalizeFrictionLimit(f.Limit) + offset, err := db.DecodeFrictionCursor(f.Cursor) + if err != nil { + return nil, "", err + } + pb := ¶mBuilder{} + preds := []string{"s.deleted_at IS NULL"} + if f.Date != "" { + preds = append(preds, `ff.session_id IN (SELECT subject_id FROM friction_digest_sessions + WHERE date = `+pb.add(f.Date)+` AND subject_kind = 'session')`) + } + if f.Kind != "" { + preds = append(preds, "ff.kind = "+pb.add(f.Kind)) + } + if f.SessionID != "" { + preds = append(preds, "ff.session_id = "+pb.add(f.SessionID)) + } + if f.Fingerprint != "" { + preds = append(preds, "ff.fingerprint = "+pb.add(f.Fingerprint)) + } + limitParam := pb.add(limit + 1) + offsetParam := pb.add(offset) + rows, err := s.pg.QueryContext(ctx, ` + SELECT ff.session_id, ff.kind, ff.detector, ff.message_ordinal, + ff.call_index, ff.tool_name, ff.label, ff.text, ff.evidence, + ff.title, ff.fingerprint, ff.occurred_at, ff.seq, ff.rules_version + FROM friction_findings ff JOIN sessions s ON s.id = ff.session_id + WHERE `+strings.Join(preds, " AND ")+` + ORDER BY ff.session_id, ff.seq, ff.id + LIMIT `+limitParam+` OFFSET `+offsetParam, pb.args...) + if err != nil { + return nil, "", fmt.Errorf("listing friction findings: %w", err) + } + defer rows.Close() + out := make([]db.FrictionFinding, 0, limit+1) + for rows.Next() { + var ( + r db.FrictionFinding + ordinal, callIndex sql.NullInt64 + occurred sql.NullTime + ) + if err := rows.Scan(&r.SessionID, &r.Kind, &r.Detector, &ordinal, + &callIndex, &r.ToolName, &r.Label, &r.Text, &r.Evidence, + &r.Title, &r.Fingerprint, &occurred, &r.Seq, &r.RulesVersion); err != nil { + return nil, "", fmt.Errorf("scanning friction finding: %w", err) + } + r.MessageOrdinal = pgNullIntPtr(ordinal) + r.CallIndex = pgNullIntPtr(callIndex) + if occurred.Valid { + r.OccurredAt = new(occurred.Time.UTC()) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("listing friction findings: %w", err) + } + if len(out) > limit { + return out[:limit], db.EncodeFrictionCursor(offset + limit), nil + } + return out, "", nil +} + +// ListFrictionDigests mirrors internal/db/friction_list.go. +func (s *Store) ListFrictionDigests( + ctx context.Context, from, to string, +) ([]db.FrictionDigest, error) { + pb := ¶mBuilder{} + var preds []string + if from != "" { + preds = append(preds, "date >= "+pb.add(from)) + } + if to != "" { + preds = append(preds, "date <= "+pb.add(to)) + } + where := "" + if len(preds) > 0 { + where = "WHERE " + strings.Join(preds, " AND ") + } + rows, err := s.pg.QueryContext(ctx, ` + SELECT date, timezone, rules_version, built_at, revision, + sessions_scanned, markdown_sha256, run_id + FROM friction_digests `+where+` + ORDER BY date DESC`, pb.args...) + if err != nil { + return nil, fmt.Errorf("listing friction digests: %w", err) + } + defer rows.Close() + out := []db.FrictionDigest{} + for rows.Next() { + var d db.FrictionDigest + if err := rows.Scan(&d.Date, &d.Timezone, &d.RulesVersion, &d.BuiltAt, + &d.Revision, &d.SessionsScanned, &d.MarkdownSHA256, &d.RunID); err != nil { + return nil, fmt.Errorf("scanning friction digest: %w", err) + } + d.BuiltAt = d.BuiltAt.UTC() + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("listing friction digests: %w", err) + } + return out, nil +} + +// ListFrictionPatterns mirrors internal/db/friction_list.go. +func (s *Store) ListFrictionPatterns( + ctx context.Context, f db.FrictionPatternFilter, +) ([]db.FrictionPattern, string, error) { + limit := db.NormalizeFrictionLimit(f.Limit) + offset, err := db.DecodeFrictionCursor(f.Cursor) + if err != nil { + return nil, "", err + } + if db.FrictionLinkFilterExcludesAll(f.LinkState) { + return []db.FrictionPattern{}, "", nil + } + pb := ¶mBuilder{} + var preds []string + if f.Kind != "" { + preds = append(preds, "kind = "+pb.add(f.Kind)) + } + if f.Since != "" { + preds = append(preds, "last_seen_date >= "+pb.add(f.Since)) + } + where := "" + if len(preds) > 0 { + where = "WHERE " + strings.Join(preds, " AND ") + } + limitParam := pb.add(limit + 1) + offsetParam := pb.add(offset) + rows, err := s.pg.QueryContext(ctx, ` + SELECT fingerprint, kind, title, first_seen_date, last_seen_date, + occurrence_count, session_count, last_subject_id, last_ordinal + FROM friction_patterns `+where+` + ORDER BY occurrence_count DESC, last_seen_date DESC, fingerprint ASC + LIMIT `+limitParam+` OFFSET `+offsetParam, pb.args...) + if err != nil { + return nil, "", fmt.Errorf("listing friction patterns: %w", err) + } + defer rows.Close() + out := make([]db.FrictionPattern, 0, limit+1) + for rows.Next() { + var ( + p db.FrictionPattern + ordinal sql.NullInt64 + ) + if err := rows.Scan(&p.Fingerprint, &p.Kind, &p.Title, &p.FirstSeenDate, + &p.LastSeenDate, &p.OccurrenceCount, &p.SessionCount, + &p.LastSubjectID, &ordinal); err != nil { + return nil, "", fmt.Errorf("scanning friction pattern: %w", err) + } + p.LastOrdinal = pgNullIntPtr(ordinal) + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, "", fmt.Errorf("listing friction patterns: %w", err) + } + if len(out) > limit { + return out[:limit], db.EncodeFrictionCursor(offset + limit), nil + } + return out, "", nil +} diff --git a/internal/postgres/friction_list_pgtest_test.go b/internal/postgres/friction_list_pgtest_test.go new file mode 100644 index 0000000000..d88d952876 --- /dev/null +++ b/internal/postgres/friction_list_pgtest_test.go @@ -0,0 +1,202 @@ +//go:build pgtest + +package postgres + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" +) + +type frictionFixtureFinding struct { + sid, kind, detector, fp string + seq int + ord *int +} + +var frictionFixtureFindings = []frictionFixtureFinding{ + {"claude:s1", "correction", "correction.coding", "fl1:aaa", 0, new(3)}, + {"claude:s1", "error", "error", "fl1:bbb", 1, new(5)}, + {"claude:s2", "pattern", "pattern.retry_loop", "fl1:ccc", 0, nil}, + {"claude:gone", "error", "error", "fl1:bbb", 0, new(1)}, +} + +var frictionFixtureAt = time.Date(2026, 9, 14, 10, 0, 0, 0, time.UTC) + +func frictionFixtureDigests() []struct { + d db.FrictionDigest + subject string + patterns []db.FrictionPatternUpdate +} { + built := time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC) + mk := func(date string) db.FrictionDigest { + return db.FrictionDigest{ + Date: date, Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: built, Revision: 1, SessionsScanned: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), + Markdown: []byte("# Friction Log — " + date + "\n"), + MarkdownSHA256: "sha-" + date, RunID: "run-" + date, + } + } + return []struct { + d db.FrictionDigest + subject string + patterns []db.FrictionPatternUpdate + }{ + {mk("2026-09-13"), "claude:s2", []db.FrictionPatternUpdate{ + {Fingerprint: "fl1:ccc", Kind: "pattern", Title: "ccc", Date: "2026-09-13", SubjectID: "claude:s2", Occurrences: 1}, + }}, + {mk("2026-09-14"), "claude:s1", []db.FrictionPatternUpdate{ + {Fingerprint: "fl1:aaa", Kind: "correction", Title: "aaa", Date: "2026-09-14", SubjectID: "claude:s1", Ordinal: new(3), Occurrences: 1}, + {Fingerprint: "fl1:bbb", Kind: "error", Title: "bbb", Date: "2026-09-14", SubjectID: "claude:s1", Ordinal: new(5), Occurrences: 2}, + }}, + } +} + +func seedFrictionSQLite(t *testing.T) *db.DB { + t.Helper() + d := dbtest.OpenTestDB(t) + ctx := t.Context() + bySession := map[string][]db.FrictionFinding{} + for _, f := range frictionFixtureFindings { + dbtest.SeedSession(t, d, f.sid, "proj") + bySession[f.sid] = append(bySession[f.sid], db.FrictionFinding{ + SessionID: f.sid, Kind: f.kind, Detector: f.detector, + MessageOrdinal: f.ord, Title: "[friction/" + f.kind + "] " + f.sid, + Fingerprint: f.fp, OccurredAt: &frictionFixtureAt, Seq: f.seq, + RulesVersion: "friction-v1", + }) + } + for sid, rows := range bySession { + require.NoError(t, d.ReplaceSessionFriction(ctx, sid, rows, nil, "friction-v1", "hash-"+sid)) + } + require.NoError(t, d.SoftDeleteSession(ctx, "claude:gone")) + for _, dg := range frictionFixtureDigests() { + require.NoError(t, d.SaveFrictionDigest(ctx, dg.d, + []db.FrictionDigestSubject{{SubjectID: dg.subject, Date: dg.d.Date, SubjectKind: "session"}}, + dg.patterns)) + } + return d +} + +func seedFrictionPG(t *testing.T, store *Store) { + t.Helper() + pg := store.DB() + ctx := t.Context() + for _, table := range []string{"friction_findings", "friction_digest_sessions", "friction_patterns", "friction_digests"} { + _, err := pg.ExecContext(ctx, "DELETE FROM "+table) + require.NoError(t, err, table) + } + seen := map[string]bool{} + for _, f := range frictionFixtureFindings { + if !seen[f.sid] { + seen[f.sid] = true + _, err := pg.ExecContext(ctx, `DELETE FROM sessions WHERE id = $1`, f.sid) + require.NoError(t, err) + var deleted any + if f.sid == "claude:gone" { + deleted = frictionFixtureAt + } + _, err = pg.ExecContext(ctx, ` + INSERT INTO sessions (id, machine, project, agent, first_message, + message_count, user_message_count, deleted_at) + VALUES ($1, 'test-machine', 'proj', 'claude', 'test', 1, 0, $2)`, + f.sid, deleted) + require.NoError(t, err, "insert session %s", f.sid) + } + _, err := pg.ExecContext(ctx, ` + INSERT INTO friction_findings (session_id, kind, detector, + message_ordinal, call_index, tool_name, label, text, evidence, + title, fingerprint, occurred_at, seq, rules_version) + VALUES ($1,$2,$3,$4,NULL,'','','','',$5,$6,$7,$8,'friction-v1')`, + f.sid, f.kind, f.detector, f.ord, "[friction/"+f.kind+"] "+f.sid, + f.fp, frictionFixtureAt, f.seq) + require.NoError(t, err, "insert finding") + } + for _, dg := range frictionFixtureDigests() { + require.NoError(t, store.SaveFrictionDigest(ctx, dg.d, + []db.FrictionDigestSubject{{SubjectID: dg.subject, Date: dg.d.Date, SubjectKind: "session"}}, + dg.patterns)) + } +} + +func TestFrictionListParity(t *testing.T) { + pgURL := testPGURL(t) + ensureStoreSchema(t, pgURL) + store, err := NewStore(pgURL, testSchema, true) + require.NoError(t, err) + defer store.Close() + seedFrictionPG(t, store) + local := seedFrictionSQLite(t) + ctx := t.Context() + + findingFilters := map[string]db.FrictionFindingFilter{ + "all": {}, + "kind": {Kind: "error"}, + "session": {SessionID: "claude:s2"}, + "fingerprint": {Fingerprint: "fl1:bbb"}, + "date": {Date: "2026-09-14"}, + "page_one": {Limit: 2}, + "page_two": {Limit: 2, Cursor: "2"}, + } + for name, f := range findingFilters { + t.Run("findings_"+name, func(t *testing.T) { + want, wantNext, err := local.ListFrictionFindings(ctx, f) + require.NoError(t, err) + got, gotNext, err := store.ListFrictionFindings(ctx, f) + require.NoError(t, err) + require.Len(t, got, len(want)) + for i := range want { + require.NotNil(t, got[i].OccurredAt) + assert.True(t, want[i].OccurredAt.Equal(*got[i].OccurredAt)) + want[i].OccurredAt, got[i].OccurredAt = nil, nil + } + assert.Equal(t, want, got) + assert.Equal(t, wantNext, gotNext) + }) + } + + for name, rng := range map[string][2]string{"all": {"", ""}, "from": {"2026-09-14", ""}, "to": {"", "2026-09-13"}} { + t.Run("digests_"+name, func(t *testing.T) { + want, err := local.ListFrictionDigests(ctx, rng[0], rng[1]) + require.NoError(t, err) + got, err := store.ListFrictionDigests(ctx, rng[0], rng[1]) + require.NoError(t, err) + require.Len(t, got, len(want)) + for i := range want { + assert.True(t, want[i].BuiltAt.Equal(got[i].BuiltAt)) + want[i].BuiltAt, got[i].BuiltAt = time.Time{}, time.Time{} + } + assert.Equal(t, want, got) + }) + } + + patternFilters := map[string]db.FrictionPatternFilter{ + "all": {}, + "kind": {Kind: "pattern"}, + "since": {Since: "2026-09-14"}, + "linked": {LinkState: db.FrictionLinkStateLinked}, + "unlinked": {LinkState: db.FrictionLinkStateUnlinked}, + "page": {Limit: 1}, + } + for name, f := range patternFilters { + t.Run("patterns_"+name, func(t *testing.T) { + want, wantNext, err := local.ListFrictionPatterns(ctx, f) + require.NoError(t, err) + got, gotNext, err := store.ListFrictionPatterns(ctx, f) + require.NoError(t, err) + assert.Equal(t, want, got) + assert.Equal(t, wantNext, gotNext) + }) + } + + t.Run("bad_cursor", func(t *testing.T) { + _, _, err := store.ListFrictionFindings(ctx, db.FrictionFindingFilter{Cursor: "nope"}) + require.ErrorIs(t, err, db.ErrInvalidCursor) + }) +} From 876c67f0f410e32691cb303752850e64f0ed8c3f Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:28:30 +0000 Subject: [PATCH 56/62] feat(friction): resolve review run requests --- internal/friction/review/run_request.go | 47 ++++++++++++ internal/friction/review/run_request_test.go | 76 ++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 internal/friction/review/run_request.go create mode 100644 internal/friction/review/run_request_test.go diff --git a/internal/friction/review/run_request.go b/internal/friction/review/run_request.go new file mode 100644 index 0000000000..e1442847bc --- /dev/null +++ b/internal/friction/review/run_request.go @@ -0,0 +1,47 @@ +package review + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ErrInvalidRunDate marks a malformed date or a day not yet complete locally. +var ErrInvalidRunDate = errors.New("invalid friction run date") + +// RunRequest is the shared request for the friction run command and API. +type RunRequest struct { + Date string + Rebuild bool + DryRun bool +} + +// LatestCompleteDate returns yesterday in the runner's time zone. +func (r *Runner) LatestCompleteDate() string { + return r.now().In(r.loc()).AddDate(0, 0, -1).Format(time.DateOnly) +} + +// Run catches up when called with no options, or builds one requested date. +func (r *Runner) Run(ctx context.Context, req RunRequest) ([]Report, error) { + if req.Date == "" && !req.Rebuild && !req.DryRun { + return r.CatchUp(ctx) + } + latest := r.LatestCompleteDate() + date := req.Date + if date == "" { + date = latest + } + parsed, err := time.Parse(time.DateOnly, date) + if err != nil || parsed.Format(time.DateOnly) != date { + return nil, fmt.Errorf("%w: %q is not YYYY-MM-DD", ErrInvalidRunDate, date) + } + if date > latest { + return nil, fmt.Errorf("%w: %s is not a complete local day (latest is %s)", ErrInvalidRunDate, date, latest) + } + report, err := r.BuildDate(ctx, date, BuildOptions{Rebuild: req.Rebuild, DryRun: req.DryRun}) + if err != nil { + return nil, err + } + return []Report{report}, nil +} diff --git a/internal/friction/review/run_request_test.go b/internal/friction/review/run_request_test.go new file mode 100644 index 0000000000..2e7340e2a6 --- /dev/null +++ b/internal/friction/review/run_request_test.go @@ -0,0 +1,76 @@ +package review + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/dbtest" +) + +func TestRunnerRun(t *testing.T) { + now := time.Date(2026, 9, 16, 2, 30, 0, 0, time.UTC) + newRunner := func(t *testing.T) *Runner { + return &Runner{ + Store: dbtest.OpenTestDB(t), Loc: time.UTC, + Now: func() time.Time { return now }, BackfillDays: 7, + } + } + t.Run("latest_complete_date_is_yesterday_in_zone", func(t *testing.T) { + r := newRunner(t) + assert.Equal(t, "2026-09-15", r.LatestCompleteDate()) + tokyo, err := time.LoadLocation("Asia/Tokyo") + require.NoError(t, err) + r.Loc = tokyo + assert.Equal(t, "2026-09-15", r.LatestCompleteDate()) + r.Now = func() time.Time { return time.Date(2026, 9, 16, 16, 0, 0, 0, time.UTC) } + assert.Equal(t, "2026-09-16", r.LatestCompleteDate()) + r.Loc = nil + assert.Equal(t, "2026-09-15", r.LatestCompleteDate()) + }) + invalid := []struct { + name string + req RunRequest + }{ + {"malformed", RunRequest{Date: "2026-9-1"}}, + {"today", RunRequest{Date: "2026-09-16"}}, + {"future_dry_run", RunRequest{Date: "2026-10-01", DryRun: true}}, + } + for _, tt := range invalid { + t.Run("rejects_"+tt.name, func(t *testing.T) { + _, err := newRunner(t).Run(t.Context(), tt.req) + require.ErrorIs(t, err, ErrInvalidRunDate) + }) + } + t.Run("dry_run_without_date_targets_yesterday_and_writes_nothing", func(t *testing.T) { + r := newRunner(t) + reports, err := r.Run(t.Context(), RunRequest{DryRun: true}) + require.NoError(t, err) + require.Len(t, reports, 1) + assert.Equal(t, "2026-09-15", reports[0].Date) + assert.False(t, reports[0].Written) + d, err := r.Store.GetFrictionDigest(t.Context(), "2026-09-15") + require.NoError(t, err) + assert.Nil(t, d) + }) + t.Run("explicit_date_builds_once", func(t *testing.T) { + r := newRunner(t) + reports, err := r.Run(t.Context(), RunRequest{Date: "2026-09-14"}) + require.NoError(t, err) + require.Len(t, reports, 1) + assert.True(t, reports[0].Written) + again, err := r.Run(t.Context(), RunRequest{Date: "2026-09-14"}) + require.NoError(t, err) + require.Len(t, again, 1) + assert.False(t, again[0].Written) + }) + t.Run("empty_request_is_catch_up", func(t *testing.T) { + reports, err := newRunner(t).Run(t.Context(), RunRequest{}) + require.NoError(t, err) + for _, rep := range reports { + assert.Less(t, rep.Date, "2026-09-16") + } + }) +} From d5697e66d98ef17b64a8f0907837b817bc91c790 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:39:43 +0000 Subject: [PATCH 57/62] feat(server): friction log API routes --- .../lib/api/generated/friction/friction.ts | 182 + frontend/src/lib/api/generated/index.ts | 1 + .../generated/models/frictionArchivePeriod.ts | 12 + .../models/frictionArchivePeriodAgentsUsd.ts | 5 + .../models/frictionArchivePeriodModelsUsd.ts | 5 + .../generated/models/frictionArchiveSpend.ts | 12 + .../generated/models/frictionCreatedIssue.ts | 10 + .../generated/models/frictionDigestItem.ts | 12 + .../models/frictionDigestResponse.ts | 20 + .../models/frictionDigestsResponse.ts | 8 + .../generated/models/frictionFindingItem.ts | 23 + .../models/frictionFindingsResponse.ts | 9 + .../api/generated/models/frictionP0Alert.ts | 8 + .../generated/models/frictionPatternItem.ts | 16 + .../models/frictionPatternsResponse.ts | 9 + .../models/frictionPersonaSummary.ts | 21 + .../api/generated/models/frictionRunReport.ts | 11 + .../generated/models/frictionRunRequest.ts | 12 + .../generated/models/frictionRunResponse.ts | 8 + .../api/generated/models/frictionSignal.ts | 28 + .../generated/models/frictionSpendSummary.ts | 18 + .../frictionSpendSummaryModelCostsUsd.ts | 5 + .../frictionSpendSummaryRoleCostsUsd.ts | 5 + .../api/generated/models/frictionSummary.ts | 28 + .../models/frictionSummaryP0Alerts.ts | 5 + .../models/frictionSummaryPersonas.ts | 6 + ...V1FrictionDigestsByDateMdPathParameters.ts | 7 + ...piV1FrictionDigestsByDatePathParameters.ts | 7 + .../models/getApiV1FrictionDigestsParams.ts | 14 + .../models/getApiV1FrictionFindingsParams.ts | 30 + .../models/getApiV1FrictionPatternsParams.ts | 26 + .../src/lib/api/generated/models/index.ts | 29 + internal/apiclient/client.gen.go | 2533 ++++++- internal/apiclient/generate.yaml | 6 + .../postgres/friction_http_pgtest_test.go | 81 + internal/server/huma_route_groups.go | 1 + internal/server/huma_routes_friction.go | 549 ++ .../huma_routes_friction_internal_test.go | 27 + internal/server/huma_routes_friction_test.go | 298 + openapi.yaml | 6701 ++++++++++------- 40 files changed, 7916 insertions(+), 2872 deletions(-) create mode 100644 frontend/src/lib/api/generated/friction/friction.ts create mode 100644 frontend/src/lib/api/generated/models/frictionArchivePeriod.ts create mode 100644 frontend/src/lib/api/generated/models/frictionArchivePeriodAgentsUsd.ts create mode 100644 frontend/src/lib/api/generated/models/frictionArchivePeriodModelsUsd.ts create mode 100644 frontend/src/lib/api/generated/models/frictionArchiveSpend.ts create mode 100644 frontend/src/lib/api/generated/models/frictionCreatedIssue.ts create mode 100644 frontend/src/lib/api/generated/models/frictionDigestItem.ts create mode 100644 frontend/src/lib/api/generated/models/frictionDigestResponse.ts create mode 100644 frontend/src/lib/api/generated/models/frictionDigestsResponse.ts create mode 100644 frontend/src/lib/api/generated/models/frictionFindingItem.ts create mode 100644 frontend/src/lib/api/generated/models/frictionFindingsResponse.ts create mode 100644 frontend/src/lib/api/generated/models/frictionP0Alert.ts create mode 100644 frontend/src/lib/api/generated/models/frictionPatternItem.ts create mode 100644 frontend/src/lib/api/generated/models/frictionPatternsResponse.ts create mode 100644 frontend/src/lib/api/generated/models/frictionPersonaSummary.ts create mode 100644 frontend/src/lib/api/generated/models/frictionRunReport.ts create mode 100644 frontend/src/lib/api/generated/models/frictionRunRequest.ts create mode 100644 frontend/src/lib/api/generated/models/frictionRunResponse.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSignal.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSpendSummary.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSpendSummaryModelCostsUsd.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSpendSummaryRoleCostsUsd.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSummary.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSummaryP0Alerts.ts create mode 100644 frontend/src/lib/api/generated/models/frictionSummaryPersonas.ts create mode 100644 frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDateMdPathParameters.ts create mode 100644 frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDatePathParameters.ts create mode 100644 frontend/src/lib/api/generated/models/getApiV1FrictionDigestsParams.ts create mode 100644 frontend/src/lib/api/generated/models/getApiV1FrictionFindingsParams.ts create mode 100644 frontend/src/lib/api/generated/models/getApiV1FrictionPatternsParams.ts create mode 100644 internal/postgres/friction_http_pgtest_test.go create mode 100644 internal/server/huma_routes_friction.go create mode 100644 internal/server/huma_routes_friction_internal_test.go create mode 100644 internal/server/huma_routes_friction_test.go diff --git a/frontend/src/lib/api/generated/friction/friction.ts b/frontend/src/lib/api/generated/friction/friction.ts new file mode 100644 index 0000000000..23f67f7f99 --- /dev/null +++ b/frontend/src/lib/api/generated/friction/friction.ts @@ -0,0 +1,182 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { + FrictionDigestResponse, + FrictionDigestsResponse, + FrictionFindingsResponse, + FrictionPatternsResponse, + FrictionRunRequest, + FrictionRunResponse, + GetApiV1FrictionDigestsByDateMdPathParameters, + GetApiV1FrictionDigestsByDatePathParameters, + GetApiV1FrictionDigestsParams, + GetApiV1FrictionFindingsParams, + GetApiV1FrictionPatternsParams, +} from "../models"; + +import { orvalFetch } from "../../runtime.ts"; +import { orvalRequest } from "../../runtime.ts"; + +export const getGetApiV1FrictionDigestsUrl = (params?: GetApiV1FrictionDigestsParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : String(value)); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/friction/digests?${stringifiedParams}` + : `/api/v1/friction/digests`; +}; + +/** + * @summary List friction digests + */ +export const getApiV1FrictionDigests = async ( + params?: GetApiV1FrictionDigestsParams, + options?: Parameters[1], +): Promise => { + return orvalFetch(getGetApiV1FrictionDigestsUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getGetApiV1FrictionDigestsByDateUrl = ({ + date, +}: GetApiV1FrictionDigestsByDatePathParameters) => { + return `/api/v1/friction/digests/${encodeURIComponent(String(date))}`; +}; + +/** + * @summary Get friction digest + */ +export const getApiV1FrictionDigestsByDate = async ( + { date }: GetApiV1FrictionDigestsByDatePathParameters, + options?: Parameters[1], +): Promise => { + return orvalFetch(getGetApiV1FrictionDigestsByDateUrl({ date }), { + ...options, + method: "GET", + }); +}; + +export const getGetApiV1FrictionDigestsByDateMdUrl = ({ + date, +}: GetApiV1FrictionDigestsByDateMdPathParameters) => { + return `/api/v1/friction/digests/${encodeURIComponent(String(date))}/md`; +}; + +/** + * @summary Get friction digest as Markdown + */ +export const getApiV1FrictionDigestsByDateMd = async ( + { date }: GetApiV1FrictionDigestsByDateMdPathParameters, + options?: Parameters[1], +): Promise => { + return orvalRequest(getGetApiV1FrictionDigestsByDateMdUrl({ date }), { + ...options, + method: "GET", + }); +}; + +export const getGetApiV1FrictionFindingsUrl = (params?: GetApiV1FrictionFindingsParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : String(value)); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/friction/findings?${stringifiedParams}` + : `/api/v1/friction/findings`; +}; + +/** + * @summary List friction findings + */ +export const getApiV1FrictionFindings = async ( + params?: GetApiV1FrictionFindingsParams, + options?: Parameters[1], +): Promise => { + return orvalFetch(getGetApiV1FrictionFindingsUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getGetApiV1FrictionPatternsUrl = (params?: GetApiV1FrictionPatternsParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : String(value)); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/v1/friction/patterns?${stringifiedParams}` + : `/api/v1/friction/patterns`; +}; + +/** + * @summary List friction patterns + */ +export const getApiV1FrictionPatterns = async ( + params?: GetApiV1FrictionPatternsParams, + options?: Parameters[1], +): Promise => { + return orvalFetch(getGetApiV1FrictionPatternsUrl(params), { + ...options, + method: "GET", + }); +}; + +export const getPostApiV1FrictionRunUrl = () => { + return `/api/v1/friction/run`; +}; + +/** + * @summary Run friction review + */ +export const postApiV1FrictionRun = async ( + frictionRunRequest: FrictionRunRequest, + options?: Parameters[1], +): Promise => { + const getHeaders = ( + h?: NonNullable, + ): Record => { + if (!h) return {}; + if (h instanceof Headers) return Object.fromEntries(h.entries()); + if (Symbol.iterator in h) { + return Object.fromEntries( + Array.from( + h as Iterable>, + (entry) => Array.from(entry) as [string, string], + ), + ); + } + const headers: Record = {}; + for (const [name, value] of Object.entries(h)) { + if (value !== undefined) headers[name] = value; + } + return headers; + }; + return orvalFetch(getPostApiV1FrictionRunUrl(), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...getHeaders(options?.headers) }, + body: JSON.stringify(frictionRunRequest), + }); +}; diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index 108040f8c5..e326849187 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -5,6 +5,7 @@ export * as AssetsService from "./assets/assets.ts"; export * as ConfigService from "./config/config.ts"; export * as DataService from "./data/data.ts"; export * as EmbeddingsService from "./embeddings/embeddings.ts"; +export * as FrictionService from "./friction/friction.ts"; export * as HealthService from "./health/health.ts"; export * as ImportService from "./import/import.ts"; export * as InsightsService from "./insights/insights.ts"; diff --git a/frontend/src/lib/api/generated/models/frictionArchivePeriod.ts b/frontend/src/lib/api/generated/models/frictionArchivePeriod.ts new file mode 100644 index 0000000000..67d2691aba --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionArchivePeriod.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionArchivePeriodAgentsUsd } from "./frictionArchivePeriodAgentsUsd.ts"; +import type { FrictionArchivePeriodModelsUsd } from "./frictionArchivePeriodModelsUsd.ts"; + +export interface FrictionArchivePeriod { + agents_usd: FrictionArchivePeriodAgentsUsd; + days: number; + models_usd: FrictionArchivePeriodModelsUsd; + total_usd: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionArchivePeriodAgentsUsd.ts b/frontend/src/lib/api/generated/models/frictionArchivePeriodAgentsUsd.ts new file mode 100644 index 0000000000..0a33a24ea3 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionArchivePeriodAgentsUsd.ts @@ -0,0 +1,5 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type FrictionArchivePeriodAgentsUsd = { [key: string]: string }; diff --git a/frontend/src/lib/api/generated/models/frictionArchivePeriodModelsUsd.ts b/frontend/src/lib/api/generated/models/frictionArchivePeriodModelsUsd.ts new file mode 100644 index 0000000000..8688b8d674 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionArchivePeriodModelsUsd.ts @@ -0,0 +1,5 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type FrictionArchivePeriodModelsUsd = { [key: string]: string }; diff --git a/frontend/src/lib/api/generated/models/frictionArchiveSpend.ts b/frontend/src/lib/api/generated/models/frictionArchiveSpend.ts new file mode 100644 index 0000000000..12f807ef08 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionArchiveSpend.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionArchivePeriod } from "./frictionArchivePeriod.ts"; + +export interface FrictionArchiveSpend { + timezone: string; + week: FrictionArchivePeriod; + week_from: string; + week_to: string; + yesterday: FrictionArchivePeriod; +} diff --git a/frontend/src/lib/api/generated/models/frictionCreatedIssue.ts b/frontend/src/lib/api/generated/models/frictionCreatedIssue.ts new file mode 100644 index 0000000000..5fd3e07c37 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionCreatedIssue.ts @@ -0,0 +1,10 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionCreatedIssue { + backend: string; + id: string; + title: string; + url: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionDigestItem.ts b/frontend/src/lib/api/generated/models/frictionDigestItem.ts new file mode 100644 index 0000000000..a28d4326b7 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionDigestItem.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionDigestItem { + built_at: string; + date: string; + revision: number; + rules_version: string; + sessions_scanned: number; + timezone: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionDigestResponse.ts b/frontend/src/lib/api/generated/models/frictionDigestResponse.ts new file mode 100644 index 0000000000..b262f69240 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionDigestResponse.ts @@ -0,0 +1,20 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionP0Alert } from "./frictionP0Alert.ts"; +import type { FrictionSignal } from "./frictionSignal.ts"; +import type { FrictionSummary } from "./frictionSummary.ts"; + +export interface FrictionDigestResponse { + built_at: string; + date: string; + markdown_sha256: string; + p0_alerts: FrictionP0Alert[]; + revision: number; + rules_version: string; + sessions_scanned: number; + signals: FrictionSignal[]; + summary: FrictionSummary; + timezone: string; + web_url: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionDigestsResponse.ts b/frontend/src/lib/api/generated/models/frictionDigestsResponse.ts new file mode 100644 index 0000000000..8ff14dba85 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionDigestsResponse.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionDigestItem } from "./frictionDigestItem.ts"; + +export interface FrictionDigestsResponse { + digests: FrictionDigestItem[]; +} diff --git a/frontend/src/lib/api/generated/models/frictionFindingItem.ts b/frontend/src/lib/api/generated/models/frictionFindingItem.ts new file mode 100644 index 0000000000..802dc5af32 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionFindingItem.ts @@ -0,0 +1,23 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionFindingItem { + /** @nullable */ + call_index: number | null; + detector: string; + evidence: string; + fingerprint: string; + kind: string; + label: string; + /** @nullable */ + message_ordinal: number | null; + /** @nullable */ + occurred_at: string | null; + rules_version: string; + seq: number; + session_id: string; + text: string; + title: string; + tool_name: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionFindingsResponse.ts b/frontend/src/lib/api/generated/models/frictionFindingsResponse.ts new file mode 100644 index 0000000000..86e14c4b74 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionFindingsResponse.ts @@ -0,0 +1,9 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionFindingItem } from "./frictionFindingItem.ts"; + +export interface FrictionFindingsResponse { + findings: FrictionFindingItem[]; + next_cursor: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionP0Alert.ts b/frontend/src/lib/api/generated/models/frictionP0Alert.ts new file mode 100644 index 0000000000..c6417069e1 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionP0Alert.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionP0Alert { + subject_ids: string[]; + tool: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionPatternItem.ts b/frontend/src/lib/api/generated/models/frictionPatternItem.ts new file mode 100644 index 0000000000..a4ecf052f5 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionPatternItem.ts @@ -0,0 +1,16 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionPatternItem { + fingerprint: string; + first_seen_date: string; + kind: string; + /** @nullable */ + last_ordinal: number | null; + last_seen_date: string; + last_subject_id: string; + occurrence_count: number; + session_count: number; + title: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionPatternsResponse.ts b/frontend/src/lib/api/generated/models/frictionPatternsResponse.ts new file mode 100644 index 0000000000..944ba2d4bd --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionPatternsResponse.ts @@ -0,0 +1,9 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionPatternItem } from "./frictionPatternItem.ts"; + +export interface FrictionPatternsResponse { + next_cursor: string; + patterns: FrictionPatternItem[]; +} diff --git a/frontend/src/lib/api/generated/models/frictionPersonaSummary.ts b/frontend/src/lib/api/generated/models/frictionPersonaSummary.ts new file mode 100644 index 0000000000..9ad1a19598 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionPersonaSummary.ts @@ -0,0 +1,21 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionPersonaSummary { + /** @nullable */ + channel: string | null; + corrections: number; + /** @nullable */ + cost_usd: string | null; + deferrals: number; + errors: number; + /** @minimum 0 */ + input_tokens: number; + /** @minimum 0 */ + output_tokens: number; + patterns: number; + persona: string; + sessions: number; + workarounds: number; +} diff --git a/frontend/src/lib/api/generated/models/frictionRunReport.ts b/frontend/src/lib/api/generated/models/frictionRunReport.ts new file mode 100644 index 0000000000..e7930a9a12 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionRunReport.ts @@ -0,0 +1,11 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionSummary } from "./frictionSummary.ts"; + +export interface FrictionRunReport { + date: string; + human: string; + summary: FrictionSummary; + written: boolean; +} diff --git a/frontend/src/lib/api/generated/models/frictionRunRequest.ts b/frontend/src/lib/api/generated/models/frictionRunRequest.ts new file mode 100644 index 0000000000..00435b3d76 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionRunRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionRunRequest { + /** Digest date (YYYY-MM-DD); omit to catch up every missing complete date */ + date?: string; + /** Compute and render without writing */ + dry_run?: boolean; + /** Rebuild an existing digest, keeping its membership */ + rebuild?: boolean; +} diff --git a/frontend/src/lib/api/generated/models/frictionRunResponse.ts b/frontend/src/lib/api/generated/models/frictionRunResponse.ts new file mode 100644 index 0000000000..50a40f6142 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionRunResponse.ts @@ -0,0 +1,8 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionRunReport } from "./frictionRunReport.ts"; + +export interface FrictionRunResponse { + reports: FrictionRunReport[]; +} diff --git a/frontend/src/lib/api/generated/models/frictionSignal.ts b/frontend/src/lib/api/generated/models/frictionSignal.ts new file mode 100644 index 0000000000..45f34f4d6e --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSignal.ts @@ -0,0 +1,28 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export interface FrictionSignal { + agent: string; + /** @nullable */ + call_index: number | null; + channel: string; + detector: string; + evidence: string; + fingerprint: string; + kind: string; + label: string; + machine: string; + /** @nullable */ + message_ordinal: number | null; + /** @nullable */ + occurred_at: string | null; + persona: string; + seat: string; + session_url: string; + subject_id: string; + subject_kind: string; + text: string; + title: string; + tool_name: string; +} diff --git a/frontend/src/lib/api/generated/models/frictionSpendSummary.ts b/frontend/src/lib/api/generated/models/frictionSpendSummary.ts new file mode 100644 index 0000000000..7f1ba744d0 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSpendSummary.ts @@ -0,0 +1,18 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionSpendSummaryModelCostsUsd } from "./frictionSpendSummaryModelCostsUsd.ts"; +import type { FrictionSpendSummaryRoleCostsUsd } from "./frictionSpendSummaryRoleCostsUsd.ts"; + +export interface FrictionSpendSummary { + /** @minimum 0 */ + input_tokens: number; + model_costs_usd: FrictionSpendSummaryModelCostsUsd; + /** @minimum 0 */ + output_tokens: number; + role_costs_usd: FrictionSpendSummaryRoleCostsUsd; + sessions_with_cost: number; + sessions_with_stats: number; + /** @nullable */ + total_usd: string | null; +} diff --git a/frontend/src/lib/api/generated/models/frictionSpendSummaryModelCostsUsd.ts b/frontend/src/lib/api/generated/models/frictionSpendSummaryModelCostsUsd.ts new file mode 100644 index 0000000000..86ea6cb033 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSpendSummaryModelCostsUsd.ts @@ -0,0 +1,5 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type FrictionSpendSummaryModelCostsUsd = { [key: string]: string }; diff --git a/frontend/src/lib/api/generated/models/frictionSpendSummaryRoleCostsUsd.ts b/frontend/src/lib/api/generated/models/frictionSpendSummaryRoleCostsUsd.ts new file mode 100644 index 0000000000..875572ac6c --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSpendSummaryRoleCostsUsd.ts @@ -0,0 +1,5 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type FrictionSpendSummaryRoleCostsUsd = { [key: string]: string }; diff --git a/frontend/src/lib/api/generated/models/frictionSummary.ts b/frontend/src/lib/api/generated/models/frictionSummary.ts new file mode 100644 index 0000000000..af09361f9a --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSummary.ts @@ -0,0 +1,28 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionArchiveSpend } from "./frictionArchiveSpend.ts"; +import type { FrictionCreatedIssue } from "./frictionCreatedIssue.ts"; +import type { FrictionSpendSummary } from "./frictionSpendSummary.ts"; +import type { FrictionSummaryP0Alerts } from "./frictionSummaryP0Alerts.ts"; +import type { FrictionSummaryPersonas } from "./frictionSummaryPersonas.ts"; + +export interface FrictionSummary { + archive_spend?: FrictionArchiveSpend; + corrections: number; + created_issues: FrictionCreatedIssue[]; + deferrals: number; + /** @nullable */ + digest_path: string | null; + errors: number; + frustrations: number; + interruptions: number; + p0_alerts: FrictionSummaryP0Alerts; + patterns: number; + personas: FrictionSummaryPersonas; + schema_version: number; + sessions_scanned: number; + spend: FrictionSpendSummary; + tracker_failures: number; + workarounds: number; +} diff --git a/frontend/src/lib/api/generated/models/frictionSummaryP0Alerts.ts b/frontend/src/lib/api/generated/models/frictionSummaryP0Alerts.ts new file mode 100644 index 0000000000..8d13fe3d0d --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSummaryP0Alerts.ts @@ -0,0 +1,5 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type FrictionSummaryP0Alerts = { [key: string]: string[] }; diff --git a/frontend/src/lib/api/generated/models/frictionSummaryPersonas.ts b/frontend/src/lib/api/generated/models/frictionSummaryPersonas.ts new file mode 100644 index 0000000000..5ec6da7a52 --- /dev/null +++ b/frontend/src/lib/api/generated/models/frictionSummaryPersonas.ts @@ -0,0 +1,6 @@ +/** + * Generated by Orval. Do not edit manually. + */ +import type { FrictionPersonaSummary } from "./frictionPersonaSummary.ts"; + +export type FrictionSummaryPersonas = { [key: string]: FrictionPersonaSummary }; diff --git a/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDateMdPathParameters.ts b/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDateMdPathParameters.ts new file mode 100644 index 0000000000..a879afa7b4 --- /dev/null +++ b/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDateMdPathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetApiV1FrictionDigestsByDateMdPathParameters = { + date: string; +}; diff --git a/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDatePathParameters.ts b/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDatePathParameters.ts new file mode 100644 index 0000000000..581ff5278e --- /dev/null +++ b/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsByDatePathParameters.ts @@ -0,0 +1,7 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetApiV1FrictionDigestsByDatePathParameters = { + date: string; +}; diff --git a/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsParams.ts b/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsParams.ts new file mode 100644 index 0000000000..bc428a01e4 --- /dev/null +++ b/frontend/src/lib/api/generated/models/getApiV1FrictionDigestsParams.ts @@ -0,0 +1,14 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetApiV1FrictionDigestsParams = { + /** + * First digest date (YYYY-MM-DD) + */ + from?: string; + /** + * Last digest date (YYYY-MM-DD) + */ + to?: string; +}; diff --git a/frontend/src/lib/api/generated/models/getApiV1FrictionFindingsParams.ts b/frontend/src/lib/api/generated/models/getApiV1FrictionFindingsParams.ts new file mode 100644 index 0000000000..c322692728 --- /dev/null +++ b/frontend/src/lib/api/generated/models/getApiV1FrictionFindingsParams.ts @@ -0,0 +1,30 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetApiV1FrictionFindingsParams = { + /** + * Only findings from sessions in this digest (YYYY-MM-DD) + */ + date?: string; + /** + * correction, error, workaround, deferral, pattern, frustration or interruption + */ + kind?: string; + /** + * Session ID + */ + session_id?: string; + /** + * Pattern fingerprint (fl1:) + */ + fingerprint?: string; + /** + * Page size, 1-1000 (default 100) + */ + limit?: number; + /** + * Cursor from next_cursor + */ + cursor?: string; +}; diff --git a/frontend/src/lib/api/generated/models/getApiV1FrictionPatternsParams.ts b/frontend/src/lib/api/generated/models/getApiV1FrictionPatternsParams.ts new file mode 100644 index 0000000000..e4556acfc8 --- /dev/null +++ b/frontend/src/lib/api/generated/models/getApiV1FrictionPatternsParams.ts @@ -0,0 +1,26 @@ +/** + * Generated by Orval. Do not edit manually. + */ + +export type GetApiV1FrictionPatternsParams = { + /** + * correction, error, workaround, deferral, pattern, frustration or interruption + */ + kind?: string; + /** + * linked, unlinked, pending, failed, needs_human or abandoned + */ + link_state?: string; + /** + * Only patterns last seen on or after this date (YYYY-MM-DD) + */ + since?: string; + /** + * Page size, 1-1000 (default 100) + */ + limit?: number; + /** + * Cursor from next_cursor + */ + cursor?: string; +}; diff --git a/frontend/src/lib/api/generated/models/index.ts b/frontend/src/lib/api/generated/models/index.ts index 61b07b80ae..5a0ee6304b 100644 --- a/frontend/src/lib/api/generated/models/index.ts +++ b/frontend/src/lib/api/generated/models/index.ts @@ -202,6 +202,30 @@ export * from "./exportPricingFallback.ts"; export * from "./exportProjectIdentity.ts"; export * from "./exportProjectMapEntry.ts"; export * from "./fillStats.ts"; +export * from "./frictionArchivePeriod.ts"; +export * from "./frictionArchivePeriodAgentsUsd.ts"; +export * from "./frictionArchivePeriodModelsUsd.ts"; +export * from "./frictionArchiveSpend.ts"; +export * from "./frictionCreatedIssue.ts"; +export * from "./frictionDigestItem.ts"; +export * from "./frictionDigestResponse.ts"; +export * from "./frictionDigestsResponse.ts"; +export * from "./frictionFindingItem.ts"; +export * from "./frictionFindingsResponse.ts"; +export * from "./frictionP0Alert.ts"; +export * from "./frictionPatternItem.ts"; +export * from "./frictionPatternsResponse.ts"; +export * from "./frictionPersonaSummary.ts"; +export * from "./frictionRunReport.ts"; +export * from "./frictionRunRequest.ts"; +export * from "./frictionRunResponse.ts"; +export * from "./frictionSignal.ts"; +export * from "./frictionSpendSummary.ts"; +export * from "./frictionSpendSummaryModelCostsUsd.ts"; +export * from "./frictionSpendSummaryRoleCostsUsd.ts"; +export * from "./frictionSummary.ts"; +export * from "./frictionSummaryP0Alerts.ts"; +export * from "./frictionSummaryPersonas.ts"; export * from "./generateInsightRequest.ts"; export * from "./getApiV1ActivityReportBucket.ts"; export * from "./getApiV1ActivityReportByReportIdSessionsDirection.ts"; @@ -248,6 +272,11 @@ export * from "./getApiV1DataProjectsByProjectKeySessionsPathParameters.ts"; export * from "./getApiV1DataProjectsParams.ts"; export * from "./getApiV1EmbeddingsGenerationsParams.ts"; export * from "./getApiV1EmbeddingsStatusParams.ts"; +export * from "./getApiV1FrictionDigestsByDateMdPathParameters.ts"; +export * from "./getApiV1FrictionDigestsByDatePathParameters.ts"; +export * from "./getApiV1FrictionDigestsParams.ts"; +export * from "./getApiV1FrictionFindingsParams.ts"; +export * from "./getApiV1FrictionPatternsParams.ts"; export * from "./getApiV1InsightsByIdExportPathParameters.ts"; export * from "./getApiV1InsightsByIdMdPathParameters.ts"; export * from "./getApiV1InsightsByIdPathParameters.ts"; diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 2d212ccea2..221dc047e5 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -505,6 +505,270 @@ func (o *GetAPIV1EmbeddingsStatusRequestOptions) GetHeader() (map[string]string, return nil, nil } +// GetAPIV1FrictionDigestsRequestOptions is the options needed to make a request to GetAPIV1FrictionDigests. +type GetAPIV1FrictionDigestsRequestOptions struct { + Query *GetAPIV1FrictionDigestsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetAPIV1FrictionDigestsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetAPIV1FrictionDigestsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *GetAPIV1FrictionDigestsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetAPIV1FrictionDigestsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetAPIV1FrictionDigestsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetAPIV1FrictionDigestsDateRequestOptions is the options needed to make a request to GetAPIV1FrictionDigestsDate. +type GetAPIV1FrictionDigestsDateRequestOptions struct { + PathParams *GetAPIV1FrictionDigestsDatePath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetAPIV1FrictionDigestsDateRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetAPIV1FrictionDigestsDateRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetAPIV1FrictionDigestsDateRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetAPIV1FrictionDigestsDateRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetAPIV1FrictionDigestsDateRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetAPIV1FrictionDigestsDateMdRequestOptions is the options needed to make a request to GetAPIV1FrictionDigestsDateMd. +type GetAPIV1FrictionDigestsDateMdRequestOptions struct { + PathParams *GetAPIV1FrictionDigestsDateMdPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetAPIV1FrictionDigestsDateMdRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetAPIV1FrictionDigestsDateMdRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *GetAPIV1FrictionDigestsDateMdRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetAPIV1FrictionDigestsDateMdRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetAPIV1FrictionDigestsDateMdRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetAPIV1FrictionFindingsRequestOptions is the options needed to make a request to GetAPIV1FrictionFindings. +type GetAPIV1FrictionFindingsRequestOptions struct { + Query *GetAPIV1FrictionFindingsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetAPIV1FrictionFindingsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetAPIV1FrictionFindingsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *GetAPIV1FrictionFindingsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetAPIV1FrictionFindingsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetAPIV1FrictionFindingsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// GetAPIV1FrictionPatternsRequestOptions is the options needed to make a request to GetAPIV1FrictionPatterns. +type GetAPIV1FrictionPatternsRequestOptions struct { + Query *GetAPIV1FrictionPatternsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *GetAPIV1FrictionPatternsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *GetAPIV1FrictionPatternsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *GetAPIV1FrictionPatternsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *GetAPIV1FrictionPatternsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *GetAPIV1FrictionPatternsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// PostAPIV1FrictionRunRequestOptions is the options needed to make a request to PostAPIV1FrictionRun. +type PostAPIV1FrictionRunRequestOptions struct { + Body *PostAPIV1FrictionRunBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *PostAPIV1FrictionRunRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *PostAPIV1FrictionRunRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *PostAPIV1FrictionRunRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *PostAPIV1FrictionRunRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *PostAPIV1FrictionRunRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // GetAPIV1MachinesRequestOptions is the options needed to make a request to GetAPIV1Machines. type GetAPIV1MachinesRequestOptions struct { Query *GetAPIV1MachinesQuery @@ -4534,6 +4798,12 @@ type ClientInterface interface { PostAPIV1EmbeddingsGenerationsIDActivateWithResponse(ctx context.Context, options *PostAPIV1EmbeddingsGenerationsIDActivateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PostAPIV1EmbeddingsGenerationsIDActivateResp, error) PostAPIV1EmbeddingsGenerationsIDRetireWithResponse(ctx context.Context, options *PostAPIV1EmbeddingsGenerationsIDRetireRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PostAPIV1EmbeddingsGenerationsIDRetireResp, error) GetAPIV1EmbeddingsStatusWithResponse(ctx context.Context, options *GetAPIV1EmbeddingsStatusRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1EmbeddingsStatusResp, error) + GetAPIV1FrictionDigestsWithResponse(ctx context.Context, options *GetAPIV1FrictionDigestsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionDigestsResp, error) + GetAPIV1FrictionDigestsDateWithResponse(ctx context.Context, options *GetAPIV1FrictionDigestsDateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionDigestsDateResp, error) + GetAPIV1FrictionDigestsDateMdWithResponse(ctx context.Context, options *GetAPIV1FrictionDigestsDateMdRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionDigestsDateMdResp, error) + GetAPIV1FrictionFindingsWithResponse(ctx context.Context, options *GetAPIV1FrictionFindingsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionFindingsResp, error) + GetAPIV1FrictionPatternsWithResponse(ctx context.Context, options *GetAPIV1FrictionPatternsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionPatternsResp, error) + PostAPIV1FrictionRunWithResponse(ctx context.Context, options *PostAPIV1FrictionRunRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PostAPIV1FrictionRunResp, error) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetAPIV1MachinesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1MachinesResp, error) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetAPIV1ProjectsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1ProjectsResp, error) PostAPIV1PushClickhouseWithResponse(ctx context.Context, options *PostAPIV1PushClickhouseRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PostAPIV1PushClickhouseResp, error) @@ -6472,11 +6742,11 @@ func (c *Client) GetAPIV1EmbeddingsStatusWithResponse(ctx context.Context, optio } } -// GetAPIV1Machines List machines -func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetAPIV1MachinesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1MachinesResp, error) { +// GetAPIV1FrictionDigests List friction digests +func (c *Client) GetAPIV1FrictionDigestsWithResponse(ctx context.Context, options *GetAPIV1FrictionDigestsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionDigestsResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/machines", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/friction/digests", Method: "GET", Options: options, } @@ -6486,12 +6756,12 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/machines") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/friction/digests") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetAPIV1MachinesResp{ + out := &GetAPIV1FrictionDigestsResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -6499,7 +6769,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA switch resp.StatusCode { case 200: - out.JSON200 = new(GetAPIV1MachinesResponse) + out.JSON200 = new(GetAPIV1FrictionDigestsResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -6507,7 +6777,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesResponse", + TargetType: "GetAPIV1FrictionDigestsResponse", Body: bodyBytes, Err: err, } @@ -6515,7 +6785,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, nil case 400: - out.JSON400 = new(GetAPIV1MachinesErrorResponse) + out.JSON400 = new(GetAPIV1FrictionDigestsErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { @@ -6523,7 +6793,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponse", + TargetType: "GetAPIV1FrictionDigestsErrorResponse", Body: bodyBytes, Err: err, } @@ -6531,7 +6801,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 401: - out.JSON401 = new(GetAPIV1MachinesErrorResponseJSON) + out.JSON401 = new(GetAPIV1FrictionDigestsErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { @@ -6539,7 +6809,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -6547,7 +6817,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 403: - out.JSON403 = new(GetAPIV1MachinesErrorResponseJSON403) + out.JSON403 = new(GetAPIV1FrictionDigestsErrorResponseJSON403) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { @@ -6555,7 +6825,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON403", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON403", Body: bodyBytes, Err: err, } @@ -6563,7 +6833,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 404: - out.JSON404 = new(GetAPIV1MachinesErrorResponseJSON404) + out.JSON404 = new(GetAPIV1FrictionDigestsErrorResponseJSON404) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { @@ -6571,7 +6841,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON404", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON404", Body: bodyBytes, Err: err, } @@ -6579,7 +6849,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 409: - out.JSON409 = new(GetAPIV1MachinesErrorResponseJSON409) + out.JSON409 = new(GetAPIV1FrictionDigestsErrorResponseJSON409) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { @@ -6587,7 +6857,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON409", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON409", Body: bodyBytes, Err: err, } @@ -6595,7 +6865,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 422: - out.JSON422 = new(GetAPIV1MachinesErrorResponseJSON422) + out.JSON422 = new(GetAPIV1FrictionDigestsErrorResponseJSON422) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { @@ -6603,7 +6873,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON422", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON422", Body: bodyBytes, Err: err, } @@ -6611,7 +6881,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 500: - out.JSON500 = new(GetAPIV1MachinesErrorResponseJSON500) + out.JSON500 = new(GetAPIV1FrictionDigestsErrorResponseJSON500) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { @@ -6619,7 +6889,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON500", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON500", Body: bodyBytes, Err: err, } @@ -6627,7 +6897,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 501: - out.JSON501 = new(GetAPIV1MachinesErrorResponseJSON501) + out.JSON501 = new(GetAPIV1FrictionDigestsErrorResponseJSON501) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { @@ -6635,7 +6905,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON501", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON501", Body: bodyBytes, Err: err, } @@ -6643,7 +6913,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 502: - out.JSON502 = new(GetAPIV1MachinesErrorResponseJSON502) + out.JSON502 = new(GetAPIV1FrictionDigestsErrorResponseJSON502) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { @@ -6651,7 +6921,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON502", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON502", Body: bodyBytes, Err: err, } @@ -6659,7 +6929,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 503: - out.JSON503 = new(GetAPIV1MachinesErrorResponseJSON503) + out.JSON503 = new(GetAPIV1FrictionDigestsErrorResponseJSON503) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { @@ -6667,7 +6937,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON503", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON503", Body: bodyBytes, Err: err, } @@ -6675,7 +6945,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 504: - out.JSON504 = new(GetAPIV1MachinesErrorResponseJSON504) + out.JSON504 = new(GetAPIV1FrictionDigestsErrorResponseJSON504) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { @@ -6683,7 +6953,7 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1MachinesErrorResponseJSON504", + TargetType: "GetAPIV1FrictionDigestsErrorResponseJSON504", Body: bodyBytes, Err: err, } @@ -6695,11 +6965,11 @@ func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetA } } -// GetAPIV1Projects List projects -func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetAPIV1ProjectsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1ProjectsResp, error) { +// GetAPIV1FrictionDigestsDate Get friction digest +func (c *Client) GetAPIV1FrictionDigestsDateWithResponse(ctx context.Context, options *GetAPIV1FrictionDigestsDateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionDigestsDateResp, error) { var err error reqParams := runtime.RequestOptionsParameters{ - RequestURL: c.apiClient.GetBaseURL() + "/api/v1/projects", + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/friction/digests/{date}", Method: "GET", Options: options, } @@ -6709,12 +6979,12 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA return nil, fmt.Errorf("error creating request: %w", err) } - resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/projects") + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/friction/digests/{date}") if err != nil { return nil, fmt.Errorf("error executing request: %w", err) } - out := &GetAPIV1ProjectsResp{ + out := &GetAPIV1FrictionDigestsDateResp{ HTTPResponse: resp.Raw, Body: resp.Content, StatusCode: resp.StatusCode, @@ -6722,7 +6992,7 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA switch resp.StatusCode { case 200: - out.JSON200 = new(GetAPIV1ProjectsResponse) + out.JSON200 = new(GetAPIV1FrictionDigestsDateResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { @@ -6730,7 +7000,7 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1ProjectsResponse", + TargetType: "GetAPIV1FrictionDigestsDateResponse", Body: bodyBytes, Err: err, } @@ -6738,7 +7008,7 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA } return out, nil case 400: - out.JSON400 = new(GetAPIV1ProjectsErrorResponse) + out.JSON400 = new(GetAPIV1FrictionDigestsDateErrorResponse) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { @@ -6746,7 +7016,7 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1ProjectsErrorResponse", + TargetType: "GetAPIV1FrictionDigestsDateErrorResponse", Body: bodyBytes, Err: err, } @@ -6754,7 +7024,7 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA } return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) case 401: - out.JSON401 = new(GetAPIV1ProjectsErrorResponseJSON) + out.JSON401 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON) bodyBytes := resp.Content if len(bodyBytes) > 0 { if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { @@ -6762,7 +7032,1338 @@ func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetA StatusCode: resp.StatusCode, ContentType: resp.Headers.Get("Content-Type"), ContentLength: len(bodyBytes), - TargetType: "GetAPIV1ProjectsErrorResponseJSON", + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON403) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON403", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 501: + out.JSON501 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON501) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON501", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(GetAPIV1FrictionDigestsDateErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetAPIV1FrictionDigestsDateMd Get friction digest as Markdown +func (c *Client) GetAPIV1FrictionDigestsDateMdWithResponse(ctx context.Context, options *GetAPIV1FrictionDigestsDateMdRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionDigestsDateMdResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/friction/digests/{date}/md", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/friction/digests/{date}/md") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetAPIV1FrictionDigestsDateMdResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.Headers200 = &GetAPIV1FrictionDigestsDateMdResp200Headers{ + CacheControl: resp.Headers.Get("Cache-Control"), + ContentDisposition: resp.Headers.Get("Content-Disposition"), + ContentType: resp.Headers.Get("Content-Type"), + XContentTypeOptions: resp.Headers.Get("X-Content-Type-Options"), + } + return out, nil + case 400: + out.JSON400 = new(GetAPIV1FrictionDigestsDateMdErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON403) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON403", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 501: + out.JSON501 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON501) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON501", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(GetAPIV1FrictionDigestsDateMdErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionDigestsDateMdErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetAPIV1FrictionFindings List friction findings +func (c *Client) GetAPIV1FrictionFindingsWithResponse(ctx context.Context, options *GetAPIV1FrictionFindingsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionFindingsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/friction/findings", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/friction/findings") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetAPIV1FrictionFindingsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetAPIV1FrictionFindingsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetAPIV1FrictionFindingsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(GetAPIV1FrictionFindingsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(GetAPIV1FrictionFindingsErrorResponseJSON403) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON403", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetAPIV1FrictionFindingsErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetAPIV1FrictionFindingsErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(GetAPIV1FrictionFindingsErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetAPIV1FrictionFindingsErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 501: + out.JSON501 = new(GetAPIV1FrictionFindingsErrorResponseJSON501) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON501", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetAPIV1FrictionFindingsErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetAPIV1FrictionFindingsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(GetAPIV1FrictionFindingsErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionFindingsErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetAPIV1FrictionPatterns List friction patterns +func (c *Client) GetAPIV1FrictionPatternsWithResponse(ctx context.Context, options *GetAPIV1FrictionPatternsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1FrictionPatternsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/friction/patterns", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/friction/patterns") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetAPIV1FrictionPatternsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetAPIV1FrictionPatternsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetAPIV1FrictionPatternsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(GetAPIV1FrictionPatternsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(GetAPIV1FrictionPatternsErrorResponseJSON403) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON403", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetAPIV1FrictionPatternsErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetAPIV1FrictionPatternsErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(GetAPIV1FrictionPatternsErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetAPIV1FrictionPatternsErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 501: + out.JSON501 = new(GetAPIV1FrictionPatternsErrorResponseJSON501) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON501", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetAPIV1FrictionPatternsErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetAPIV1FrictionPatternsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(GetAPIV1FrictionPatternsErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1FrictionPatternsErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// PostAPIV1FrictionRun Run friction review +func (c *Client) PostAPIV1FrictionRunWithResponse(ctx context.Context, options *PostAPIV1FrictionRunRequestOptions, reqEditors ...runtime.RequestEditorFn) (*PostAPIV1FrictionRunResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/friction/run", + Method: "POST", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/friction/run") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &PostAPIV1FrictionRunResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(PostAPIV1FrictionRunResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(PostAPIV1FrictionRunErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(PostAPIV1FrictionRunErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(PostAPIV1FrictionRunErrorResponseJSON403) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON403", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(PostAPIV1FrictionRunErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(PostAPIV1FrictionRunErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(PostAPIV1FrictionRunErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(PostAPIV1FrictionRunErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 501: + out.JSON501 = new(PostAPIV1FrictionRunErrorResponseJSON501) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON501", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(PostAPIV1FrictionRunErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(PostAPIV1FrictionRunErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(PostAPIV1FrictionRunErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "PostAPIV1FrictionRunErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetAPIV1Machines List machines +func (c *Client) GetAPIV1MachinesWithResponse(ctx context.Context, options *GetAPIV1MachinesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1MachinesResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/machines", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/machines") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetAPIV1MachinesResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetAPIV1MachinesResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetAPIV1MachinesErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(GetAPIV1MachinesErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 403: + out.JSON403 = new(GetAPIV1MachinesErrorResponseJSON403) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON403); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON403", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(GetAPIV1MachinesErrorResponseJSON404) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON404", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(GetAPIV1MachinesErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 422: + out.JSON422 = new(GetAPIV1MachinesErrorResponseJSON422) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON422); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON422", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 500: + out.JSON500 = new(GetAPIV1MachinesErrorResponseJSON500) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON500); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON500", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 501: + out.JSON501 = new(GetAPIV1MachinesErrorResponseJSON501) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON501); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON501", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 502: + out.JSON502 = new(GetAPIV1MachinesErrorResponseJSON502) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON502); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON502", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(GetAPIV1MachinesErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 504: + out.JSON504 = new(GetAPIV1MachinesErrorResponseJSON504) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON504); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1MachinesErrorResponseJSON504", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// GetAPIV1Projects List projects +func (c *Client) GetAPIV1ProjectsWithResponse(ctx context.Context, options *GetAPIV1ProjectsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*GetAPIV1ProjectsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/projects", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/projects") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &GetAPIV1ProjectsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(GetAPIV1ProjectsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1ProjectsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(GetAPIV1ProjectsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1ProjectsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 401: + out.JSON401 = new(GetAPIV1ProjectsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON401); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "GetAPIV1ProjectsErrorResponseJSON", Body: bodyBytes, Err: err, } @@ -14923,6 +16524,24 @@ type PostAPIV1EmbeddingsGenerationsIDRetirePath struct { ID int64 `json:"id"` } +type GetAPIV1FrictionDigestsDatePath struct { + // Date Digest date (YYYY-MM-DD) + Date string `json:"date" validate:"required"` +} + +func (g GetAPIV1FrictionDigestsDatePath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + +type GetAPIV1FrictionDigestsDateMdPath struct { + // Date Digest date (YYYY-MM-DD) + Date string `json:"date" validate:"required"` +} + +func (g GetAPIV1FrictionDigestsDateMdPath) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(g)) +} + type HeadAPIV1RawSyncUploadsUploadIDPath struct { UploadID string `json:"upload_id" validate:"required"` } @@ -15002,6 +16621,8 @@ type PostAPIV1EmbeddingsGenerationsIDActivateBody = EmbeddingsGenerationActionRe type PostAPIV1EmbeddingsGenerationsIDRetireBody = EmbeddingsGenerationActionRequest +type PostAPIV1FrictionRunBody = FrictionRunRequest + type PostAPIV1PushClickhouseBody = DaemonPushRequest type PostAPIV1PushDuckdbBody = DaemonPushRequest @@ -15170,6 +16791,51 @@ type GetAPIV1EmbeddingsStatusQuery struct { Store *string `json:"store,omitempty"` } +type GetAPIV1FrictionDigestsQuery struct { + // From First digest date (YYYY-MM-DD) + From *string `json:"from,omitempty"` + + // To Last digest date (YYYY-MM-DD) + To *string `json:"to,omitempty"` +} + +type GetAPIV1FrictionFindingsQuery struct { + // Date Only findings from sessions in this digest (YYYY-MM-DD) + Date *string `json:"date,omitempty"` + + // Kind correction, error, workaround, deferral, pattern, frustration or interruption + Kind *string `json:"kind,omitempty"` + + // SessionID Session ID + SessionID *string `json:"session_id,omitempty"` + + // Fingerprint Pattern fingerprint (fl1:) + Fingerprint *string `json:"fingerprint,omitempty"` + + // Limit Page size, 1-1000 (default 100) + Limit *int64 `json:"limit,omitempty"` + + // Cursor Cursor from next_cursor + Cursor *string `json:"cursor,omitempty"` +} + +type GetAPIV1FrictionPatternsQuery struct { + // Kind correction, error, workaround, deferral, pattern, frustration or interruption + Kind *string `json:"kind,omitempty"` + + // LinkState linked, unlinked, pending, failed, needs_human or abandoned + LinkState *string `json:"link_state,omitempty"` + + // Since Only patterns last seen on or after this date (YYYY-MM-DD) + Since *string `json:"since,omitempty"` + + // Limit Page size, 1-1000 (default 100) + Limit *int64 `json:"limit,omitempty"` + + // Cursor Cursor from next_cursor + Cursor *string `json:"cursor,omitempty"` +} + type GetAPIV1MachinesQuery struct { // IncludeOneShot Include one-shot sessions IncludeOneShot *bool `json:"include_one_shot,omitempty"` @@ -16169,121 +17835,265 @@ type PostAPIV1DataCompactErrorResponseJSON503 = APIErrorResponse type PostAPIV1DataCompactErrorResponseJSON504 = APIErrorResponse -type PostAPIV1EmbeddingsBuildResponse = EmbeddingsBuildResponse +type PostAPIV1EmbeddingsBuildResponse = EmbeddingsBuildResponse + +type PostAPIV1EmbeddingsBuildErrorResponse = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON403 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON404 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON409 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON422 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON500 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON501 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON502 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON503 = APIErrorResponse + +type PostAPIV1EmbeddingsBuildErrorResponseJSON504 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsResponse = EmbeddingsGenerationsResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponse = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON403 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON404 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON409 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON422 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON500 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON501 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON502 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON503 = APIErrorResponse + +type GetAPIV1EmbeddingsGenerationsErrorResponseJSON504 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponse = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON403 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON404 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON409 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON422 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON500 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON501 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON502 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON503 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON504 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponse = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON403 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON404 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON409 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON422 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON500 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON501 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON502 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON503 = APIErrorResponse + +type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON504 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusResponse = vector.BuildStatus + +type GetAPIV1EmbeddingsStatusErrorResponse = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON403 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON404 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON409 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON422 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON500 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON501 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON502 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON503 = APIErrorResponse + +type GetAPIV1EmbeddingsStatusErrorResponseJSON504 = APIErrorResponse + +type GetAPIV1FrictionDigestsResponse = FrictionDigestsResponse + +type GetAPIV1FrictionDigestsErrorResponse = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON403 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON404 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON409 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON422 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON500 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON501 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON502 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON503 = APIErrorResponse + +type GetAPIV1FrictionDigestsErrorResponseJSON504 = APIErrorResponse + +type GetAPIV1FrictionDigestsDateResponse = FrictionDigestResponse + +type GetAPIV1FrictionDigestsDateErrorResponse = APIErrorResponse + +type GetAPIV1FrictionDigestsDateErrorResponseJSON = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponse = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON403 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON404 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON403 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON409 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON404 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON422 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON409 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON500 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON422 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON501 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON500 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON502 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON501 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON503 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON502 = APIErrorResponse +type GetAPIV1FrictionDigestsDateErrorResponseJSON504 = APIErrorResponse -type PostAPIV1EmbeddingsBuildErrorResponseJSON503 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdResponse = []byte -type PostAPIV1EmbeddingsBuildErrorResponseJSON504 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponse = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsResponse = EmbeddingsGenerationsResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponse = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON403 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON404 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON403 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON409 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON404 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON422 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON409 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON500 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON422 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON501 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON500 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON502 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON501 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON503 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON502 = APIErrorResponse +type GetAPIV1FrictionDigestsDateMdErrorResponseJSON504 = APIErrorResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON503 = APIErrorResponse +type GetAPIV1FrictionFindingsResponse = FrictionFindingsResponse -type GetAPIV1EmbeddingsGenerationsErrorResponseJSON504 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponse = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponse = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON403 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON403 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON404 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON404 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON409 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON409 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON422 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON422 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON500 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON500 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON501 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON501 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON502 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON502 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON503 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON503 = APIErrorResponse +type GetAPIV1FrictionFindingsErrorResponseJSON504 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDActivateErrorResponseJSON504 = APIErrorResponse +type GetAPIV1FrictionPatternsResponse = FrictionPatternsResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponse = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponse = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON403 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON403 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON404 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON404 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON409 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON409 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON422 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON422 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON500 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON500 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON501 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON501 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON502 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON502 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON503 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON503 = APIErrorResponse -type PostAPIV1EmbeddingsGenerationsIDRetireErrorResponseJSON504 = APIErrorResponse +type GetAPIV1FrictionPatternsErrorResponseJSON504 = APIErrorResponse -type GetAPIV1EmbeddingsStatusResponse = vector.BuildStatus +type PostAPIV1FrictionRunResponse = FrictionRunResponse -type GetAPIV1EmbeddingsStatusErrorResponse = APIErrorResponse +type PostAPIV1FrictionRunErrorResponse = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON403 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON403 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON404 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON404 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON409 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON409 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON422 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON422 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON500 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON500 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON501 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON501 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON502 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON502 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON503 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON503 = APIErrorResponse -type GetAPIV1EmbeddingsStatusErrorResponseJSON504 = APIErrorResponse +type PostAPIV1FrictionRunErrorResponseJSON504 = APIErrorResponse type GetAPIV1MachinesResponse = MachinesResponse @@ -17379,6 +19189,121 @@ type GetAPIV1EmbeddingsStatusResp struct { JSON504 *GetAPIV1EmbeddingsStatusErrorResponseJSON504 } +type GetAPIV1FrictionDigestsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetAPIV1FrictionDigestsResponse + JSON400 *GetAPIV1FrictionDigestsErrorResponse + JSON401 *GetAPIV1FrictionDigestsErrorResponseJSON + JSON403 *GetAPIV1FrictionDigestsErrorResponseJSON403 + JSON404 *GetAPIV1FrictionDigestsErrorResponseJSON404 + JSON409 *GetAPIV1FrictionDigestsErrorResponseJSON409 + JSON422 *GetAPIV1FrictionDigestsErrorResponseJSON422 + JSON500 *GetAPIV1FrictionDigestsErrorResponseJSON500 + JSON501 *GetAPIV1FrictionDigestsErrorResponseJSON501 + JSON502 *GetAPIV1FrictionDigestsErrorResponseJSON502 + JSON503 *GetAPIV1FrictionDigestsErrorResponseJSON503 + JSON504 *GetAPIV1FrictionDigestsErrorResponseJSON504 +} + +type GetAPIV1FrictionDigestsDateResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetAPIV1FrictionDigestsDateResponse + JSON400 *GetAPIV1FrictionDigestsDateErrorResponse + JSON401 *GetAPIV1FrictionDigestsDateErrorResponseJSON + JSON403 *GetAPIV1FrictionDigestsDateErrorResponseJSON403 + JSON404 *GetAPIV1FrictionDigestsDateErrorResponseJSON404 + JSON409 *GetAPIV1FrictionDigestsDateErrorResponseJSON409 + JSON422 *GetAPIV1FrictionDigestsDateErrorResponseJSON422 + JSON500 *GetAPIV1FrictionDigestsDateErrorResponseJSON500 + JSON501 *GetAPIV1FrictionDigestsDateErrorResponseJSON501 + JSON502 *GetAPIV1FrictionDigestsDateErrorResponseJSON502 + JSON503 *GetAPIV1FrictionDigestsDateErrorResponseJSON503 + JSON504 *GetAPIV1FrictionDigestsDateErrorResponseJSON504 +} + +type GetAPIV1FrictionDigestsDateMdResp200Headers struct { + CacheControl string `header:"Cache-Control"` + ContentDisposition string `header:"Content-Disposition"` + ContentType string `header:"Content-Type"` + XContentTypeOptions string `header:"X-Content-Type-Options"` +} + +type GetAPIV1FrictionDigestsDateMdResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + Headers200 *GetAPIV1FrictionDigestsDateMdResp200Headers + JSON400 *GetAPIV1FrictionDigestsDateMdErrorResponse + JSON401 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON + JSON403 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON403 + JSON404 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON404 + JSON409 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON409 + JSON422 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON422 + JSON500 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON500 + JSON501 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON501 + JSON502 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON502 + JSON503 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON503 + JSON504 *GetAPIV1FrictionDigestsDateMdErrorResponseJSON504 +} + +type GetAPIV1FrictionFindingsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetAPIV1FrictionFindingsResponse + JSON400 *GetAPIV1FrictionFindingsErrorResponse + JSON401 *GetAPIV1FrictionFindingsErrorResponseJSON + JSON403 *GetAPIV1FrictionFindingsErrorResponseJSON403 + JSON404 *GetAPIV1FrictionFindingsErrorResponseJSON404 + JSON409 *GetAPIV1FrictionFindingsErrorResponseJSON409 + JSON422 *GetAPIV1FrictionFindingsErrorResponseJSON422 + JSON500 *GetAPIV1FrictionFindingsErrorResponseJSON500 + JSON501 *GetAPIV1FrictionFindingsErrorResponseJSON501 + JSON502 *GetAPIV1FrictionFindingsErrorResponseJSON502 + JSON503 *GetAPIV1FrictionFindingsErrorResponseJSON503 + JSON504 *GetAPIV1FrictionFindingsErrorResponseJSON504 +} + +type GetAPIV1FrictionPatternsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *GetAPIV1FrictionPatternsResponse + JSON400 *GetAPIV1FrictionPatternsErrorResponse + JSON401 *GetAPIV1FrictionPatternsErrorResponseJSON + JSON403 *GetAPIV1FrictionPatternsErrorResponseJSON403 + JSON404 *GetAPIV1FrictionPatternsErrorResponseJSON404 + JSON409 *GetAPIV1FrictionPatternsErrorResponseJSON409 + JSON422 *GetAPIV1FrictionPatternsErrorResponseJSON422 + JSON500 *GetAPIV1FrictionPatternsErrorResponseJSON500 + JSON501 *GetAPIV1FrictionPatternsErrorResponseJSON501 + JSON502 *GetAPIV1FrictionPatternsErrorResponseJSON502 + JSON503 *GetAPIV1FrictionPatternsErrorResponseJSON503 + JSON504 *GetAPIV1FrictionPatternsErrorResponseJSON504 +} + +type PostAPIV1FrictionRunResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *PostAPIV1FrictionRunResponse + JSON400 *PostAPIV1FrictionRunErrorResponse + JSON401 *PostAPIV1FrictionRunErrorResponseJSON + JSON403 *PostAPIV1FrictionRunErrorResponseJSON403 + JSON404 *PostAPIV1FrictionRunErrorResponseJSON404 + JSON409 *PostAPIV1FrictionRunErrorResponseJSON409 + JSON422 *PostAPIV1FrictionRunErrorResponseJSON422 + JSON500 *PostAPIV1FrictionRunErrorResponseJSON500 + JSON501 *PostAPIV1FrictionRunErrorResponseJSON501 + JSON502 *PostAPIV1FrictionRunErrorResponseJSON502 + JSON503 *PostAPIV1FrictionRunErrorResponseJSON503 + JSON504 *PostAPIV1FrictionRunErrorResponseJSON504 +} + type GetAPIV1MachinesResp struct { HTTPResponse *http.Response Body []byte @@ -19586,6 +21511,418 @@ type FillStats struct { Stale int64 `json:"Stale"` } +type FrictionArchivePeriod struct { + AgentsUsd map[string]string `json:"agents_usd"` + Days int64 `json:"days"` + ModelsUsd map[string]string `json:"models_usd"` + TotalUsd string `json:"total_usd" validate:"required"` +} + +func (f FrictionArchivePeriod) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionArchiveSpend struct { + Timezone string `json:"timezone" validate:"required"` + Week FrictionArchivePeriod `json:"week"` + WeekFrom string `json:"week_from" validate:"required"` + WeekTo string `json:"week_to" validate:"required"` + Yesterday FrictionArchivePeriod `json:"yesterday"` +} + +func (f FrictionArchiveSpend) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(f.Timezone, "required"); err != nil { + errors = errors.Append("Timezone", err) + } + if v, ok := any(f.Week).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Week", err) + } + } + if err := typesValidator.Var(f.WeekFrom, "required"); err != nil { + errors = errors.Append("WeekFrom", err) + } + if err := typesValidator.Var(f.WeekTo, "required"); err != nil { + errors = errors.Append("WeekTo", err) + } + if v, ok := any(f.Yesterday).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Yesterday", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionCreatedIssue struct { + Backend string `json:"backend" validate:"required"` + ID string `json:"id" validate:"required"` + Title string `json:"title" validate:"required"` + URL string `json:"url" validate:"required"` +} + +func (f FrictionCreatedIssue) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionDigestItem struct { + BuiltAt time.Time `json:"built_at" validate:"required"` + Date string `json:"date" validate:"required"` + Revision int64 `json:"revision"` + RulesVersion string `json:"rules_version" validate:"required"` + SessionsScanned int64 `json:"sessions_scanned"` + Timezone string `json:"timezone" validate:"required"` +} + +func (f FrictionDigestItem) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionDigestResponse struct { + BuiltAt time.Time `json:"built_at" validate:"required"` + Date string `json:"date" validate:"required"` + MarkdownSha256 string `json:"markdown_sha256" validate:"required"` + P0Alerts []FrictionP0Alert `json:"p0_alerts" validate:"required"` + Revision int64 `json:"revision"` + RulesVersion string `json:"rules_version" validate:"required"` + SessionsScanned int64 `json:"sessions_scanned"` + Signals []FrictionSignal `json:"signals" validate:"required"` + Summary FrictionSummary `json:"summary"` + Timezone string `json:"timezone" validate:"required"` + WebURL string `json:"web_url" validate:"required"` +} + +func (f FrictionDigestResponse) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(f.BuiltAt, "required"); err != nil { + errors = errors.Append("BuiltAt", err) + } + if err := typesValidator.Var(f.Date, "required"); err != nil { + errors = errors.Append("Date", err) + } + if err := typesValidator.Var(f.MarkdownSha256, "required"); err != nil { + errors = errors.Append("MarkdownSha256", err) + } + for i, item := range f.P0Alerts { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("P0Alerts[%d]", i), err) + } + } + } + if err := typesValidator.Var(f.RulesVersion, "required"); err != nil { + errors = errors.Append("RulesVersion", err) + } + for i, item := range f.Signals { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Signals[%d]", i), err) + } + } + } + if v, ok := any(f.Summary).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Summary", err) + } + } + if err := typesValidator.Var(f.Timezone, "required"); err != nil { + errors = errors.Append("Timezone", err) + } + if err := typesValidator.Var(f.WebURL, "required"); err != nil { + errors = errors.Append("WebURL", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionDigestsResponse struct { + Digests []FrictionDigestItem `json:"digests" validate:"required"` +} + +func (f FrictionDigestsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range f.Digests { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Digests[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionFindingItem struct { + CallIndex *int64 `json:"call_index,omitempty"` + Detector string `json:"detector" validate:"required"` + Evidence string `json:"evidence" validate:"required"` + Fingerprint string `json:"fingerprint" validate:"required"` + Kind string `json:"kind" validate:"required"` + Label string `json:"label" validate:"required"` + MessageOrdinal *int64 `json:"message_ordinal,omitempty"` + OccurredAt *time.Time `json:"occurred_at,omitempty" validate:"required"` + RulesVersion string `json:"rules_version" validate:"required"` + Seq int64 `json:"seq"` + SessionID string `json:"session_id" validate:"required"` + Text string `json:"text" validate:"required"` + Title string `json:"title" validate:"required"` + ToolName string `json:"tool_name" validate:"required"` +} + +func (f FrictionFindingItem) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionFindingsResponse struct { + Findings []FrictionFindingItem `json:"findings" validate:"required"` + NextCursor string `json:"next_cursor" validate:"required"` +} + +func (f FrictionFindingsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range f.Findings { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Findings[%d]", i), err) + } + } + } + if err := typesValidator.Var(f.NextCursor, "required"); err != nil { + errors = errors.Append("NextCursor", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionP0Alert struct { + SubjectIds []string `json:"subject_ids" validate:"required"` + Tool string `json:"tool" validate:"required"` +} + +func (f FrictionP0Alert) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionPatternItem struct { + Fingerprint string `json:"fingerprint" validate:"required"` + FirstSeenDate string `json:"first_seen_date" validate:"required"` + Kind string `json:"kind" validate:"required"` + LastOrdinal *int64 `json:"last_ordinal,omitempty"` + LastSeenDate string `json:"last_seen_date" validate:"required"` + LastSubjectID string `json:"last_subject_id" validate:"required"` + OccurrenceCount int64 `json:"occurrence_count"` + SessionCount int64 `json:"session_count"` + Title string `json:"title" validate:"required"` +} + +func (f FrictionPatternItem) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionPatternsResponse struct { + NextCursor string `json:"next_cursor" validate:"required"` + Patterns []FrictionPatternItem `json:"patterns" validate:"required"` +} + +func (f FrictionPatternsResponse) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(f.NextCursor, "required"); err != nil { + errors = errors.Append("NextCursor", err) + } + for i, item := range f.Patterns { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Patterns[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionPersonaSummary struct { + Channel *string `json:"channel,omitempty" validate:"required"` + Corrections int64 `json:"corrections"` + CostUsd *string `json:"cost_usd,omitempty" validate:"required"` + Deferrals int64 `json:"deferrals"` + Errors int64 `json:"errors"` + InputTokens int64 `json:"input_tokens" validate:"gte=0"` + OutputTokens int64 `json:"output_tokens" validate:"gte=0"` + Patterns int64 `json:"patterns"` + Persona string `json:"persona" validate:"required"` + Sessions int64 `json:"sessions"` + Workarounds int64 `json:"workarounds"` +} + +func (f FrictionPersonaSummary) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionRunReport struct { + Date string `json:"date" validate:"required"` + Human string `json:"human" validate:"required"` + Summary FrictionSummary `json:"summary"` + Written bool `json:"written"` +} + +func (f FrictionRunReport) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(f.Date, "required"); err != nil { + errors = errors.Append("Date", err) + } + if err := typesValidator.Var(f.Human, "required"); err != nil { + errors = errors.Append("Human", err) + } + if v, ok := any(f.Summary).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Summary", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionRunRequest struct { + // Date Digest date (YYYY-MM-DD); omit to catch up every missing complete date + Date *string `json:"date,omitempty"` + + // DryRun Compute and render without writing + DryRun *bool `json:"dry_run,omitempty"` + + // Rebuild Rebuild an existing digest, keeping its membership + Rebuild *bool `json:"rebuild,omitempty"` +} + +type FrictionRunResponse struct { + Reports []FrictionRunReport `json:"reports" validate:"required"` +} + +func (f FrictionRunResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range f.Reports { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Reports[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type FrictionSignal struct { + Agent string `json:"agent" validate:"required"` + CallIndex *int64 `json:"call_index,omitempty"` + Channel string `json:"channel" validate:"required"` + Detector string `json:"detector" validate:"required"` + Evidence string `json:"evidence" validate:"required"` + Fingerprint string `json:"fingerprint" validate:"required"` + Kind string `json:"kind" validate:"required"` + Label string `json:"label" validate:"required"` + Machine string `json:"machine" validate:"required"` + MessageOrdinal *int64 `json:"message_ordinal,omitempty"` + OccurredAt *time.Time `json:"occurred_at,omitempty" validate:"required"` + Persona string `json:"persona" validate:"required"` + Seat string `json:"seat" validate:"required"` + SessionURL string `json:"session_url" validate:"required"` + SubjectID string `json:"subject_id" validate:"required"` + SubjectKind string `json:"subject_kind" validate:"required"` + Text string `json:"text" validate:"required"` + Title string `json:"title" validate:"required"` + ToolName string `json:"tool_name" validate:"required"` +} + +func (f FrictionSignal) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionSpendSummary struct { + InputTokens int64 `json:"input_tokens" validate:"gte=0"` + ModelCostsUsd map[string]string `json:"model_costs_usd"` + OutputTokens int64 `json:"output_tokens" validate:"gte=0"` + RoleCostsUsd map[string]string `json:"role_costs_usd"` + SessionsWithCost int64 `json:"sessions_with_cost"` + SessionsWithStats int64 `json:"sessions_with_stats"` + TotalUsd *string `json:"total_usd,omitempty" validate:"required"` +} + +func (f FrictionSpendSummary) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + +type FrictionSummary struct { + ArchiveSpend *FrictionArchiveSpend `json:"archive_spend,omitempty"` + Corrections int64 `json:"corrections"` + CreatedIssues []FrictionCreatedIssue `json:"created_issues" validate:"required"` + Deferrals int64 `json:"deferrals"` + DigestPath *string `json:"digest_path,omitempty" validate:"required"` + Errors int64 `json:"errors"` + Frustrations int64 `json:"frustrations"` + Interruptions int64 `json:"interruptions"` + P0Alerts map[string][]string `json:"p0_alerts"` + Patterns int64 `json:"patterns"` + Personas map[string]FrictionPersonaSummary `json:"personas"` + SchemaVersion int64 `json:"schema_version"` + SessionsScanned int64 `json:"sessions_scanned"` + Spend FrictionSpendSummary `json:"spend"` + TrackerFailures int64 `json:"tracker_failures"` + Workarounds int64 `json:"workarounds"` +} + +func (f FrictionSummary) Validate() error { + var errors runtime.ValidationErrors + if f.ArchiveSpend != nil { + if v, ok := any(f.ArchiveSpend).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("ArchiveSpend", err) + } + } + } + for i, item := range f.CreatedIssues { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("CreatedIssues[%d]", i), err) + } + } + } + if f.DigestPath != nil { + if err := typesValidator.Var(f.DigestPath, "required"); err != nil { + errors = errors.Append("DigestPath", err) + } + } + for k, v := range f.Personas { + if validator, ok := any(v).(runtime.Validator); ok { + if err := validator.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Personas[%s]", k), err) + } + } + } + if v, ok := any(f.Spend).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Spend", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type MachinesResponse struct { MachineAliases map[string]string `json:"machine_aliases"` MachineLabels map[string]string `json:"machine_labels"` diff --git a/internal/apiclient/generate.yaml b/internal/apiclient/generate.yaml index 30f569fd54..df5f2c601f 100644 --- a/internal/apiclient/generate.yaml +++ b/internal/apiclient/generate.yaml @@ -58,6 +58,12 @@ filter: - post-api-v1-raw-sync-uploads - head-api-v1-raw-sync-uploads-upload-id - patch-api-v1-raw-sync-uploads-upload-id + - get-api-v1-friction-digests + - get-api-v1-friction-digests-date + - get-api-v1-friction-digests-date-md + - get-api-v1-friction-findings + - get-api-v1-friction-patterns + - post-api-v1-friction-run - post-api-v1-raw-sync-manifests overlay: sources: diff --git a/internal/postgres/friction_http_pgtest_test.go b/internal/postgres/friction_http_pgtest_test.go new file mode 100644 index 0000000000..6f19c51dd6 --- /dev/null +++ b/internal/postgres/friction_http_pgtest_test.go @@ -0,0 +1,81 @@ +//go:build pgtest + +package postgres_test + +import ( + "encoding/json/v2" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/postgres" + "go.kenn.io/agentsview/internal/server" +) + +func TestFrictionRoutesOnPostgres(t *testing.T) { + pgURL := os.Getenv("TEST_PG_URL") + if pgURL == "" { + t.Skip("TEST_PG_URL must point to a dedicated test database") + } + pg, dataDir := newPGE2ETestDatabase(t) + store, err := postgres.NewStore(pgURL, pgE2ESchema, true) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, store.Close()) }) + ctx := t.Context() + _, err = pg.ExecContext(ctx, `INSERT INTO sessions (id, machine, project, agent, first_message, message_count, user_message_count) + VALUES ('claude:s1', 'm', 'p', 'claude', 'x', 1, 0)`) + require.NoError(t, err) + _, err = pg.ExecContext(ctx, `INSERT INTO friction_findings (session_id, kind, detector, tool_name, label, text, evidence, title, fingerprint, seq, rules_version) + VALUES ('claude:s1', 'error', 'error', 'Bash', '', 'boom', '', 't', 'fl1:x', 0, 'friction-v1')`) + require.NoError(t, err) + snap := friction.DigestSnapshot{Date: "2026-09-14", Timezone: "UTC", RulesVersion: friction.RulesVersion, SessionsScanned: 1} + snapJSON, err := review.EncodeSnapshot(snap) + require.NoError(t, err) + require.NoError(t, store.SaveFrictionDigest(ctx, db.FrictionDigest{ + Date: "2026-09-14", Timezone: "UTC", RulesVersion: friction.RulesVersion, + BuiltAt: time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC), Revision: 1, SessionsScanned: 1, + SnapshotJSON: snapJSON, SummaryJSON: friction.RenderSummaryJSON(snap, friction.SummaryMeta{}), + Markdown: friction.RenderMarkdown(snap, friction.RenderLinks{}), MarkdownSHA256: "s", RunID: "r", + }, []db.FrictionDigestSubject{{SubjectID: "claude:s1", Date: "2026-09-14", SubjectKind: "session"}}, + []db.FrictionPatternUpdate{{Fingerprint: "fl1:x", Kind: "error", Title: "t", Date: "2026-09-14", SubjectID: "claude:s1", Occurrences: 1}})) + + handler := server.New(config.Config{Host: "127.0.0.1", DataDir: dataDir}, store, nil).Handler() + get := func(path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:0"+path, nil) + req.RemoteAddr = "127.0.0.1:1234" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w + } + tests := []struct { + path, key string + count int + }{ + {"/api/v1/friction/digests", "digests", 1}, + {"/api/v1/friction/findings?date=2026-09-14", "findings", 1}, + {"/api/v1/friction/patterns", "patterns", 1}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + w := get(tt.path) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Len(t, body[tt.key], tt.count) + }) + } + w := get("/api/v1/friction/digests/2026-09-14") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + w = get("/api/v1/friction/digests/2026-09-14/md") + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "# Friction Log — 2026-09-14") +} diff --git a/internal/server/huma_route_groups.go b/internal/server/huma_route_groups.go index 5f63e54dbc..d8b5f9a7dc 100644 --- a/internal/server/huma_route_groups.go +++ b/internal/server/huma_route_groups.go @@ -15,6 +15,7 @@ func (s *Server) registerTypedAPIRoutes() { s.registerTrendsRoutes() s.registerUsageRoutes() s.registerInsightsRoutes() + s.registerFrictionRoutes() s.registerSearchRoutes() s.registerRecallRoutes() s.describeTransferRoutes() diff --git a/internal/server/huma_routes_friction.go b/internal/server/huma_routes_friction.go new file mode 100644 index 0000000000..7aaca380aa --- /dev/null +++ b/internal/server/huma_routes_friction.go @@ -0,0 +1,549 @@ +package server + +import ( + "cmp" + "context" + "encoding/json/v2" + "errors" + "fmt" + "maps" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/timeutil" +) + +func (s *Server) registerFrictionRoutes() { + group := huma.NewGroup(s.api, "/api/v1") + configureRouteGroup(group, "Friction") + + s.get(group, "/friction/digests", "List friction digests", s.humaListFrictionDigests) + s.get(group, "/friction/digests/{date}", "Get friction digest", s.humaGetFrictionDigest) + s.raw(group, http.MethodGet, "/friction/digests/{date}/md", + "Get friction digest as Markdown", "text/markdown", s.humaFrictionDigestMarkdown) + s.get(group, "/friction/findings", "List friction findings", s.humaListFrictionFindings) + s.get(group, "/friction/patterns", "List friction patterns", s.humaListFrictionPatterns) + s.postLong(group, "/friction/run", "Run friction review", s.humaRunFrictionReview) +} + +// --- inputs --- + +type frictionDigestsInput struct { + From string `query:"from" doc:"First digest date (YYYY-MM-DD)"` + To string `query:"to" doc:"Last digest date (YYYY-MM-DD)"` +} + +type frictionDatePathInput struct { + Date string `path:"date" required:"true" doc:"Digest date (YYYY-MM-DD)"` +} + +type frictionFindingsInput struct { + Date string `query:"date" doc:"Only findings from sessions in this digest (YYYY-MM-DD)"` + Kind string `query:"kind" doc:"correction, error, workaround, deferral, pattern, frustration or interruption"` + SessionID string `query:"session_id" doc:"Session ID"` + Fingerprint string `query:"fingerprint" doc:"Pattern fingerprint (fl1:)"` + Limit int `query:"limit" doc:"Page size, 1-1000 (default 100)"` + Cursor string `query:"cursor" doc:"Cursor from next_cursor"` +} + +type frictionPatternsInput struct { + Kind string `query:"kind" doc:"correction, error, workaround, deferral, pattern, frustration or interruption"` + LinkState string `query:"link_state" doc:"linked, unlinked, pending, failed, needs_human or abandoned"` + Since string `query:"since" doc:"Only patterns last seen on or after this date (YYYY-MM-DD)"` + Limit int `query:"limit" doc:"Page size, 1-1000 (default 100)"` + Cursor string `query:"cursor" doc:"Cursor from next_cursor"` +} + +type frictionRunRequest struct { + Date string `json:"date,omitempty" doc:"Digest date (YYYY-MM-DD); omit to catch up every missing complete date"` + Rebuild bool `json:"rebuild,omitempty" doc:"Rebuild an existing digest, keeping its membership"` + DryRun bool `json:"dry_run,omitempty" doc:"Compute and render without writing"` +} + +type frictionRunInput struct { + Body frictionRunRequest +} + +// --- responses (names fixed by the PR 5-8 contract) --- + +type frictionDigestsResponse struct { + Digests []frictionDigestItem `json:"digests"` +} + +type frictionDigestItem struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + RulesVersion string `json:"rules_version"` + BuiltAt time.Time `json:"built_at"` + Revision int `json:"revision"` + SessionsScanned int `json:"sessions_scanned"` +} + +type frictionDigestResponse struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + RulesVersion string `json:"rules_version"` + BuiltAt time.Time `json:"built_at"` + Revision int `json:"revision"` + SessionsScanned int `json:"sessions_scanned"` + MarkdownSHA256 string `json:"markdown_sha256"` + WebURL string `json:"web_url"` + Summary frictionSummary `json:"summary"` + Signals []frictionSignal `json:"signals"` + P0Alerts []frictionP0Alert `json:"p0_alerts"` +} + +// frictionSummary mirrors the summary JSON schema v3 keys (spec §9.2): +// jilog's schema 2 plus frustrations and interruptions. +type frictionSummary struct { + SchemaVersion int `json:"schema_version"` + SessionsScanned int `json:"sessions_scanned"` + TrackerFailures int `json:"tracker_failures"` + Corrections int `json:"corrections"` + Errors int `json:"errors"` + Workarounds int `json:"workarounds"` + Deferrals int `json:"deferrals"` + Patterns int `json:"patterns"` + Frustrations int `json:"frustrations"` + Interruptions int `json:"interruptions"` + P0Alerts map[string][]string `json:"p0_alerts"` + Personas map[string]frictionPersonaSummary `json:"personas"` + Spend *frictionSpendSummary `json:"spend"` + ArchiveSpend *frictionArchiveSpend `json:"archive_spend,omitempty"` + DigestPath *string `json:"digest_path"` + CreatedIssues []frictionCreatedIssue `json:"created_issues"` +} + +type frictionPersonaSummary struct { + Persona string `json:"persona"` + Channel *string `json:"channel"` + Sessions int `json:"sessions"` + Corrections int `json:"corrections"` + Errors int `json:"errors"` + Workarounds int `json:"workarounds"` + Deferrals int `json:"deferrals"` + Patterns int `json:"patterns"` + InputTokens uint64 `json:"input_tokens"` + OutputTokens uint64 `json:"output_tokens"` + CostUSD *string `json:"cost_usd"` +} + +type frictionSpendSummary struct { + TotalUSD *string `json:"total_usd"` + SessionsWithStats int `json:"sessions_with_stats"` + SessionsWithCost int `json:"sessions_with_cost"` + InputTokens uint64 `json:"input_tokens"` + OutputTokens uint64 `json:"output_tokens"` + RoleCostsUSD map[string]string `json:"role_costs_usd"` + ModelCostsUSD map[string]string `json:"model_costs_usd"` +} + +type frictionArchivePeriod struct { + TotalUSD string `json:"total_usd"` + Days int `json:"days"` + AgentsUSD map[string]string `json:"agents_usd"` + ModelsUSD map[string]string `json:"models_usd"` +} + +type frictionArchiveSpend struct { + Yesterday *frictionArchivePeriod `json:"yesterday"` + Week frictionArchivePeriod `json:"week"` + WeekFrom string `json:"week_from"` + WeekTo string `json:"week_to"` + Timezone string `json:"timezone"` +} + +type frictionCreatedIssue struct { + ID string `json:"id"` + Backend string `json:"backend"` + Title string `json:"title"` + URL string `json:"url"` +} + +type frictionSignal struct { + Kind string `json:"kind"` + Detector string `json:"detector"` + SubjectID string `json:"subject_id"` + SubjectKind string `json:"subject_kind"` + Title string `json:"title"` + Fingerprint string `json:"fingerprint"` + Text string `json:"text"` + ToolName string `json:"tool_name"` + Label string `json:"label"` + Evidence string `json:"evidence"` + MessageOrdinal *int `json:"message_ordinal"` + CallIndex *int `json:"call_index"` + OccurredAt *time.Time `json:"occurred_at"` + Seat string `json:"seat"` + Agent string `json:"agent"` + Machine string `json:"machine"` + Persona string `json:"persona"` + Channel string `json:"channel"` + SessionURL string `json:"session_url"` +} + +type frictionP0Alert struct { + Tool string `json:"tool"` + SubjectIDs []string `json:"subject_ids"` +} + +type frictionFindingsResponse struct { + Findings []frictionFindingItem `json:"findings"` + NextCursor string `json:"next_cursor"` +} + +type frictionFindingItem struct { + SessionID string `json:"session_id"` + Kind string `json:"kind"` + Detector string `json:"detector"` + MessageOrdinal *int `json:"message_ordinal"` + CallIndex *int `json:"call_index"` + ToolName string `json:"tool_name"` + Label string `json:"label"` + Text string `json:"text"` + Evidence string `json:"evidence"` + Title string `json:"title"` + Fingerprint string `json:"fingerprint"` + OccurredAt *time.Time `json:"occurred_at"` + Seq int `json:"seq"` + RulesVersion string `json:"rules_version"` +} + +type frictionPatternsResponse struct { + Patterns []frictionPatternItem `json:"patterns"` + NextCursor string `json:"next_cursor"` +} + +type frictionPatternItem struct { + Fingerprint string `json:"fingerprint"` + Kind string `json:"kind"` + Title string `json:"title"` + FirstSeenDate string `json:"first_seen_date"` + LastSeenDate string `json:"last_seen_date"` + OccurrenceCount int `json:"occurrence_count"` + SessionCount int `json:"session_count"` + LastSubjectID string `json:"last_subject_id"` + LastOrdinal *int `json:"last_ordinal"` +} + +type frictionRunResponse struct { + Reports []frictionRunReport `json:"reports"` +} + +type frictionRunReport struct { + Date string `json:"date"` + Written bool `json:"written"` + Summary frictionSummary `json:"summary"` + Human string `json:"human"` +} + +// --- validation and helpers --- + +var ( + frictionKinds = []string{"correction", "error", "workaround", "deferral", "pattern", "frustration", "interruption"} + frictionLinkStates = []string{"linked", "unlinked", "pending", "failed", "needs_human", "abandoned"} +) + +func validateFrictionEnum(field, value string, allowed []string) error { + if value == "" || slices.Contains(allowed, value) { + return nil + } + return apiError(http.StatusBadRequest, + fmt.Sprintf("invalid %s %q: use one of %s", field, value, strings.Join(allowed, ", "))) +} + +func validateFrictionLimit(n int) error { + if n < 0 || n > db.MaxFrictionListLimit { + return apiError(http.StatusBadRequest, + fmt.Sprintf("limit must be between 1 and %d", db.MaxFrictionListLimit)) + } + return nil +} + +func frictionStoreError(logPrefix string, err error) error { + if handled := handleHumaReadOnly(err); handled != nil { + return handled + } + if errors.Is(err, db.ErrInvalidCursor) { + return apiError(http.StatusBadRequest, "invalid cursor") + } + return internalError(logPrefix, err) +} + +func (s *Server) frictionPublicURL() string { + s.mu.RLock() + defer s.mu.RUnlock() + return strings.TrimRight(strings.TrimSpace(s.cfg.PublicURL), "/") +} + +func (s *Server) frictionRequireAuth() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.RequireAuth +} + +// frictionSessionURL mirrors the browser router: agent prefix and opaque id +// are separate path segments (servicehttp.sessionWebURL uses the same split). +func frictionSessionURL(base, sessionID string, ordinal *int) string { + if base == "" || sessionID == "" { + return "" + } + prefix, rest, found := strings.Cut(sessionID, ":") + path := url.PathEscape(prefix) + if found { + path += "/" + url.PathEscape(rest) + } + u := base + "/sessions/" + path + if ordinal != nil { + u += "?msg=" + strconv.Itoa(*ordinal) + } + return u +} + +func frictionSignalItems(sigs []friction.Signal, base string) []frictionSignal { + out := make([]frictionSignal, 0, len(sigs)) + for _, sig := range sigs { + item := frictionSignal{ + Kind: string(sig.Kind), Detector: sig.Detector, SubjectID: sig.SubjectID, + SubjectKind: cmp.Or(sig.SubjectKind, friction.SubjectSession), + Title: sig.Title(), Fingerprint: sig.Fingerprint(), Text: sig.Text, + ToolName: sig.ToolName, Label: sig.Label, Evidence: sig.Evidence, + MessageOrdinal: sig.Ordinal, CallIndex: sig.CallIndex, + Seat: sig.Dims.Seat, Agent: sig.Dims.Agent, Machine: sig.Dims.Machine, + Persona: sig.Dims.Persona, Channel: sig.Dims.Channel, + } + if item.SubjectKind == friction.SubjectSession { + // Diagnostic identities (§11.4) are not sessions; they get no link. + item.SessionURL = frictionSessionURL(base, sig.SubjectID, sig.Ordinal) + } + if !sig.OccurredAt.IsZero() { + item.OccurredAt = new(sig.OccurredAt.UTC()) + } + out = append(out, item) + } + return out +} + +func frictionP0AlertItems(alerts map[string][]string) []frictionP0Alert { + out := make([]frictionP0Alert, 0, len(alerts)) + for _, tool := range slices.Sorted(maps.Keys(alerts)) { + ids := slices.Clone(alerts[tool]) + slices.Sort(ids) + out = append(out, frictionP0Alert{Tool: tool, SubjectIDs: ids}) + } + return out +} + +func decodeFrictionSummary(raw []byte) (frictionSummary, error) { + var out frictionSummary + if err := json.Unmarshal(raw, &out); err != nil { + return frictionSummary{}, fmt.Errorf("decoding friction summary: %w", err) + } + return out, nil +} + +func (s *Server) frictionDigestByDate(ctx context.Context, date string) (*db.FrictionDigest, error) { + if !timeutil.IsValidDate(date) { + return nil, apiError(http.StatusBadRequest, "invalid date format: use YYYY-MM-DD") + } + d, err := s.db.GetFrictionDigest(ctx, date) + if err != nil { + return nil, frictionStoreError("get friction digest", err) + } + if d == nil { + return nil, apiError(http.StatusNotFound, "friction digest not found") + } + return d, nil +} + +// --- handlers --- + +func (s *Server) humaListFrictionDigests( + ctx context.Context, in *frictionDigestsInput, +) (*jsonOutput[frictionDigestsResponse], error) { + if err := validateDateFilterValues("", in.From, in.To, ""); err != nil { + return nil, err + } + rows, err := s.db.ListFrictionDigests(ctx, in.From, in.To) + if err != nil { + return nil, frictionStoreError("list friction digests", err) + } + out := make([]frictionDigestItem, 0, len(rows)) + for _, d := range rows { + out = append(out, frictionDigestItem{ + Date: d.Date, Timezone: d.Timezone, RulesVersion: d.RulesVersion, + BuiltAt: d.BuiltAt.UTC(), Revision: d.Revision, SessionsScanned: d.SessionsScanned, + }) + } + return &jsonOutput[frictionDigestsResponse]{Body: frictionDigestsResponse{Digests: out}}, nil +} + +func (s *Server) humaGetFrictionDigest( + ctx context.Context, in *frictionDatePathInput, +) (*jsonOutput[frictionDigestResponse], error) { + d, err := s.frictionDigestByDate(ctx, in.Date) + if err != nil { + return nil, err + } + summary, err := decodeFrictionSummary(d.SummaryJSON) + if err != nil { + return nil, internalError("friction digest "+d.Date, err) + } + snap, err := review.DecodeSnapshot(d.SnapshotJSON) + if err != nil { + return nil, internalError("friction digest "+d.Date, err) + } + base := s.frictionPublicURL() + resp := frictionDigestResponse{ + Date: d.Date, Timezone: d.Timezone, RulesVersion: d.RulesVersion, + BuiltAt: d.BuiltAt.UTC(), Revision: d.Revision, SessionsScanned: d.SessionsScanned, + MarkdownSHA256: d.MarkdownSHA256, Summary: summary, + Signals: frictionSignalItems(snap.Signals, base), + P0Alerts: frictionP0AlertItems(snap.P0Alerts), + } + if base != "" { + resp.WebURL = base + "/friction/" + d.Date + } + return &jsonOutput[frictionDigestResponse]{Body: resp}, nil +} + +func (s *Server) humaFrictionDigestMarkdown( + ctx context.Context, in *frictionDatePathInput, +) (*bytesOutput, error) { + d, err := s.frictionDigestByDate(ctx, in.Date) + if err != nil { + return nil, err + } + return &bytesOutput{ + ContentType: "text/markdown; charset=utf-8", + ContentDisposition: fmt.Sprintf(`inline; filename="friction-log-%s.md"`, d.Date), + NoSniff: "nosniff", + Body: d.Markdown, + }, nil +} + +func (s *Server) humaListFrictionFindings( + ctx context.Context, in *frictionFindingsInput, +) (*jsonOutput[frictionFindingsResponse], error) { + if err := validateDateFilterValues(in.Date, "", "", ""); err != nil { + return nil, err + } + if err := validateFrictionEnum("kind", in.Kind, frictionKinds); err != nil { + return nil, err + } + if err := validateFrictionLimit(in.Limit); err != nil { + return nil, err + } + rows, next, err := s.db.ListFrictionFindings(ctx, db.FrictionFindingFilter{ + Date: in.Date, Kind: in.Kind, SessionID: in.SessionID, + Fingerprint: in.Fingerprint, Limit: in.Limit, Cursor: in.Cursor, + }) + if err != nil { + return nil, frictionStoreError("list friction findings", err) + } + out := make([]frictionFindingItem, 0, len(rows)) + for _, r := range rows { + out = append(out, frictionFindingItem{ + SessionID: r.SessionID, Kind: r.Kind, Detector: r.Detector, + MessageOrdinal: r.MessageOrdinal, CallIndex: r.CallIndex, + ToolName: r.ToolName, Label: r.Label, Text: r.Text, Evidence: r.Evidence, + Title: r.Title, Fingerprint: r.Fingerprint, OccurredAt: r.OccurredAt, + Seq: r.Seq, RulesVersion: r.RulesVersion, + }) + } + return &jsonOutput[frictionFindingsResponse]{ + Body: frictionFindingsResponse{Findings: out, NextCursor: next}, + }, nil +} + +func (s *Server) humaListFrictionPatterns( + ctx context.Context, in *frictionPatternsInput, +) (*jsonOutput[frictionPatternsResponse], error) { + if err := validateDateFilterValues(in.Since, "", "", ""); err != nil { + return nil, err + } + if err := validateFrictionEnum("kind", in.Kind, frictionKinds); err != nil { + return nil, err + } + if err := validateFrictionEnum("link_state", in.LinkState, frictionLinkStates); err != nil { + return nil, err + } + if err := validateFrictionLimit(in.Limit); err != nil { + return nil, err + } + rows, next, err := s.db.ListFrictionPatterns(ctx, db.FrictionPatternFilter{ + Kind: in.Kind, LinkState: in.LinkState, Since: in.Since, + Limit: in.Limit, Cursor: in.Cursor, + }) + if err != nil { + return nil, frictionStoreError("list friction patterns", err) + } + out := make([]frictionPatternItem, 0, len(rows)) + for _, p := range rows { + out = append(out, frictionPatternItem{ + Fingerprint: p.Fingerprint, Kind: p.Kind, Title: p.Title, + FirstSeenDate: p.FirstSeenDate, LastSeenDate: p.LastSeenDate, + OccurrenceCount: p.OccurrenceCount, SessionCount: p.SessionCount, + LastSubjectID: p.LastSubjectID, LastOrdinal: p.LastOrdinal, + }) + } + return &jsonOutput[frictionPatternsResponse]{ + Body: frictionPatternsResponse{Patterns: out, NextCursor: next}, + }, nil +} + +func (s *Server) humaRunFrictionReview( + ctx context.Context, in *frictionRunInput, +) (*jsonOutput[frictionRunResponse], error) { + if !s.frictionRequireAuth() && !isLocalhostContext(ctx) { + return nil, apiError(http.StatusForbidden, + "friction review runs require auth or a localhost request") + } + runner, exclusive := s.frictionRunner, s.frictionExclusive + if runner == nil { + return nil, apiError(http.StatusServiceUnavailable, "friction review is not enabled") + } + if exclusive == nil { + exclusive = func(f func() error) error { return f() } + } + req := review.RunRequest{Date: in.Body.Date, Rebuild: in.Body.Rebuild, DryRun: in.Body.DryRun} + var reports []review.Report + err := exclusive(func() error { + var runErr error + reports, runErr = runner.Run(ctx, req) + return runErr + }) + if errors.Is(err, review.ErrInvalidRunDate) { + return nil, apiError(http.StatusBadRequest, err.Error()) + } + if handled := handleHumaContextError(err); handled != nil { + return nil, handled + } + if handled := handleHumaReadOnly(err); handled != nil { + return nil, handled + } + if err != nil { + return nil, internalError("friction review", err) + } + out := make([]frictionRunReport, 0, len(reports)) + for _, rep := range reports { + summary, err := decodeFrictionSummary(friction.RenderSummaryJSON(rep.Snapshot, rep.Meta)) + if err != nil { + return nil, internalError("friction review", err) + } + out = append(out, frictionRunReport{ + Date: rep.Date, Written: rep.Written, Summary: summary, + Human: friction.HumanSummary(rep.Snapshot, rep.Meta), + }) + } + return &jsonOutput[frictionRunResponse]{Body: frictionRunResponse{Reports: out}}, nil +} diff --git a/internal/server/huma_routes_friction_internal_test.go b/internal/server/huma_routes_friction_internal_test.go new file mode 100644 index 0000000000..ecddb91d2d --- /dev/null +++ b/internal/server/huma_routes_friction_internal_test.go @@ -0,0 +1,27 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFrictionSessionURL(t *testing.T) { + tests := []struct { + name, base, id string + ord *int + want string + }{ + {"no_public_url", "", "claude:s1", new(1), ""}, + {"prefixed_with_ordinal", "https://h.example", "claude:s1", new(7), "https://h.example/sessions/claude/s1?msg=7"}, + {"no_ordinal", "https://h.example", "claude:s1", nil, "https://h.example/sessions/claude/s1"}, + {"unprefixed", "https://h.example", "abc", nil, "https://h.example/sessions/abc"}, + {"escapes_rest", "https://h.example/base", "codex:abc/def ghi", nil, "https://h.example/base/sessions/codex/abc%2Fdef%20ghi"}, + {"only_first_colon_splits", "https://h.example", "remote~m:claude:x", nil, "https://h.example/sessions/remote~m/claude:x"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, frictionSessionURL(tt.base, tt.id, tt.ord)) + }) + } +} diff --git a/internal/server/huma_routes_friction_test.go b/internal/server/huma_routes_friction_test.go new file mode 100644 index 0000000000..d10a324207 --- /dev/null +++ b/internal/server/huma_routes_friction_test.go @@ -0,0 +1,298 @@ +package server_test + +import ( + "context" + "encoding/json/v2" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/server" +) + +// Wire mirrors used only to decode responses in tests. +type testFrictionSignal struct { + Kind string `json:"kind"` + SubjectID string `json:"subject_id"` + SubjectKind string `json:"subject_kind"` + Fingerprint string `json:"fingerprint"` + Title string `json:"title"` + MessageOrdinal *int `json:"message_ordinal"` + SessionURL string `json:"session_url"` +} + +type testFrictionDigest struct { + Date string `json:"date"` + Revision int `json:"revision"` + WebURL string `json:"web_url"` + Summary struct { + SchemaVersion int `json:"schema_version"` + Corrections int `json:"corrections"` + P0Alerts map[string][]string `json:"p0_alerts"` + Personas map[string]any `json:"personas"` + DigestPath *string `json:"digest_path"` + } `json:"summary"` + Signals []testFrictionSignal `json:"signals"` + P0Alerts []struct { + Tool string `json:"tool"` + SubjectIDs []string `json:"subject_ids"` + } `json:"p0_alerts"` +} + +func frictionTestSnapshot(date string) friction.DigestSnapshot { + return friction.DigestSnapshot{ + Date: date, Timezone: "UTC", RulesVersion: friction.RulesVersion, + Signals: []friction.Signal{{ + Kind: friction.KindCorrection, SubjectID: "claude:s1", SubjectKind: friction.SubjectSession, + Detector: "correction.coding", Text: "no, use the other flag", Ordinal: new(4), + }, { + Kind: friction.KindError, SubjectID: "run-7:ci_check", SubjectKind: friction.SubjectDiagnostic, + Detector: "error", ToolName: "ci_check", Text: "run-7:ci_check: nightly check failed", + }}, + P0Alerts: map[string][]string{"bash": {"claude:s3", "claude:s1", "claude:s2"}}, + SessionsScanned: 1, + } +} + +func seedFrictionDigest(t *testing.T, d *db.DB, date string) { + t.Helper() + snap := frictionTestSnapshot(date) + snapJSON, err := review.EncodeSnapshot(snap) + require.NoError(t, err) + path := "friction:" + date + summary := friction.RenderSummaryJSON(snap, friction.SummaryMeta{DigestPath: &path}) + md := friction.RenderMarkdown(snap, friction.RenderLinks{}) + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: date, Timezone: "UTC", RulesVersion: friction.RulesVersion, + BuiltAt: time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC), Revision: 1, + SessionsScanned: 1, SnapshotJSON: snapJSON, SummaryJSON: summary, + Markdown: md, MarkdownSHA256: "sha", RunID: "run", + }, []db.FrictionDigestSubject{{SubjectID: "claude:s1", Date: date, SubjectKind: "session"}}, nil)) +} + +func TestFrictionDigestRoutes(t *testing.T) { + te := setup(t, withPublicURL("https://av.example.test/base/")) + seedFrictionDigest(t, te.db, "2026-09-14") + + t.Run("list", func(t *testing.T) { + w := te.get(t, "/api/v1/friction/digests?from=2026-09-01&to=2026-09-30") + assertStatus(t, w, http.StatusOK) + got := decode[struct { + Digests []struct { + Date string `json:"date"` + Revision int `json:"revision"` + } `json:"digests"` + }](t, w) + require.Len(t, got.Digests, 1) + assert.Equal(t, "2026-09-14", got.Digests[0].Date) + }) + t.Run("detail", func(t *testing.T) { + w := te.get(t, "/api/v1/friction/digests/2026-09-14") + assertStatus(t, w, http.StatusOK) + got := decode[testFrictionDigest](t, w) + assert.Equal(t, "https://av.example.test/base/friction/2026-09-14", got.WebURL) + assert.Equal(t, 3, got.Summary.SchemaVersion) + assert.Equal(t, 1, got.Summary.Corrections) + assert.NotNil(t, got.Summary.Personas, "personas is always present (jilog json_output_has_documented_keys)") + require.Len(t, got.Signals, 2) + assert.Equal(t, "claude:s1", got.Signals[0].SubjectID) + assert.Equal(t, "session", got.Signals[0].SubjectKind) + assert.True(t, strings.HasPrefix(got.Signals[0].Fingerprint, "fl1:")) + assert.Equal(t, "https://av.example.test/base/sessions/claude/s1?msg=4", got.Signals[0].SessionURL) + assert.Equal(t, "diagnostic", got.Signals[1].SubjectKind) + assert.Empty(t, got.Signals[1].SessionURL, "diagnostic identities are not sessions") + require.Len(t, got.P0Alerts, 1) + assert.Equal(t, "bash", got.P0Alerts[0].Tool) + assert.Equal(t, []string{"claude:s1", "claude:s2", "claude:s3"}, got.P0Alerts[0].SubjectIDs) + }) + t.Run("markdown", func(t *testing.T) { + w := te.get(t, "/api/v1/friction/digests/2026-09-14/md") + assertStatus(t, w, http.StatusOK) + assert.Contains(t, w.Header().Get("Content-Type"), "text/markdown") + assert.True(t, strings.HasPrefix(w.Body.String(), "---\n")) + assert.Contains(t, w.Body.String(), "# Friction Log — 2026-09-14") + }) + t.Run("missing_is_404", func(t *testing.T) { + assertStatus(t, te.get(t, "/api/v1/friction/digests/2026-01-01"), http.StatusNotFound) + assertStatus(t, te.get(t, "/api/v1/friction/digests/2026-01-01/md"), http.StatusNotFound) + }) + t.Run("bad_date_is_400", func(t *testing.T) { + assertStatus(t, te.get(t, "/api/v1/friction/digests/yesterday"), http.StatusBadRequest) + assertStatus(t, te.get(t, "/api/v1/friction/digests?from=2026-09-30&to=2026-09-01"), http.StatusBadRequest) + }) + t.Run("corrupt_summary_is_500", func(t *testing.T) { + require.NoError(t, te.db.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2026-09-10", Timezone: "UTC", RulesVersion: friction.RulesVersion, + BuiltAt: time.Now().UTC(), Revision: 1, SnapshotJSON: []byte("{}"), + SummaryJSON: []byte("not json"), Markdown: []byte("x"), MarkdownSHA256: "x", RunID: "r", + }, nil, nil)) + w := te.get(t, "/api/v1/friction/digests/2026-09-10") + assertStatus(t, w, http.StatusInternalServerError) + assertBodyContains(t, w, "internal error") + }) +} + +func TestFrictionListRoutes(t *testing.T) { + te := setup(t) + seedFrictionDigest(t, te.db, "2026-09-14") + require.NoError(t, te.db.UpsertSession(t.Context(), db.Session{ID: "claude:s1", Project: "p", Machine: "m", Agent: "claude", MessageCount: 1})) + require.NoError(t, te.db.ReplaceSessionFriction(t.Context(), "claude:s1", []db.FrictionFinding{ + {SessionID: "claude:s1", Kind: "error", Detector: "error", ToolName: "Bash", Title: "t1", Fingerprint: "fl1:x", Seq: 0, RulesVersion: friction.RulesVersion}, + {SessionID: "claude:s1", Kind: "workaround", Detector: "workaround", Label: "for now", Title: "t2", Fingerprint: "fl1:y", Seq: 1, RulesVersion: friction.RulesVersion}, + {SessionID: "claude:s1", Kind: "frustration", Detector: "frustration", Text: "why is this still broken", Title: "t3", Fingerprint: "fl1:z", Seq: 2, RulesVersion: friction.RulesVersion}, + }, nil, friction.RulesVersion, "h")) + + tests := []struct { + name string + path string + status int + count int + next string + }{ + {"findings_all", "/api/v1/friction/findings", http.StatusOK, 3, ""}, + {"findings_kind", "/api/v1/friction/findings?kind=error", http.StatusOK, 1, ""}, + {"findings_kind_frustration", "/api/v1/friction/findings?kind=frustration", http.StatusOK, 1, ""}, + {"findings_kind_interruption", "/api/v1/friction/findings?kind=interruption", http.StatusOK, 0, ""}, + {"findings_session_and_date", "/api/v1/friction/findings?session_id=claude:s1&date=2026-09-14", http.StatusOK, 3, ""}, + {"findings_page", "/api/v1/friction/findings?limit=1", http.StatusOK, 1, "1"}, + {"findings_bad_kind", "/api/v1/friction/findings?kind=nope", http.StatusBadRequest, 0, ""}, + {"bad_cursor_is_400", "/api/v1/friction/findings?cursor=abc", http.StatusBadRequest, 0, ""}, + {"limit_over_max_is_400", "/api/v1/friction/findings?limit=5000", http.StatusBadRequest, 0, ""}, + {"patterns_empty", "/api/v1/friction/patterns", http.StatusOK, 0, ""}, + {"patterns_bad_link_state", "/api/v1/friction/patterns?link_state=maybe", http.StatusBadRequest, 0, ""}, + {"patterns_bad_since", "/api/v1/friction/patterns?since=last-week", http.StatusBadRequest, 0, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := te.get(t, tt.path) + assertStatus(t, w, tt.status) + if tt.status != http.StatusOK { + return + } + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + key := "findings" + if strings.Contains(tt.path, "patterns") { + key = "patterns" + } + items, ok := body[key].([]any) + require.True(t, ok, "%s must be a non-null array", key) + assert.Len(t, items, tt.count) + assert.Equal(t, tt.next, body["next_cursor"]) + }) + } +} + +func TestFrictionRunRoute(t *testing.T) { + fixed := time.Date(2026, 9, 16, 2, 30, 0, 0, time.UTC) + runner := &review.Runner{Loc: time.UTC, Now: func() time.Time { return fixed }, BackfillDays: 7} + te := setupWithServerOpts(t, []server.Option{server.WithFriction(runner, nil)}) + runner.Store = te.db + + post := func(t *testing.T, body, remote string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, + "/api/v1/friction/run", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if remote != "" { + req.RemoteAddr = remote + } + w := httptest.NewRecorder() + te.handler.ServeHTTP(w, req) + return w + } + type runResp struct { + Reports []struct { + Date string `json:"date"` + Written bool `json:"written"` + Human string `json:"human"` + Summary struct { + SchemaVersion int `json:"schema_version"` + DigestPath *string `json:"digest_path"` + } `json:"summary"` + } `json:"reports"` + } + t.Run("remote_without_auth_is_403", func(t *testing.T) { + assertStatus(t, post(t, `{}`, "203.0.113.9:4000"), http.StatusForbidden) + }) + t.Run("today_is_400", func(t *testing.T) { + assertStatus(t, post(t, `{"date":"2026-09-16"}`, ""), http.StatusBadRequest) + }) + t.Run("malformed_date_is_400", func(t *testing.T) { + assertStatus(t, post(t, `{"date":"9/14"}`, ""), http.StatusBadRequest) + }) + t.Run("dry_run_writes_nothing", func(t *testing.T) { + w := post(t, `{"date":"2026-09-14","dry_run":true}`, "") + assertStatus(t, w, http.StatusOK) + got := decode[runResp](t, w) + require.Len(t, got.Reports, 1) + assert.False(t, got.Reports[0].Written) + assert.Nil(t, got.Reports[0].Summary.DigestPath, "dry run digest_path is null (jilog json_dry_run_uses_null_digest_path)") + assertStatus(t, te.get(t, "/api/v1/friction/digests/2026-09-14"), http.StatusNotFound) + }) + t.Run("build_then_read", func(t *testing.T) { + w := post(t, `{"date":"2026-09-14"}`, "") + assertStatus(t, w, http.StatusOK) + got := decode[runResp](t, w) + require.Len(t, got.Reports, 1) + assert.True(t, got.Reports[0].Written) + assert.Equal(t, 3, got.Reports[0].Summary.SchemaVersion) + assert.Contains(t, got.Reports[0].Human, "session(s) scanned") + assertStatus(t, te.get(t, "/api/v1/friction/digests/2026-09-14"), http.StatusOK) + }) +} + +func TestFrictionRunRouteDisabled(t *testing.T) { + te := setup(t) + w := te.post(t, "/api/v1/friction/run", `{}`) + assertStatus(t, w, http.StatusServiceUnavailable) + assertBodyContains(t, w, "friction review is not enabled") +} + +type readOnlyFrictionStore struct{ db.Store } + +func (readOnlyFrictionStore) ListFrictionFindings(_ context.Context, _ db.FrictionFindingFilter) ([]db.FrictionFinding, string, error) { + return nil, "", db.ErrReadOnly +} + +func (readOnlyFrictionStore) ListFrictionDigests(_ context.Context, _, _ string) ([]db.FrictionDigest, error) { + return nil, db.ErrReadOnly +} + +func (readOnlyFrictionStore) ListFrictionPatterns(_ context.Context, _ db.FrictionPatternFilter) ([]db.FrictionPattern, string, error) { + return nil, "", db.ErrReadOnly +} + +func (readOnlyFrictionStore) GetFrictionDigest(_ context.Context, _ string) (*db.FrictionDigest, error) { + return nil, db.ErrReadOnly +} + +func TestFrictionRoutesReadOnlyBackends(t *testing.T) { + te := setup(t) + cfg := config.Config{Host: "127.0.0.1", DataDir: t.TempDir()} + handler := server.New(cfg, readOnlyFrictionStore{Store: te.db}, nil).Handler() + for _, path := range []string{ + "/api/v1/friction/digests", + "/api/v1/friction/digests/2026-09-14", + "/api/v1/friction/digests/2026-09-14/md", + "/api/v1/friction/findings", + "/api/v1/friction/patterns", + } { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "http://127.0.0.1:0"+path, nil) + req.RemoteAddr = "127.0.0.1:1234" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assertStatus(t, w, http.StatusNotImplemented) + }) + } +} diff --git a/openapi.yaml b/openapi.yaml index 93f0ea88db..aa1859a188 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4630,2351 +4630,2897 @@ components: - Skipped - Stale type: object - GenerateInsightRequest: + FrictionArchivePeriod: additionalProperties: false properties: - agent: - type: string - automated_scope: - type: string - date_from: - type: string - date_to: - type: string - filters: - $ref: "#/components/schemas/CannedSessionFiltersInput" - force_refresh: - type: boolean - kind: - type: string - llm_opt_in: - type: boolean - project: - type: string - prompt: - type: string - session_id: + agents_usd: + additionalProperties: + type: string + type: object + days: + format: int64 + type: integer + models_usd: + additionalProperties: + type: string + type: object + total_usd: type: string + required: + - total_usd + - days + - agents_usd + - models_usd + type: object + FrictionArchiveSpend: + additionalProperties: false + properties: timezone: type: string - type: + week: + $ref: "#/components/schemas/FrictionArchivePeriod" + week_from: + type: string + week_to: type: string + yesterday: + $ref: "#/components/schemas/FrictionArchivePeriod" required: - - type - - date_from - - date_to + - yesterday + - week + - week_from + - week_to + - timezone type: object - GithubConfigResponse: + FrictionCreatedIssue: additionalProperties: false properties: - configured: - type: boolean + backend: + type: string + id: + type: string + title: + type: string + url: + type: string required: - - configured + - id + - backend + - title + - url type: object - ImporterImportStats: + FrictionDigestItem: additionalProperties: false properties: - errors: - format: int64 - type: integer - imported: - format: int64 - type: integer - skipped: + built_at: + format: date-time + type: string + date: + type: string + revision: format: int64 type: integer - updated: + rules_version: + type: string + sessions_scanned: format: int64 type: integer + timezone: + type: string required: - - imported - - updated - - skipped - - errors + - date + - timezone + - rules_version + - built_at + - revision + - sessions_scanned type: object - InsightsResponse: + FrictionDigestResponse: additionalProperties: false properties: - insights: + built_at: + format: date-time + type: string + date: + type: string + markdown_sha256: + type: string + p0_alerts: items: - $ref: "#/components/schemas/DbInsight" + $ref: "#/components/schemas/FrictionP0Alert" + type: array + revision: + format: int64 + type: integer + rules_version: + type: string + sessions_scanned: + format: int64 + type: integer + signals: + items: + $ref: "#/components/schemas/FrictionSignal" type: array + summary: + $ref: "#/components/schemas/FrictionSummary" + timezone: + type: string + web_url: + type: string required: - - insights + - date + - timezone + - rules_version + - built_at + - revision + - sessions_scanned + - markdown_sha256 + - web_url + - summary + - signals + - p0_alerts type: object - MachinesResponse: + FrictionDigestsResponse: additionalProperties: false properties: - machine_aliases: - additionalProperties: - type: string - type: object - machine_labels: - additionalProperties: - type: string - type: object - machines: + digests: items: - type: string + $ref: "#/components/schemas/FrictionDigestItem" type: array required: - - machines - - machine_labels - - machine_aliases + - digests type: object - ModelTotal: + FrictionFindingItem: additionalProperties: false properties: - cacheCreationTokens: - format: int64 - type: integer - cacheReadTokens: - format: int64 - type: integer - cost: - $ref: "#/components/schemas/MoneyMoney" - inputTokens: + call_index: format: int64 - type: integer - model: + type: + - integer + - "null" + detector: type: string - outputTokens: + evidence: + type: string + fingerprint: + type: string + kind: + type: string + label: + type: string + message_ordinal: format: int64 - type: integer - required: - - model - - inputTokens - - outputTokens - - cacheCreationTokens - - cacheReadTokens - - cost - type: object - MoneyMoney: - additionalProperties: false - properties: - microdollars: + type: + - integer + - "null" + occurred_at: + format: date-time + type: + - string + - "null" + rules_version: + type: string + seq: format: int64 type: integer + session_id: + type: string + text: + type: string + title: + type: string + tool_name: + type: string required: - - microdollars + - session_id + - kind + - detector + - message_ordinal + - call_index + - tool_name + - label + - text + - evidence + - title + - fingerprint + - occurred_at + - seq + - rules_version type: object - OpenRequest: + FrictionFindingsResponse: additionalProperties: false properties: - opener_id: + findings: + items: + $ref: "#/components/schemas/FrictionFindingItem" + type: array + next_cursor: type: string required: - - opener_id + - findings + - next_cursor type: object - OpenSessionResponse: + FrictionP0Alert: additionalProperties: false properties: - launched: - type: boolean - opener: - type: string - path: + subject_ids: + items: + type: string + type: array + tool: type: string required: - - launched - - opener - - path + - tool + - subject_ids type: object - Opener: + FrictionPatternItem: additionalProperties: false properties: - bin: + fingerprint: type: string - id: + first_seen_date: type: string kind: type: string - name: + last_ordinal: + format: int64 + type: + - integer + - "null" + last_seen_date: + type: string + last_subject_id: + type: string + occurrence_count: + format: int64 + type: integer + session_count: + format: int64 + type: integer + title: type: string required: - - id - - name + - fingerprint - kind - - bin - type: object - OpenersResponse: - additionalProperties: false - properties: - openers: - items: - $ref: "#/components/schemas/Opener" - type: array - required: - - openers + - title + - first_seen_date + - last_seen_date + - occurrence_count + - session_count + - last_subject_id + - last_ordinal type: object - OrdinalsResponse: + FrictionPatternsResponse: additionalProperties: false properties: - ordinals: + next_cursor: + type: string + patterns: items: - format: int64 - type: integer + $ref: "#/components/schemas/FrictionPatternItem" type: array required: - - ordinals + - patterns + - next_cursor type: object - PinMessageResponse: + FrictionPersonaSummary: additionalProperties: false properties: - id: + channel: + type: + - string + - "null" + corrections: format: int64 type: integer - required: - - id - type: object - PinRequest: - additionalProperties: false - properties: - note: - type: string - type: object - PingInfo: - additionalProperties: false - properties: - healthy: - type: boolean - ok: - type: boolean - pid: + cost_usd: + type: + - string + - "null" + deferrals: format: int64 type: integer - service: - type: string - sync: - $ref: "#/components/schemas/SyncProgress" - version: - type: string - required: - - ok - - healthy - type: object - PinsResponse: - additionalProperties: false - properties: - pins: - items: - $ref: "#/components/schemas/DbPinnedMessage" - type: array - required: - - pins - type: object - ProjectTotal: - additionalProperties: false - properties: - cacheCreationTokens: + errors: format: int64 type: integer - cacheReadTokens: + input_tokens: format: int64 + minimum: 0 type: integer - cost: - $ref: "#/components/schemas/MoneyMoney" - inputTokens: + output_tokens: format: int64 + minimum: 0 type: integer - outputTokens: + patterns: format: int64 type: integer - project: + persona: type: string - project_key: - type: string - required: - - project_key - - project - - inputTokens - - outputTokens - - cacheCreationTokens - - cacheReadTokens - - cost - type: object - ProjectsResponse: - additionalProperties: false - properties: - projects: - items: - $ref: "#/components/schemas/DbProjectInfo" - type: array + sessions: + format: int64 + type: integer + workarounds: + format: int64 + type: integer required: - - projects + - persona + - channel + - sessions + - corrections + - errors + - workarounds + - deferrals + - patterns + - input_tokens + - output_tokens + - cost_usd type: object - PublishResponse: + FrictionRunReport: additionalProperties: false properties: - gist_id: - type: string - gist_url: - type: string - raw_url: + date: type: string - view_url: + human: type: string - required: - - gist_id - - gist_url - - view_url - - raw_url - type: object - RawSyncManifestResponse: - additionalProperties: false - properties: - created: + summary: + $ref: "#/components/schemas/FrictionSummary" + written: type: boolean - generation: - format: int64 - type: integer - manifest_id: - type: string - receipt: - type: string required: - - manifest_id - - receipt - - generation - - created + - date + - written + - summary + - human type: object - RawSyncMissingObjectsInputBody: + FrictionRunRequest: additionalProperties: false properties: - objects: - items: - $ref: "#/components/schemas/RawsyncObjectRef" - type: array - provider: + date: + description: Digest date (YYYY-MM-DD); omit to catch up every missing complete date type: string - required: - - provider - - objects - type: object - RawSyncMissingObjectsResponse: - additionalProperties: false - properties: - missing: - items: - $ref: "#/components/schemas/RawsyncObjectRef" - type: array - required: - - missing + dry_run: + description: Compute and render without writing + type: boolean + rebuild: + description: Rebuild an existing digest, keeping its membership + type: boolean type: object - RawSyncTokenInputBody: + FrictionRunResponse: additionalProperties: false properties: - scopes: + reports: items: - type: string + $ref: "#/components/schemas/FrictionRunReport" type: array required: - - scopes + - reports type: object - RawSyncTokenResponse: + FrictionSignal: additionalProperties: false properties: - device_id: + agent: type: string - expires_at: - format: date-time + call_index: + format: int64 + type: + - integer + - "null" + channel: type: string - scopes: - items: - type: string - type: array - token: + detector: type: string - required: - - token - - device_id - - scopes - - expires_at - type: object - RawSyncUploadResponse: - additionalProperties: false - properties: - complete: - type: boolean - created: - type: boolean - expires_at: - format: date-time + evidence: type: string - object: - $ref: "#/components/schemas/RawsyncObjectRef" - offset: + fingerprint: + type: string + kind: + type: string + label: + type: string + machine: + type: string + message_ordinal: format: int64 - type: integer - upload_id: + type: + - integer + - "null" + occurred_at: + format: date-time + type: + - string + - "null" + persona: type: string - required: - - object - - offset - - complete - - created - type: object - RawSyncUploadStartInputBody: - additionalProperties: false - properties: - object: - $ref: "#/components/schemas/RawsyncObjectRef" - provider: + seat: + type: string + session_url: + type: string + subject_id: + type: string + subject_kind: + type: string + text: + type: string + title: + type: string + tool_name: type: string required: - - provider - - object + - kind + - detector + - subject_id + - subject_kind + - title + - fingerprint + - text + - tool_name + - label + - evidence + - message_ordinal + - call_index + - occurred_at + - seat + - agent + - machine + - persona + - channel + - session_url type: object - RawsyncDeviceStatus: + FrictionSpendSummary: additionalProperties: false properties: - device_id: - type: string - last_seen_at: - format: date-time + input_tokens: + format: int64 + minimum: 0 + type: integer + model_costs_usd: + additionalProperties: + type: string + type: object + output_tokens: + format: int64 + minimum: 0 + type: integer + role_costs_usd: + additionalProperties: + type: string + type: object + sessions_with_cost: + format: int64 + type: integer + sessions_with_stats: + format: int64 + type: integer + total_usd: type: - string - "null" required: - - device_id - - last_seen_at + - total_usd + - sessions_with_stats + - sessions_with_cost + - input_tokens + - output_tokens + - role_costs_usd + - model_costs_usd type: object - RawsyncEntry: + FrictionSummary: additionalProperties: false properties: - length: - format: int64 - type: integer - mod_time_ns: + archive_spend: + $ref: "#/components/schemas/FrictionArchiveSpend" + corrections: format: int64 type: integer - objects: + created_issues: items: - $ref: "#/components/schemas/RawsyncObjectRef" + $ref: "#/components/schemas/FrictionCreatedIssue" type: array - path: - type: string - type: - type: string + deferrals: + format: int64 + type: integer + digest_path: + type: + - string + - "null" + errors: + format: int64 + type: integer + frustrations: + format: int64 + type: integer + interruptions: + format: int64 + type: integer + p0_alerts: + additionalProperties: + items: + type: string + type: array + type: object + patterns: + format: int64 + type: integer + personas: + additionalProperties: + $ref: "#/components/schemas/FrictionPersonaSummary" + type: object + schema_version: + format: int64 + type: integer + sessions_scanned: + format: int64 + type: integer + spend: + $ref: "#/components/schemas/FrictionSpendSummary" + tracker_failures: + format: int64 + type: integer + workarounds: + format: int64 + type: integer required: - - path - - type - - length - - objects + - schema_version + - sessions_scanned + - tracker_failures + - corrections + - errors + - workarounds + - deferrals + - patterns + - frustrations + - interruptions + - p0_alerts + - personas + - spend + - digest_path + - created_issues type: object - RawsyncManifest: + GenerateInsightRequest: additionalProperties: false properties: - capture_id: + agent: type: string - captured_at: - format: date-time + automated_scope: type: string - configured_root_id: + date_from: type: string - entries: - items: - $ref: "#/components/schemas/RawsyncEntry" - type: array - expected_parent_receipt: + date_to: type: string + filters: + $ref: "#/components/schemas/CannedSessionFiltersInput" + force_refresh: + type: boolean kind: type: string - provider: + llm_opt_in: + type: boolean + project: type: string - schema_version: - format: int64 - type: integer - source_key: + prompt: type: string - required: - - schema_version - - provider - - configured_root_id - - source_key - - capture_id - - captured_at - - kind - type: object - RawsyncObjectRef: - additionalProperties: false - properties: - length: - format: int64 - type: integer - sha256: + session_id: + type: string + timezone: + type: string + type: type: string required: - - sha256 - - length + - type + - date_from + - date_to type: object - RawsyncOpenUploadStatus: + GithubConfigResponse: additionalProperties: false properties: - created_at: - format: date-time - type: string - upload_id: - type: string + configured: + type: boolean required: - - upload_id - - created_at - type: - - object - - "null" - RawsyncParseJobCounts: + - configured + type: object + ImporterImportStats: additionalProperties: false properties: - complete: - format: int64 - type: integer - failed: - format: int64 - type: integer - leased: + errors: format: int64 type: integer - ready: + imported: format: int64 type: integer - retrying: + skipped: format: int64 type: integer - superseded: + updated: format: int64 type: integer required: - - ready - - leased - - retrying - - complete - - failed - - superseded + - imported + - updated + - skipped + - errors type: object - RawsyncSourceHeadStatus: + InsightsResponse: additionalProperties: false properties: - configured_root_id: - type: string - device_id: - type: string - generation: - format: int64 - type: integer - last_accepted_at: - format: date-time - type: - - string - - "null" - parse_failed: - type: boolean - parse_leased: - type: boolean - parse_pending: - type: boolean - provider: - type: string - source_key: - type: string + insights: + items: + $ref: "#/components/schemas/DbInsight" + type: array required: - - device_id - - configured_root_id - - provider - - source_key - - generation - - last_accepted_at - - parse_pending - - parse_leased - - parse_failed + - insights type: object - RawsyncStatus: + MachinesResponse: additionalProperties: false properties: - active_device_count: - format: int64 - type: integer - devices: - items: - $ref: "#/components/schemas/RawsyncDeviceStatus" - type: array - parse_jobs: - $ref: "#/components/schemas/RawsyncParseJobCounts" - source_heads: + machine_aliases: + additionalProperties: + type: string + type: object + machine_labels: + additionalProperties: + type: string + type: object + machines: items: - $ref: "#/components/schemas/RawsyncSourceHeadStatus" + type: string type: array - uploads: - $ref: "#/components/schemas/RawsyncUploadStatusSummary" required: - - source_heads - - parse_jobs - - active_device_count - - devices - - uploads + - machines + - machine_labels + - machine_aliases type: object - RawsyncUploadStatusSummary: + ModelTotal: additionalProperties: false properties: - oldest_open_session: - $ref: "#/components/schemas/RawsyncOpenUploadStatus" - open_count: + cacheCreationTokens: format: int64 type: integer - pending_bytes: + cacheReadTokens: + format: int64 + type: integer + cost: + $ref: "#/components/schemas/MoneyMoney" + inputTokens: + format: int64 + type: integer + model: + type: string + outputTokens: format: int64 type: integer required: - - open_count - - pending_bytes - - oldest_open_session + - model + - inputTokens + - outputTokens + - cacheCreationTokens + - cacheReadTokens + - cost type: object - RecallEntriesResponse: + MoneyMoney: additionalProperties: false properties: - entries: - items: - $ref: "#/components/schemas/DbRecallResult" - type: array - next_cursor: - type: string - result_cap: + microdollars: format: int64 type: integer - trusted_only: - type: boolean required: - - entries - - trusted_only - - next_cursor + - microdollars type: object - RecallExtractGenerationStatus: + OpenRequest: additionalProperties: false properties: - created_at: - type: string - fingerprint: - type: string - model: - type: string - segmenter: - type: string - state: - type: string - updated_at: + opener_id: type: string required: - - fingerprint - - state - - model - - segmenter - - created_at - - updated_at + - opener_id type: object - RecallExtractProgressItem: + OpenSessionResponse: additionalProperties: false properties: - agent: - type: string - generation_fingerprint: - type: string - last_error: - type: string - project: + launched: + type: boolean + opener: type: string - retry_at: + path: type: string - retry_eligible: - type: boolean - session_id: + required: + - launched + - opener + - path + type: object + Opener: + additionalProperties: false + properties: + bin: type: string - session_title: + id: type: string - state: - enum: - - pending - - partial - - failed + kind: type: string - unit_cursor: - format: int64 - type: integer - units_total: - format: int64 - type: integer - updated_at: + name: type: string required: - - session_id - - generation_fingerprint - - state - - unit_cursor - - units_total - - updated_at - - session_title - - project - - agent + - id + - name + - kind + - bin type: object - RecallExtractProgressResponse: + OpenersResponse: additionalProperties: false properties: - generation_fingerprint: - type: string - next_cursor: - type: string - progress: + openers: items: - $ref: "#/components/schemas/RecallExtractProgressItem" + $ref: "#/components/schemas/Opener" type: array required: - - progress + - openers type: object - RecallExtractionStatusResponse: + OrdinalsResponse: additionalProperties: false properties: - configured: - type: boolean - eligible_backlog: - format: int64 - type: integer - fingerprint: - type: string - generations: - items: - $ref: "#/components/schemas/RecallExtractGenerationStatus" - type: array - management_available: - type: boolean - progress_available: - type: boolean - source_runs: + ordinals: items: - type: string + format: int64 + type: integer type: array - stats: - $ref: "#/components/schemas/DbExtractProgressStats" required: - - configured - - management_available - - progress_available + - ordinals type: object - RecallScoreBreakdown: + PinMessageResponse: additionalProperties: false properties: - base_score: - format: double - type: number - confidence_bonus: - format: double - type: number - entity_boost: - format: double - type: number - evidence_idf_score: - format: double - type: number - evidence_keyword_overlap: - format: int64 - type: integer - identifier_boost: - format: double - type: number - keyword_idf_score: - format: double - type: number - keyword_overlap: + id: format: int64 type: integer - phrase_boost: - format: double - type: number - temporal_boost: - format: double - type: number - total: - format: double - type: number required: - - keyword_overlap - - keyword_idf_score - - evidence_keyword_overlap - - evidence_idf_score - - identifier_boost - - phrase_boost - - entity_boost - - temporal_boost - - confidence_bonus - - base_score - - total + - id type: object - RemoteSyncFailure: + PinRequest: additionalProperties: false properties: - error: + note: type: string - host: - $ref: "#/components/schemas/ConfigRemoteHost" - required: - - host - - error type: object - RemoteSyncRequest: + PingInfo: additionalProperties: false properties: - full: + healthy: type: boolean - hosts: - items: - $ref: "#/components/schemas/ConfigRemoteHost" - type: array - include_local: + ok: type: boolean - required: - - full - - include_local - - hosts - type: object - RemoteSyncResponse: - additionalProperties: false - properties: - error: + pid: + format: int64 + type: integer + service: type: string - error_code: + sync: + $ref: "#/components/schemas/SyncProgress" + version: type: string - failures: - items: - $ref: "#/components/schemas/RemoteSyncFailure" - type: array - local_stats: - $ref: "#/components/schemas/SyncSyncStats" - type: object - RemotesyncArchiveRequest: - additionalProperties: false - properties: - codex_index_files: - additionalProperties: - items: - type: string - type: array - type: object - delta_files: - items: - type: string - type: array - x-go-type: "*[]string" - dirs: - additionalProperties: - items: - type: string - type: array - type: object - extra_files: - items: - type: string - type: array - files: - additionalProperties: - items: - type: string - type: array - type: object - forbidden_roots: - items: - type: string - type: array - provider_extra_files: - additionalProperties: - items: - type: string - type: array - type: object required: - - dirs + - ok + - healthy type: object - RemotesyncManifest: + PinsResponse: additionalProperties: false properties: - files: + pins: items: - $ref: "#/components/schemas/RemotesyncManifestEntry" + $ref: "#/components/schemas/DbPinnedMessage" type: array required: - - files + - pins type: object - RemotesyncManifestEntry: + ProjectTotal: additionalProperties: false properties: - mtime_ns: + cacheCreationTokens: format: int64 type: integer - path: - type: string - size: + cacheReadTokens: format: int64 type: integer + cost: + $ref: "#/components/schemas/MoneyMoney" + inputTokens: + format: int64 + type: integer + outputTokens: + format: int64 + type: integer + project: + type: string + project_key: + type: string required: - - path - - size - - mtime_ns + - project_key + - project + - inputTokens + - outputTokens + - cacheCreationTokens + - cacheReadTokens + - cost type: object - RemotesyncTargetSet: + ProjectsResponse: additionalProperties: false properties: - codex_index_files: - additionalProperties: - items: - type: string - type: array - type: object - dirs: - additionalProperties: - items: - type: string - type: array - type: object - extra_files: + projects: items: - type: string + $ref: "#/components/schemas/DbProjectInfo" type: array - files: - additionalProperties: - items: - type: string - type: array - type: object - forbidden_roots: + required: + - projects + type: object + PublishResponse: + additionalProperties: false + properties: + gist_id: + type: string + gist_url: + type: string + raw_url: + type: string + view_url: + type: string + required: + - gist_id + - gist_url + - view_url + - raw_url + type: object + RawSyncManifestResponse: + additionalProperties: false + properties: + created: + type: boolean + generation: + format: int64 + type: integer + manifest_id: + type: string + receipt: + type: string + required: + - manifest_id + - receipt + - generation + - created + type: object + RawSyncMissingObjectsInputBody: + additionalProperties: false + properties: + objects: items: - type: string + $ref: "#/components/schemas/RawsyncObjectRef" type: array - provider_extra_files: - additionalProperties: - items: - type: string - type: array - type: object + provider: + type: string required: - - dirs + - provider + - objects type: object - RenameRequest: + RawSyncMissingObjectsResponse: additionalProperties: false properties: - display_name: - type: - - string - - "null" + missing: + items: + $ref: "#/components/schemas/RawsyncObjectRef" + type: array required: - - display_name + - missing type: object - ResolveSessionIDsResponse: + RawSyncTokenInputBody: additionalProperties: false properties: - ids: + scopes: items: type: string type: array - raw_suffix: - type: boolean required: - - ids + - scopes type: object - ResumeRequest: + RawSyncTokenResponse: additionalProperties: false properties: - command_only: + device_id: + type: string + expires_at: + format: date-time + type: string + scopes: + items: + type: string + type: array + token: + type: string + required: + - token + - device_id + - scopes + - expires_at + type: object + RawSyncUploadResponse: + additionalProperties: false + properties: + complete: type: boolean - fork_session: + created: type: boolean - from_ordinal: + expires_at: + format: date-time + type: string + object: + $ref: "#/components/schemas/RawsyncObjectRef" + offset: format: int64 type: integer - opener_id: + upload_id: type: string - skip_permissions: - type: boolean + required: + - object + - offset + - complete + - created type: object - ResumeResponse: + RawSyncUploadStartInputBody: additionalProperties: false properties: - command: - type: string - cwd: - type: string - error: + object: + $ref: "#/components/schemas/RawsyncObjectRef" + provider: type: string - launched: - type: boolean - terminal: + required: + - provider + - object + type: object + RawsyncDeviceStatus: + additionalProperties: false + properties: + device_id: type: string + last_seen_at: + format: date-time + type: + - string + - "null" required: - - launched - - command + - device_id + - last_seen_at type: object - SearchResponse: + RawsyncEntry: additionalProperties: false properties: - count: + length: format: int64 type: integer - next: + mod_time_ns: format: int64 type: integer - query: - type: string - results: + objects: items: - $ref: "#/components/schemas/DbSearchResult" + $ref: "#/components/schemas/RawsyncObjectRef" type: array + path: + type: string + type: + type: string required: - - query - - results - - count - - next + - path + - type + - length + - objects type: object - ServiceContentSearchResult: + RawsyncManifest: additionalProperties: false properties: - matches: + capture_id: + type: string + captured_at: + format: date-time + type: string + configured_root_id: + type: string + entries: items: - $ref: "#/components/schemas/DbContentMatch" + $ref: "#/components/schemas/RawsyncEntry" type: array - next_cursor: + expected_parent_receipt: + type: string + kind: + type: string + provider: + type: string + schema_version: format: int64 type: integer + source_key: + type: string required: - - matches + - schema_version + - provider + - configured_root_id + - source_key + - capture_id + - captured_at + - kind type: object - ServiceMessageList: + RawsyncObjectRef: additionalProperties: false properties: - count: - format: int64 - type: integer - first_ordinal: + length: format: int64 type: integer - last_ordinal: + sha256: + type: string + required: + - sha256 + - length + type: object + RawsyncOpenUploadStatus: + additionalProperties: false + properties: + created_at: + format: date-time + type: string + upload_id: + type: string + required: + - upload_id + - created_at + type: + - object + - "null" + RawsyncParseJobCounts: + additionalProperties: false + properties: + complete: + format: int64 + type: integer + failed: + format: int64 + type: integer + leased: + format: int64 + type: integer + ready: + format: int64 + type: integer + retrying: + format: int64 + type: integer + superseded: format: int64 type: integer - messages: - items: - $ref: "#/components/schemas/DbMessage" - type: array required: - - messages - - count + - ready + - leased + - retrying + - complete + - failed + - superseded type: object - ServiceRecallContextMeta: + RawsyncSourceHeadStatus: additionalProperties: false properties: - entry_count: + configured_root_id: + type: string + device_id: + type: string + generation: format: int64 type: integer - included_ids: - items: - type: string - type: array - included_match_reasons_by_id: - additionalProperties: - items: - type: string - type: array - type: object - included_types_by_id: - additionalProperties: - type: string - type: object - omitted_count: + last_accepted_at: + format: date-time + type: + - string + - "null" + parse_failed: + type: boolean + parse_leased: + type: boolean + parse_pending: + type: boolean + provider: + type: string + source_key: + type: string + required: + - device_id + - configured_root_id + - provider + - source_key + - generation + - last_accepted_at + - parse_pending + - parse_leased + - parse_failed + type: object + RawsyncStatus: + additionalProperties: false + properties: + active_device_count: format: int64 type: integer - prompt_injection_context: - type: boolean - prompt_injection_context_ids: - items: - type: string - type: array - prompt_injection_context_reasons: - items: - type: string - type: array - prompt_injection_context_reasons_by_id: - additionalProperties: - items: - type: string - type: array - type: object - source_episode_ids: - items: - type: string - type: array - source_run_ids: + devices: items: - type: string + $ref: "#/components/schemas/RawsyncDeviceStatus" type: array - source_session_ids: + parse_jobs: + $ref: "#/components/schemas/RawsyncParseJobCounts" + source_heads: items: - type: string + $ref: "#/components/schemas/RawsyncSourceHeadStatus" type: array - truncated: - type: boolean - truncated_from: + uploads: + $ref: "#/components/schemas/RawsyncUploadStatusSummary" + required: + - source_heads + - parse_jobs + - active_device_count + - devices + - uploads + type: object + RawsyncUploadStatusSummary: + additionalProperties: false + properties: + oldest_open_session: + $ref: "#/components/schemas/RawsyncOpenUploadStatus" + open_count: + format: int64 + type: integer + pending_bytes: format: int64 type: integer required: - - entry_count - - truncated + - open_count + - pending_bytes + - oldest_open_session type: object - ServiceRecallQuery: + RecallEntriesResponse: additionalProperties: false properties: - agent: + entries: + items: + $ref: "#/components/schemas/DbRecallResult" + type: array + next_cursor: type: string - context_max_bytes: + result_cap: format: int64 type: integer - cwd: + trusted_only: + type: boolean + required: + - entries + - trusted_only + - next_cursor + type: object + RecallExtractGenerationStatus: + additionalProperties: false + properties: + created_at: type: string - extractor_method: + fingerprint: type: string - git_branch: + model: type: string - include_context: - type: boolean - limit: - format: int64 - type: integer - mode: + segmenter: type: string - project: + state: type: string - query: + updated_at: type: string - scope: + required: + - fingerprint + - state + - model + - segmenter + - created_at + - updated_at + type: object + RecallExtractProgressItem: + additionalProperties: false + properties: + agent: type: string - skip_recording: - type: boolean - source_episode_id: + generation_fingerprint: type: string - source_run_id: + last_error: type: string - source_session_id: + project: type: string - status: + retry_at: type: string - superseded_by_entry_id: + retry_eligible: + type: boolean + session_id: type: string - supersedes_entry_id: + session_title: type: string - surface: + state: + enum: + - pending + - partial + - failed type: string - trusted_only: - type: boolean - type: + unit_cursor: + format: int64 + type: integer + units_total: + format: int64 + type: integer + updated_at: type: string required: - - query + - session_id + - generation_fingerprint + - state + - unit_cursor + - units_total + - updated_at + - session_title + - project + - agent type: object - ServiceRecallQueryResult: + RecallExtractProgressResponse: additionalProperties: false properties: - context: + generation_fingerprint: type: string - context_entries: + next_cursor: + type: string + progress: items: - $ref: "#/components/schemas/DbRecallResult" + $ref: "#/components/schemas/RecallExtractProgressItem" type: array - context_meta: - $ref: "#/components/schemas/ServiceRecallContextMeta" - context_summary: - $ref: "#/components/schemas/ServiceRecallQuerySummary" - entries: + required: + - progress + type: object + RecallExtractionStatusResponse: + additionalProperties: false + properties: + configured: + type: boolean + eligible_backlog: + format: int64 + type: integer + fingerprint: + type: string + generations: items: - $ref: "#/components/schemas/DbRecallResult" + $ref: "#/components/schemas/RecallExtractGenerationStatus" type: array - miss_reason: - type: string - mode: - type: string - query_id: + management_available: + type: boolean + progress_available: + type: boolean + source_runs: + items: + type: string + type: array + stats: + $ref: "#/components/schemas/DbExtractProgressStats" + required: + - configured + - management_available + - progress_available + type: object + RecallScoreBreakdown: + additionalProperties: false + properties: + base_score: + format: double + type: number + confidence_bonus: + format: double + type: number + entity_boost: + format: double + type: number + evidence_idf_score: + format: double + type: number + evidence_keyword_overlap: + format: int64 + type: integer + identifier_boost: + format: double + type: number + keyword_idf_score: + format: double + type: number + keyword_overlap: + format: int64 + type: integer + phrase_boost: + format: double + type: number + temporal_boost: + format: double + type: number + total: + format: double + type: number + required: + - keyword_overlap + - keyword_idf_score + - evidence_keyword_overlap + - evidence_idf_score + - identifier_boost + - phrase_boost + - entity_boost + - temporal_boost + - confidence_bonus + - base_score + - total + type: object + RemoteSyncFailure: + additionalProperties: false + properties: + error: type: string - summary: - $ref: "#/components/schemas/ServiceRecallQuerySummary" - trusted_only: + host: + $ref: "#/components/schemas/ConfigRemoteHost" + required: + - host + - error + type: object + RemoteSyncRequest: + additionalProperties: false + properties: + full: + type: boolean + hosts: + items: + $ref: "#/components/schemas/ConfigRemoteHost" + type: array + include_local: type: boolean required: - - mode - - query_id - - miss_reason - - entries - - trusted_only + - full + - include_local + - hosts type: object - ServiceRecallQuerySummary: + RemoteSyncResponse: additionalProperties: false properties: - by_agent: + error: + type: string + error_code: + type: string + failures: + items: + $ref: "#/components/schemas/RemoteSyncFailure" + type: array + local_stats: + $ref: "#/components/schemas/SyncSyncStats" + type: object + RemotesyncArchiveRequest: + additionalProperties: false + properties: + codex_index_files: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_cwd: + delta_files: + items: + type: string + type: array + x-go-type: "*[]string" + dirs: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_evidence: + extra_files: + items: + type: string + type: array + files: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_extractor: + forbidden_roots: + items: + type: string + type: array + provider_extra_files: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_git_branch: + required: + - dirs + type: object + RemotesyncManifest: + additionalProperties: false + properties: + files: + items: + $ref: "#/components/schemas/RemotesyncManifestEntry" + type: array + required: + - files + type: object + RemotesyncManifestEntry: + additionalProperties: false + properties: + mtime_ns: + format: int64 + type: integer + path: + type: string + size: + format: int64 + type: integer + required: + - path + - size + - mtime_ns + type: object + RemotesyncTargetSet: + additionalProperties: false + properties: + codex_index_files: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_lifecycle: + dirs: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_match_reason: + extra_files: + items: + type: string + type: array + files: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_model: + forbidden_roots: + items: + type: string + type: array + provider_extra_files: additionalProperties: - format: int64 - type: integer + items: + type: string + type: array type: object - by_project: - additionalProperties: - format: int64 - type: integer - type: object - by_provenance_audit: - additionalProperties: - format: int64 - type: integer - type: object - by_scope: - additionalProperties: - format: int64 - type: integer - type: object - by_source_episode: - additionalProperties: - format: int64 - type: integer - type: object - by_source_run: - additionalProperties: - format: int64 - type: integer - type: object - by_source_session: - additionalProperties: - format: int64 - type: integer - type: object - by_status: - additionalProperties: - format: int64 - type: integer - type: object - by_transferability: - additionalProperties: - format: int64 - type: integer - type: object - by_type: - additionalProperties: - format: int64 - type: integer - type: object - count: - format: int64 - type: integer required: - - count - - by_type - - by_scope - - by_status - - by_project - - by_agent - - by_cwd - - by_git_branch - - by_match_reason - - by_extractor - - by_model - - by_source_run - - by_source_session - - by_source_episode - - by_transferability - - by_provenance_audit - - by_evidence - - by_lifecycle + - dirs type: object - ServiceSecretFindingList: + RenameRequest: additionalProperties: false properties: - findings: + display_name: + type: + - string + - "null" + required: + - display_name + type: object + ResolveSessionIDsResponse: + additionalProperties: false + properties: + ids: items: - $ref: "#/components/schemas/DbSecretFindingRow" + type: string type: array - next_cursor: - format: int64 - type: integer + raw_suffix: + type: boolean required: - - findings + - ids type: object - ServiceSessionDetail: + ResumeRequest: additionalProperties: false properties: - agent: - type: string - agent_label: - type: string - compaction_count: - format: int64 - type: integer - consecutive_failure_max: + command_only: + type: boolean + fork_session: + type: boolean + from_ordinal: format: int64 type: integer - context_pressure_max: - format: double - type: number - created_at: + opener_id: type: string - cwd: + skip_permissions: + type: boolean + type: object + ResumeResponse: + additionalProperties: false + properties: + command: type: string - decode_confidence: + cwd: type: string - deleted_at: + error: type: string - display_name: + launched: + type: boolean + terminal: type: string - edit_churn_count: + required: + - launched + - command + type: object + SearchResponse: + additionalProperties: false + properties: + count: format: int64 type: integer - ended_at: - type: - - string - - "null" - ended_with_role: - type: string - entrypoint: - type: string - file_device: + next: format: int64 type: integer - file_hash: + query: type: string - file_inode: + results: + items: + $ref: "#/components/schemas/DbSearchResult" + type: array + required: + - query + - results + - count + - next + type: object + ServiceContentSearchResult: + additionalProperties: false + properties: + matches: + items: + $ref: "#/components/schemas/DbContentMatch" + type: array + next_cursor: format: int64 type: integer - file_mtime: + required: + - matches + type: object + ServiceMessageList: + additionalProperties: false + properties: + count: format: int64 type: integer - file_path: - type: string - file_size: + first_ordinal: format: int64 type: integer - final_failure_streak: + last_ordinal: format: int64 type: integer - first_message: - type: - - string - - "null" - git_branch: - type: string - has_peak_context_tokens: - type: boolean - has_total_output_tokens: - type: boolean - health_grade: - type: string - health_penalties: - additionalProperties: - format: int64 - type: integer - type: object - health_score: + messages: + items: + $ref: "#/components/schemas/DbMessage" + type: array + required: + - messages + - count + type: object + ServiceRecallContextMeta: + additionalProperties: false + properties: + entry_count: format: int64 type: integer - health_score_basis: + included_ids: items: type: string type: array - id: - type: string - is_automated: - type: boolean - is_truncated: - type: boolean - local_modified_at: - type: string - machine: - type: string - message_count: - format: int64 - type: integer - mid_task_compaction_count: - format: int64 - type: integer - outcome: - type: string - outcome_confidence: - type: string - parent_session_id: - type: string - parser_malformed_lines: - format: int64 - type: integer - peak_context_tokens: + included_match_reasons_by_id: + additionalProperties: + items: + type: string + type: array + type: object + included_types_by_id: + additionalProperties: + type: string + type: object + omitted_count: format: int64 type: integer - project: - type: string - project_assigned: + prompt_injection_context: type: boolean - quality_signals: - $ref: "#/components/schemas/DbQualitySignals" - relationship_type: - type: string - secret_leak_count: - format: int64 - type: integer - session_kind: - type: string - signals_pending_since: - type: string - source_session_id: - type: string - source_version: - type: string - started_at: - type: - - string - - "null" - termination_status: - type: string - tool_failure_signal_count: - format: int64 - type: integer - tool_retry_count: + prompt_injection_context_ids: + items: + type: string + type: array + prompt_injection_context_reasons: + items: + type: string + type: array + prompt_injection_context_reasons_by_id: + additionalProperties: + items: + type: string + type: array + type: object + source_episode_ids: + items: + type: string + type: array + source_run_ids: + items: + type: string + type: array + source_session_ids: + items: + type: string + type: array + truncated: + type: boolean + truncated_from: format: int64 type: integer - total_output_tokens: + required: + - entry_count + - truncated + type: object + ServiceRecallQuery: + additionalProperties: false + properties: + agent: + type: string + context_max_bytes: format: int64 type: integer - transcript_fidelity: + cwd: type: string - transcript_revision: + extractor_method: type: string - user_message_count: + git_branch: + type: string + include_context: + type: boolean + limit: format: int64 type: integer - web_url: + mode: type: string - required: - - id - - project - - machine - - agent - - first_message - - started_at - - ended_at - - message_count - - user_message_count - - total_output_tokens - - peak_context_tokens - - has_total_output_tokens - - has_peak_context_tokens - - is_automated - - tool_failure_signal_count - - tool_retry_count - - edit_churn_count - - consecutive_failure_max - - outcome - - outcome_confidence - - ended_with_role - - final_failure_streak - - compaction_count - - mid_task_compaction_count - - secret_leak_count - - created_at - type: object - ServiceSessionList: - additionalProperties: false - properties: - next_cursor: + project: type: string - sessions: - items: - $ref: "#/components/schemas/DbSession" - type: array - total: - format: int64 - type: integer - required: - - sessions - - total - type: object - ServiceSyncInput: - additionalProperties: false - properties: - id: + query: type: string - path: + scope: type: string - subagents: + skip_recording: type: boolean - type: object - ServiceToolCall: - additionalProperties: false - properties: - category: + source_episode_id: type: string - input_json: + source_run_id: type: string - ordinal: - format: int64 - type: integer - result_length: - format: int64 - type: integer - skill_name: + source_session_id: type: string - subagent_session_id: + status: type: string - timestamp: + superseded_by_entry_id: type: string - tool_name: + supersedes_entry_id: type: string - tool_use_id: + surface: + type: string + trusted_only: + type: boolean + type: type: string required: - - ordinal - - timestamp - - tool_use_id - - tool_name - - category - - input_json - - result_length + - query type: object - ServiceToolCallList: + ServiceRecallQueryResult: additionalProperties: false properties: - count: - format: int64 - type: integer - tool_calls: + context: + type: string + context_entries: items: - $ref: "#/components/schemas/ServiceToolCall" + $ref: "#/components/schemas/DbRecallResult" + type: array + context_meta: + $ref: "#/components/schemas/ServiceRecallContextMeta" + context_summary: + $ref: "#/components/schemas/ServiceRecallQuerySummary" + entries: + items: + $ref: "#/components/schemas/DbRecallResult" type: array + miss_reason: + type: string + mode: + type: string + query_id: + type: string + summary: + $ref: "#/components/schemas/ServiceRecallQuerySummary" + trusted_only: + type: boolean required: - - tool_calls - - count + - mode + - query_id + - miss_reason + - entries + - trusted_only type: object - ServiceUsagePairwiseComparisonDelta: + ServiceRecallQuerySummary: additionalProperties: false properties: - cacheCreationDelta: - format: int64 - type: integer - cacheCreationDeltaRatio: - format: double - type: - - number - - "null" - cacheReadDelta: - format: int64 - type: integer - cacheReadDeltaRatio: - format: double - type: - - number - - "null" - costPerSessionDelta: - anyOf: - - $ref: "#/components/schemas/MoneyMoney" - - type: "null" - costPerSessionRatio: - format: double - type: - - number - - "null" - inputTokensDelta: - format: int64 - type: integer - inputTokensDeltaRatio: - format: double - type: - - number - - "null" - outputTokensDelta: - format: int64 - type: integer - outputTokensDeltaRatio: - format: double - type: - - number - - "null" - sessionCountDelta: - format: int64 - type: integer - sessionCountDeltaRatio: - format: double - type: - - number - - "null" - tokensPerSessionDelta: - format: double - type: - - number - - "null" - tokensPerSessionRatio: - format: double - type: - - number - - "null" - totalCostDelta: - $ref: "#/components/schemas/MoneyMoney" - totalCostDeltaRatio: - format: double - type: - - number - - "null" - totalTokensDelta: + by_agent: + additionalProperties: + format: int64 + type: integer + type: object + by_cwd: + additionalProperties: + format: int64 + type: integer + type: object + by_evidence: + additionalProperties: + format: int64 + type: integer + type: object + by_extractor: + additionalProperties: + format: int64 + type: integer + type: object + by_git_branch: + additionalProperties: + format: int64 + type: integer + type: object + by_lifecycle: + additionalProperties: + format: int64 + type: integer + type: object + by_match_reason: + additionalProperties: + format: int64 + type: integer + type: object + by_model: + additionalProperties: + format: int64 + type: integer + type: object + by_project: + additionalProperties: + format: int64 + type: integer + type: object + by_provenance_audit: + additionalProperties: + format: int64 + type: integer + type: object + by_scope: + additionalProperties: + format: int64 + type: integer + type: object + by_source_episode: + additionalProperties: + format: int64 + type: integer + type: object + by_source_run: + additionalProperties: + format: int64 + type: integer + type: object + by_source_session: + additionalProperties: + format: int64 + type: integer + type: object + by_status: + additionalProperties: + format: int64 + type: integer + type: object + by_transferability: + additionalProperties: + format: int64 + type: integer + type: object + by_type: + additionalProperties: + format: int64 + type: integer + type: object + count: format: int64 type: integer - totalTokensDeltaRatio: - format: double - type: - - number - - "null" required: - - totalCostDelta - - totalCostDeltaRatio - - inputTokensDelta - - inputTokensDeltaRatio - - outputTokensDelta - - outputTokensDeltaRatio - - cacheCreationDelta - - cacheCreationDeltaRatio - - cacheReadDelta - - cacheReadDeltaRatio - - totalTokensDelta - - totalTokensDeltaRatio - - sessionCountDelta - - sessionCountDeltaRatio - - costPerSessionDelta - - costPerSessionRatio - - tokensPerSessionDelta - - tokensPerSessionRatio + - count + - by_type + - by_scope + - by_status + - by_project + - by_agent + - by_cwd + - by_git_branch + - by_match_reason + - by_extractor + - by_model + - by_source_run + - by_source_session + - by_source_episode + - by_transferability + - by_provenance_audit + - by_evidence + - by_lifecycle type: object - ServiceUsagePairwiseComparisonResponse: + ServiceSecretFindingList: additionalProperties: false properties: - deltas: - $ref: "#/components/schemas/ServiceUsagePairwiseComparisonDelta" - left: - $ref: "#/components/schemas/ServiceUsagePairwiseComparisonSide" - right: - $ref: "#/components/schemas/ServiceUsagePairwiseComparisonSide" + findings: + items: + $ref: "#/components/schemas/DbSecretFindingRow" + type: array + next_cursor: + format: int64 + type: integer required: - - left - - right - - deltas + - findings type: object - ServiceUsagePairwiseComparisonSide: + ServiceSessionDetail: additionalProperties: false properties: - cacheCreationTokens: - format: int64 - type: integer - cacheReadTokens: - format: int64 - type: integer - costPerSession: - $ref: "#/components/schemas/MoneyMoney" - inputTokens: - format: int64 - type: integer - outputTokens: + agent: + type: string + agent_label: + type: string + compaction_count: format: int64 type: integer - sessionCount: + consecutive_failure_max: format: int64 type: integer - tokensPerSession: + context_pressure_max: format: double type: number - totalCost: - $ref: "#/components/schemas/MoneyMoney" - totalTokens: - format: int64 - type: integer - required: - - totalCost - - inputTokens - - outputTokens - - cacheCreationTokens - - cacheReadTokens - - totalTokens - - sessionCount - type: object - SessionDirectoryResponse: - additionalProperties: false - properties: - path: + created_at: type: string - required: - - path - type: object - SessionProjectAssignmentRequest: - additionalProperties: false - properties: - project: + cwd: + type: string + decode_confidence: + type: string + deleted_at: type: string - required: - - project - type: object - SessionProviderResponse: - additionalProperties: false - properties: - dirs: - items: - type: string - type: array display_name: type: string - homes: - items: - type: string - type: array - homes_supported: - type: boolean - id: + edit_churn_count: + format: int64 + type: integer + ended_at: + type: + - string + - "null" + ended_with_role: type: string - post_answer_tool_work: - type: boolean - required: - - id - - display_name - - dirs - - homes_supported - - homes - type: object - SessionUsageBreakdownResponse: - additionalProperties: false - properties: - cache_creation_input_tokens: + entrypoint: + type: string + file_device: format: int64 type: integer - cache_read_input_tokens: + file_hash: + type: string + file_inode: format: int64 type: integer - cost: - $ref: "#/components/schemas/MoneyMoney" - has_cost: - type: boolean - input_tokens: + file_mtime: format: int64 type: integer - label: + file_path: type: string - message_ordinal: + file_size: format: int64 type: integer - model: - type: string - ordinal: + final_failure_streak: format: int64 type: integer - output_tokens: + first_message: + type: + - string + - "null" + git_branch: + type: string + has_peak_context_tokens: + type: boolean + has_total_output_tokens: + type: boolean + health_grade: + type: string + health_penalties: + additionalProperties: + format: int64 + type: integer + type: object + health_score: format: int64 type: integer - source: + health_score_basis: + items: + type: string + type: array + id: type: string - subagent_session_id: + is_automated: + type: boolean + is_truncated: + type: boolean + local_modified_at: type: string - timestamp: + machine: type: string - web_search_requests: + message_count: format: int64 type: integer - required: - - ordinal - - source - - label - - timestamp - - model - - input_tokens - - output_tokens - - cache_creation_input_tokens - - cache_read_input_tokens - - cost - - has_cost - type: object - SessionUsageResponse: - additionalProperties: false - properties: - agent: - type: string - ai_credits: - format: double - type: number - breakdown: - items: - $ref: "#/components/schemas/SessionUsageBreakdownResponse" - type: array - breakdown_count: + mid_task_compaction_count: format: int64 type: integer - cost: - $ref: "#/components/schemas/MoneyMoney" - cost_source: + outcome: type: string - cost_usd: - format: double - type: number - has_cost: - type: boolean - has_rollup_cost: - type: boolean - has_token_data: - type: boolean - models: - items: - type: string - type: array + outcome_confidence: + type: string + parent_session_id: + type: string + parser_malformed_lines: + format: int64 + type: integer peak_context_tokens: format: int64 type: integer project: type: string - rollup_cost: - $ref: "#/components/schemas/MoneyMoney" - rollup_cost_source: + project_assigned: + type: boolean + quality_signals: + $ref: "#/components/schemas/DbQualitySignals" + relationship_type: type: string - rollup_subagent_count: + secret_leak_count: format: int64 type: integer - server_running: - type: boolean - session_id: + session_kind: type: string - subagent_count: + signals_pending_since: + type: string + source_session_id: + type: string + source_version: + type: string + started_at: + type: + - string + - "null" + termination_status: + type: string + tool_failure_signal_count: + format: int64 + type: integer + tool_retry_count: format: int64 type: integer total_output_tokens: format: int64 type: integer - unpriced_models: - items: - type: string - type: array + transcript_fidelity: + type: string + transcript_revision: + type: string + user_message_count: + format: int64 + type: integer + web_url: + type: string required: - - session_id - - agent + - id - project + - machine + - agent + - first_message + - started_at + - ended_at + - message_count + - user_message_count - total_output_tokens - peak_context_tokens - - has_token_data - - cost - - has_cost - - models - - unpriced_models - - breakdown_count - - breakdown - - server_running + - has_total_output_tokens + - has_peak_context_tokens + - is_automated + - tool_failure_signal_count + - tool_retry_count + - edit_churn_count + - consecutive_failure_max + - outcome + - outcome_confidence + - ended_with_role + - final_failure_streak + - compaction_count + - mid_task_compaction_count + - secret_leak_count + - created_at type: object - SetGithubConfigInputBody: + ServiceSessionList: additionalProperties: false properties: - token: - description: GitHub token - minLength: 1 + next_cursor: type: string + sessions: + items: + $ref: "#/components/schemas/DbSession" + type: array + total: + format: int64 + type: integer required: - - token + - sessions + - total type: object - SetGithubConfigResponse: + ServiceSyncInput: additionalProperties: false properties: - success: - type: boolean - username: + id: type: string - required: - - success - - username + path: + type: string + subagents: + type: boolean type: object - SettingsResponse: + ServiceToolCall: additionalProperties: false properties: - agent_dirs: - additionalProperties: - items: - type: string - type: array - type: object - auth_token: - type: string - chart_palette: + category: type: string - disabled_agents: - items: - type: string - type: array - github_configured: - type: boolean - host: + input_json: type: string - port: + ordinal: format: int64 type: integer - read_only: - type: boolean - require_auth: - type: boolean - session_providers: - items: - $ref: "#/components/schemas/SessionProviderResponse" - type: array - terminal: - $ref: "#/components/schemas/TerminalResponse" - tool_result_images: - description: Inline tool-result image retention applied to ingestion after a daemon restart - enum: - - keep - - drop - - offload + result_length: + format: int64 + type: integer + skill_name: type: string - zoom_level: - enum: - - 67 - - 75 - - 80 - - 90 - - 100 - - 110 - - 120 - - 125 - - 130 - - 150 - - 175 - - 200 - type: integer - required: - - agent_dirs - - session_providers - - disabled_agents - - terminal - - github_configured - - host - - port - - chart_palette - - tool_result_images - - require_auth - - read_only - type: object - SettingsUpdateRequest: - additionalProperties: false - properties: - agent_homes: - additionalProperties: - items: - type: string - type: array - type: object - auth_token: + subagent_session_id: type: string - chart_palette: + timestamp: type: string - disabled_agents: - items: - type: string - type: array - require_auth: - type: boolean - terminal: - $ref: "#/components/schemas/TerminalResponse" - tool_result_images: - description: Inline tool-result image retention applied to ingestion after a daemon restart - enum: - - keep - - drop - - offload + tool_name: type: string - zoom_level: - enum: - - 67 - - 75 - - 80 - - 90 - - 100 - - 110 - - 120 - - 125 - - 130 - - 150 - - 175 - - 200 - type: integer + tool_use_id: + type: string + required: + - ordinal + - timestamp + - tool_use_id + - tool_name + - category + - input_json + - result_length type: object - StarredResponse: + ServiceToolCallList: additionalProperties: false properties: - session_ids: + count: + format: int64 + type: integer + tool_calls: items: - type: string + $ref: "#/components/schemas/ServiceToolCall" type: array required: - - session_ids + - tool_calls + - count type: object - SyncAnomalyStats: + ServiceUsagePairwiseComparisonDelta: additionalProperties: false properties: - gen_metadata_without_usage_by_agent: - additionalProperties: - format: int64 - type: integer - type: object - gen_metadata_without_usage_total: - format: int64 - type: integer - malformed_lines_by_agent: - additionalProperties: - format: int64 - type: integer - type: object - malformed_lines_total: + cacheCreationDelta: format: int64 type: integer - sanitize: - $ref: "#/components/schemas/SyncSanitizeStats" - unknown_schema_sessions_by_agent: - additionalProperties: - format: int64 - type: integer - type: object - unknown_schema_sessions_total: + cacheCreationDeltaRatio: + format: double + type: + - number + - "null" + cacheReadDelta: format: int64 type: integer - unsupported_source_layouts_by_agent: - additionalProperties: - format: int64 - type: integer - type: object - unsupported_source_layouts_total: + cacheReadDeltaRatio: + format: double + type: + - number + - "null" + costPerSessionDelta: + anyOf: + - $ref: "#/components/schemas/MoneyMoney" + - type: "null" + costPerSessionRatio: + format: double + type: + - number + - "null" + inputTokensDelta: format: int64 type: integer - type: object - SyncProgress: - additionalProperties: false - properties: - bytes_done: + inputTokensDeltaRatio: + format: double + type: + - number + - "null" + outputTokensDelta: format: int64 type: integer - bytes_total: + outputTokensDeltaRatio: + format: double + type: + - number + - "null" + sessionCountDelta: format: int64 type: integer - current_project: - type: string - detail: - type: string - fallback_providers: + sessionCountDeltaRatio: + format: double + type: + - number + - "null" + tokensPerSessionDelta: + format: double + type: + - number + - "null" + tokensPerSessionRatio: + format: double + type: + - number + - "null" + totalCostDelta: + $ref: "#/components/schemas/MoneyMoney" + totalCostDeltaRatio: + format: double + type: + - number + - "null" + totalTokensDelta: format: int64 type: integer - fallback_sources: + totalTokensDeltaRatio: + format: double + type: + - number + - "null" + required: + - totalCostDelta + - totalCostDeltaRatio + - inputTokensDelta + - inputTokensDeltaRatio + - outputTokensDelta + - outputTokensDeltaRatio + - cacheCreationDelta + - cacheCreationDeltaRatio + - cacheReadDelta + - cacheReadDeltaRatio + - totalTokensDelta + - totalTokensDeltaRatio + - sessionCountDelta + - sessionCountDeltaRatio + - costPerSessionDelta + - costPerSessionRatio + - tokensPerSessionDelta + - tokensPerSessionRatio + type: object + ServiceUsagePairwiseComparisonResponse: + additionalProperties: false + properties: + deltas: + $ref: "#/components/schemas/ServiceUsagePairwiseComparisonDelta" + left: + $ref: "#/components/schemas/ServiceUsagePairwiseComparisonSide" + right: + $ref: "#/components/schemas/ServiceUsagePairwiseComparisonSide" + required: + - left + - right + - deltas + type: object + ServiceUsagePairwiseComparisonSide: + additionalProperties: false + properties: + cacheCreationTokens: format: int64 type: integer - hint: - type: string - messages_indexed: + cacheReadTokens: format: int64 type: integer - phase: - type: string - projects_done: + costPerSession: + $ref: "#/components/schemas/MoneyMoney" + inputTokens: format: int64 type: integer - projects_total: + outputTokens: format: int64 type: integer - resync: - type: boolean - sessions_done: + sessionCount: format: int64 type: integer - sessions_total: + tokensPerSession: + format: double + type: number + totalCost: + $ref: "#/components/schemas/MoneyMoney" + totalTokens: format: int64 type: integer - stalled: - type: boolean - started_at: - format: date-time - type: string - updated_at: - format: date-time - type: string required: - - phase - - projects_total - - projects_done - - sessions_total - - sessions_done - - messages_indexed + - totalCost + - inputTokens + - outputTokens + - cacheCreationTokens + - cacheReadTokens + - totalTokens + - sessionCount type: object - SyncRebuildPhaseStats: + SessionDirectoryResponse: additionalProperties: false properties: - batched_writes: - format: int64 - type: integer - batches: - format: int64 - type: integer - contributor: + path: type: string - prep_nanos: - format: int64 - type: integer - scan_nanos: - format: int64 - type: integer - write_batch_size: - format: int64 - type: integer - write_nanos: - format: int64 - type: integer required: - - contributor - - prep_nanos - - scan_nanos - - write_nanos - - batches - - batched_writes - - write_batch_size + - path type: object - SyncSanitizeStats: + SessionProjectAssignmentRequest: additionalProperties: false properties: - control_chars_stripped: - format: int64 - type: integer - model_clamped: - format: int64 - type: integer - role_coerced: - format: int64 - type: integer - timestamps_blanked: - format: int64 - type: integer - tokens_clamped: - format: int64 - type: integer + project: + type: string + required: + - project type: object - SyncStatusResponse: + SessionProviderResponse: additionalProperties: false properties: - last_sync: + dirs: + items: + type: string + type: array + display_name: type: string - progress: - $ref: "#/components/schemas/SyncProgress" - stats: - $ref: "#/components/schemas/SyncSyncStats" + homes: + items: + type: string + type: array + homes_supported: + type: boolean + id: + type: string + post_answer_tool_work: + type: boolean required: - - last_sync - - stats + - id + - display_name + - dirs + - homes_supported + - homes type: object - SyncSyncStats: + SessionUsageBreakdownResponse: additionalProperties: false properties: - aborted: - type: boolean - anomalies: - $ref: "#/components/schemas/SyncAnomalyStats" - cwd_updated: + cache_creation_input_tokens: format: int64 type: integer - failed: + cache_read_input_tokens: format: int64 type: integer - orphaned_copied: + cost: + $ref: "#/components/schemas/MoneyMoney" + has_cost: + type: boolean + input_tokens: format: int64 type: integer - rebuild_phases: - items: - $ref: "#/components/schemas/SyncRebuildPhaseStats" - type: array - skipped: + label: + type: string + message_ordinal: format: int64 type: integer - synced: + model: + type: string + ordinal: format: int64 type: integer - tombstoned: + output_tokens: format: int64 type: integer - total_sessions: + source: + type: string + subagent_session_id: + type: string + timestamp: + type: string + web_search_requests: format: int64 type: integer - warnings: - items: - type: string - type: array required: - - total_sessions - - synced - - skipped - - failed + - ordinal + - source + - label + - timestamp + - model + - input_tokens + - output_tokens + - cache_creation_input_tokens + - cache_read_input_tokens + - cost + - has_cost type: object - SyncWatchBatch: + SessionUsageResponse: additionalProperties: false properties: - full_sync: + agent: + type: string + ai_credits: + format: double + type: number + breakdown: + items: + $ref: "#/components/schemas/SessionUsageBreakdownResponse" + type: array + breakdown_count: + format: int64 + type: integer + cost: + $ref: "#/components/schemas/MoneyMoney" + cost_source: + type: string + cost_usd: + format: double + type: number + has_cost: type: boolean - lost_events: + has_rollup_cost: type: boolean - paths: + has_token_data: + type: boolean + models: items: type: string type: array - reconcile_roots: + peak_context_tokens: + format: int64 + type: integer + project: + type: string + rollup_cost: + $ref: "#/components/schemas/MoneyMoney" + rollup_cost_source: + type: string + rollup_subagent_count: + format: int64 + type: integer + server_running: + type: boolean + session_id: + type: string + subagent_count: + format: int64 + type: integer + total_output_tokens: + format: int64 + type: integer + unpriced_models: items: type: string type: array - renames: - items: - $ref: "#/components/schemas/SyncWatchRename" - type: array + required: + - session_id + - agent + - project + - total_output_tokens + - peak_context_tokens + - has_token_data + - cost + - has_cost + - models + - unpriced_models + - breakdown_count + - breakdown + - server_running type: object - SyncWatchRecoveryScope: + SetGithubConfigInputBody: additionalProperties: false properties: - available_roots: - items: - type: string - type: array - deferred_roots: - items: - type: string - type: array + token: + description: GitHub token + minLength: 1 + type: string + required: + - token type: object - SyncWatchRename: + SetGithubConfigResponse: additionalProperties: false properties: - agent: - type: string - item_type: - format: int32 - minimum: 0 - type: integer - path: - type: string - root: + success: + type: boolean + username: type: string required: - - path + - success + - username type: object - TerminalConfigBody: + SettingsResponse: additionalProperties: false properties: - custom_args: - description: Argument template containing {cmd} when mode is custom + agent_dirs: + additionalProperties: + items: + type: string + type: array + type: object + auth_token: type: string - custom_bin: - description: Terminal binary path when mode is custom + chart_palette: type: string - mode: - description: Terminal launch mode + disabled_agents: + items: + type: string + type: array + github_configured: + type: boolean + host: + type: string + port: + format: int64 + type: integer + read_only: + type: boolean + require_auth: + type: boolean + session_providers: + items: + $ref: "#/components/schemas/SessionProviderResponse" + type: array + terminal: + $ref: "#/components/schemas/TerminalResponse" + tool_result_images: + description: Inline tool-result image retention applied to ingestion after a daemon restart enum: - - auto - - custom - - clipboard + - keep + - drop + - offload type: string + zoom_level: + enum: + - 67 + - 75 + - 80 + - 90 + - 100 + - 110 + - 120 + - 125 + - 130 + - 150 + - 175 + - 200 + type: integer required: - - mode + - agent_dirs + - session_providers + - disabled_agents + - terminal + - github_configured + - host + - port + - chart_palette + - tool_result_images + - require_auth + - read_only type: object - TerminalResponse: + SettingsUpdateRequest: additionalProperties: false properties: - custom_args: + agent_homes: + additionalProperties: + items: + type: string + type: array + type: object + auth_token: type: string - custom_bin: + chart_palette: type: string - mode: + disabled_agents: + items: + type: string + type: array + require_auth: + type: boolean + terminal: + $ref: "#/components/schemas/TerminalResponse" + tool_result_images: + description: Inline tool-result image retention applied to ingestion after a daemon restart + enum: + - keep + - drop + - offload type: string - required: - - mode + zoom_level: + enum: + - 67 + - 75 + - 80 + - 90 + - 100 + - 110 + - 120 + - 125 + - 130 + - 150 + - 175 + - 200 + type: integer type: object - TrashResponse: + StarredResponse: additionalProperties: false properties: - sessions: + session_ids: items: - $ref: "#/components/schemas/DbSession" + type: string type: array required: - - sessions + - session_ids type: object - UnsupportedUsage: + SyncAnomalyStats: additionalProperties: false properties: - kind: - type: string - required: - - kind + gen_metadata_without_usage_by_agent: + additionalProperties: + format: int64 + type: integer + type: object + gen_metadata_without_usage_total: + format: int64 + type: integer + malformed_lines_by_agent: + additionalProperties: + format: int64 + type: integer + type: object + malformed_lines_total: + format: int64 + type: integer + sanitize: + $ref: "#/components/schemas/SyncSanitizeStats" + unknown_schema_sessions_by_agent: + additionalProperties: + format: int64 + type: integer + type: object + unknown_schema_sessions_total: + format: int64 + type: integer + unsupported_source_layouts_by_agent: + additionalProperties: + format: int64 + type: integer + type: object + unsupported_source_layouts_total: + format: int64 + type: integer type: object - UpdateCheckResponse: + SyncProgress: additionalProperties: false properties: - current_version: + bytes_done: + format: int64 + type: integer + bytes_total: + format: int64 + type: integer + current_project: type: string - is_dev_build: - type: boolean - latest_version: + detail: type: string - update_available: + fallback_providers: + format: int64 + type: integer + fallback_sources: + format: int64 + type: integer + hint: + type: string + messages_indexed: + format: int64 + type: integer + phase: + type: string + projects_done: + format: int64 + type: integer + projects_total: + format: int64 + type: integer + resync: + type: boolean + sessions_done: + format: int64 + type: integer + sessions_total: + format: int64 + type: integer + stalled: type: boolean + started_at: + format: date-time + type: string + updated_at: + format: date-time + type: string required: - - update_available - - current_version + - phase + - projects_total + - projects_done + - sessions_total + - sessions_done + - messages_indexed type: object - UploadSessionResponse: + SyncRebuildPhaseStats: additionalProperties: false properties: - machine: - type: string - messages: + batched_writes: format: int64 type: integer - project: - type: string - session_id: + batches: + format: int64 + type: integer + contributor: type: string - sessions: + prep_nanos: + format: int64 + type: integer + scan_nanos: + format: int64 + type: integer + write_batch_size: + format: int64 + type: integer + write_nanos: format: int64 type: integer required: - - session_id - - project - - machine - - messages - - sessions + - contributor + - prep_nanos + - scan_nanos + - write_nanos + - batches + - batched_writes + - write_batch_size type: object - UsageSummaryResponse: + SyncSanitizeStats: + additionalProperties: false + properties: + control_chars_stripped: + format: int64 + type: integer + model_clamped: + format: int64 + type: integer + role_coerced: + format: int64 + type: integer + timestamps_blanked: + format: int64 + type: integer + tokens_clamped: + format: int64 + type: integer + type: object + SyncStatusResponse: + additionalProperties: false + properties: + last_sync: + type: string + progress: + $ref: "#/components/schemas/SyncProgress" + stats: + $ref: "#/components/schemas/SyncSyncStats" + required: + - last_sync + - stats + type: object + SyncSyncStats: + additionalProperties: false + properties: + aborted: + type: boolean + anomalies: + $ref: "#/components/schemas/SyncAnomalyStats" + cwd_updated: + format: int64 + type: integer + failed: + format: int64 + type: integer + orphaned_copied: + format: int64 + type: integer + rebuild_phases: + items: + $ref: "#/components/schemas/SyncRebuildPhaseStats" + type: array + skipped: + format: int64 + type: integer + synced: + format: int64 + type: integer + tombstoned: + format: int64 + type: integer + total_sessions: + format: int64 + type: integer + warnings: + items: + type: string + type: array + required: + - total_sessions + - synced + - skipped + - failed + type: object + SyncWatchBatch: + additionalProperties: false + properties: + full_sync: + type: boolean + lost_events: + type: boolean + paths: + items: + type: string + type: array + reconcile_roots: + items: + type: string + type: array + renames: + items: + $ref: "#/components/schemas/SyncWatchRename" + type: array + type: object + SyncWatchRecoveryScope: + additionalProperties: false + properties: + available_roots: + items: + type: string + type: array + deferred_roots: + items: + type: string + type: array + type: object + SyncWatchRename: + additionalProperties: false + properties: + agent: + type: string + item_type: + format: int32 + minimum: 0 + type: integer + path: + type: string + root: + type: string + required: + - path + type: object + TerminalConfigBody: + additionalProperties: false + properties: + custom_args: + description: Argument template containing {cmd} when mode is custom + type: string + custom_bin: + description: Terminal binary path when mode is custom + type: string + mode: + description: Terminal launch mode + enum: + - auto + - custom + - clipboard + type: string + required: + - mode + type: object + TerminalResponse: + additionalProperties: false + properties: + custom_args: + type: string + custom_bin: + type: string + mode: + type: string + required: + - mode + type: object + TrashResponse: + additionalProperties: false + properties: + sessions: + items: + $ref: "#/components/schemas/DbSession" + type: array + required: + - sessions + type: object + UnsupportedUsage: + additionalProperties: false + properties: + kind: + type: string + required: + - kind + type: object + UpdateCheckResponse: + additionalProperties: false + properties: + current_version: + type: string + is_dev_build: + type: boolean + latest_version: + type: string + update_available: + type: boolean + required: + - update_available + - current_version + type: object + UploadSessionResponse: + additionalProperties: false + properties: + machine: + type: string + messages: + format: int64 + type: integer + project: + type: string + session_id: + type: string + sessions: + format: int64 + type: integer + required: + - session_id + - project + - machine + - messages + - sessions + type: object + UsageSummaryResponse: additionalProperties: false properties: agentTotals: @@ -7324,7 +7870,639 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PingInfo" + $ref: "#/components/schemas/PingInfo" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: Ping daemon + tags: + - Health + /api/v1/activity/report: + get: + operationId: get-api-v1-activity-report + parameters: + - description: Range preset + explode: false + in: query + name: preset + schema: + description: Range preset + enum: + - day + - week + - month + - custom + type: string + - description: Calendar day (YYYY-MM-DD) for presets + explode: false + in: query + name: date + schema: + description: Calendar day (YYYY-MM-DD) for presets + format: date + type: string + - description: Range start (RFC3339) for custom ranges + explode: false + in: query + name: from + schema: + description: Range start (RFC3339) for custom ranges + type: string + - description: Range end (RFC3339) for custom ranges + explode: false + in: query + name: to + schema: + description: Range end (RFC3339) for custom ranges + type: string + - description: IANA timezone name + explode: false + in: query + name: timezone + schema: + description: IANA timezone name + type: string + - description: Timeline bucket size override + explode: false + in: query + name: bucket + schema: + description: Timeline bucket size override + enum: + - 5m + - 15m + - 1h + - 1d + - 1w + type: string + - description: Filter by project + explode: false + in: query + name: project + schema: + description: Filter by project + type: string + - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + explode: false + in: query + name: git_branch + schema: + description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + type: string + - description: Filter by agent + explode: false + in: query + name: agent + schema: + description: Filter by agent + type: string + - description: Filter by machine + explode: false + in: query + name: machine + schema: + description: Filter by machine + type: string + - description: "Automation class: all, interactive, or automated" + explode: false + in: query + name: automation + schema: + default: all + description: "Automation class: all, interactive, or automated" + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityReport" + text/event-stream: + schema: + type: string + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: Get activity report + tags: + - Activity + /api/v1/activity/report/{report_id}/sessions: + get: + operationId: get-api-v1-activity-report-report-id-sessions + parameters: + - description: Signed Activity report ID + in: path + name: report_id + required: true + schema: + description: Signed Activity report ID + type: string + - description: Maximum session rows + explode: false + in: query + name: limit + schema: + description: Maximum session rows + format: int64 + maximum: 500 + minimum: 0 + type: integer + - description: Opaque page cursor + explode: false + in: query + name: cursor + schema: + description: Opaque page cursor + type: string + - description: Session sort + explode: false + in: query + name: sort + schema: + description: Session sort + enum: + - agent_minutes + - cost + - first_active + - project + - agent + type: string + - description: Sort direction + explode: false + in: query + name: direction + schema: + description: Sort direction + enum: + - asc + - desc + type: string + - description: First timeline bucket in the half-open range + explode: false + in: query + name: bucket_start + schema: + description: First timeline bucket in the half-open range + format: int64 + minimum: 0 + type: integer + - description: Exclusive end of the timeline bucket range + explode: false + in: query + name: bucket_end + schema: + description: Exclusive end of the timeline bucket range + format: int64 + minimum: 1 + type: integer + - description: Include full report metadata for stateless clients + explode: false + in: query + name: include_report + schema: + description: Include full report metadata for stateless clients + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityReportSessionsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: Page activity report sessions + tags: + - Activity + /api/v1/agents: + get: + operationId: get-api-v1-agents + parameters: + - description: Include one-shot sessions + explode: false + in: query + name: include_one_shot + schema: + description: Include one-shot sessions + type: boolean + - description: Include automated sessions + explode: false + in: query + name: include_automated + schema: + description: Include automated sessions + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/AgentsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: List agents + tags: + - Metadata + /api/v1/analytics/activity: + get: + operationId: get-api-v1-analytics-activity + parameters: + - description: Range start date + explode: false + in: query + name: from + schema: + description: Range start date + format: date + type: string + - description: Range end date + explode: false + in: query + name: to + schema: + description: Range end date + format: date + type: string + - description: IANA timezone name + explode: false + in: query + name: timezone + schema: + description: IANA timezone name + type: string + - description: Filter by machine + explode: false + in: query + name: machine + schema: + description: Filter by machine + type: string + - description: Filter by project + explode: false + in: query + name: project + schema: + description: Filter by project + type: string + - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + explode: false + in: query + name: git_branch + schema: + description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + type: string + - description: Filter by agent + explode: false + in: query + name: agent + schema: + description: Filter by agent + type: string + - description: Comma-separated model filter + explode: false + in: query + name: model + schema: + description: Comma-separated model filter + type: string + - description: Day of week, Monday=0 through Sunday=6 + explode: false + in: query + name: dow + schema: + description: Day of week, Monday=0 through Sunday=6 + format: int64 + maximum: 6 + minimum: 0 + type: integer + - description: Hour of day, 0 through 23 + explode: false + in: query + name: hour + schema: + description: Hour of day, 0 through 23 + format: int64 + maximum: 23 + minimum: 0 + type: integer + - description: Minimum user message count + explode: false + in: query + name: min_user_messages + schema: + description: Minimum user message count + format: int64 + minimum: 0 + type: integer + - description: Filter sessions active since this RFC3339 timestamp + explode: false + in: query + name: active_since + schema: + description: Filter sessions active since this RFC3339 timestamp + format: date-time + type: string + - description: Automation scope + explode: false + in: query + name: automated_scope + schema: + description: Automation scope + enum: + - human + - all + - automated + type: string + - description: Include one-shot sessions + explode: false + in: query + name: include_one_shot + schema: + description: Include one-shot sessions + type: boolean + - description: Include automated sessions + explode: false + in: query + name: include_automated + schema: + description: Include automated sessions + type: boolean + - description: Filter by termination reason + explode: false + in: query + name: termination + schema: + description: Filter by termination reason + type: string + - description: Time bucket granularity + explode: false + in: query + name: granularity + schema: + default: day + description: Time bucket granularity + enum: + - day + - week + - month + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/DbActivityResponse" description: OK "400": content: @@ -7356,6 +8534,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity "500": content: application/json: @@ -7386,46 +8570,28 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Ping daemon + summary: Get analytics activity tags: - - Health - /api/v1/activity/report: + - Analytics + /api/v1/analytics/heatmap: get: - operationId: get-api-v1-activity-report + operationId: get-api-v1-analytics-heatmap parameters: - - description: Range preset - explode: false - in: query - name: preset - schema: - description: Range preset - enum: - - day - - week - - month - - custom - type: string - - description: Calendar day (YYYY-MM-DD) for presets - explode: false - in: query - name: date - schema: - description: Calendar day (YYYY-MM-DD) for presets - format: date - type: string - - description: Range start (RFC3339) for custom ranges + - description: Range start date explode: false in: query name: from schema: - description: Range start (RFC3339) for custom ranges + description: Range start date + format: date type: string - - description: Range end (RFC3339) for custom ranges + - description: Range end date explode: false in: query name: to schema: - description: Range end (RFC3339) for custom ranges + description: Range end date + format: date type: string - description: IANA timezone name explode: false @@ -7434,18 +8600,12 @@ paths: schema: description: IANA timezone name type: string - - description: Timeline bucket size override + - description: Filter by machine explode: false in: query - name: bucket + name: machine schema: - description: Timeline bucket size override - enum: - - 5m - - 15m - - 1h - - 1d - - 1w + description: Filter by machine type: string - description: Filter by project explode: false @@ -7468,30 +8628,100 @@ paths: schema: description: Filter by agent type: string - - description: Filter by machine + - description: Comma-separated model filter explode: false in: query - name: machine + name: model schema: - description: Filter by machine + description: Comma-separated model filter type: string - - description: "Automation class: all, interactive, or automated" + - description: Day of week, Monday=0 through Sunday=6 explode: false in: query - name: automation + name: dow schema: - default: all - description: "Automation class: all, interactive, or automated" + description: Day of week, Monday=0 through Sunday=6 + format: int64 + maximum: 6 + minimum: 0 + type: integer + - description: Hour of day, 0 through 23 + explode: false + in: query + name: hour + schema: + description: Hour of day, 0 through 23 + format: int64 + maximum: 23 + minimum: 0 + type: integer + - description: Minimum user message count + explode: false + in: query + name: min_user_messages + schema: + description: Minimum user message count + format: int64 + minimum: 0 + type: integer + - description: Filter sessions active since this RFC3339 timestamp + explode: false + in: query + name: active_since + schema: + description: Filter sessions active since this RFC3339 timestamp + format: date-time + type: string + - description: Automation scope + explode: false + in: query + name: automated_scope + schema: + description: Automation scope + enum: + - human + - all + - automated + type: string + - description: Include one-shot sessions + explode: false + in: query + name: include_one_shot + schema: + description: Include one-shot sessions + type: boolean + - description: Include automated sessions + explode: false + in: query + name: include_automated + schema: + description: Include automated sessions + type: boolean + - description: Filter by termination reason + explode: false + in: query + name: termination + schema: + description: Filter by termination reason + type: string + - description: Heatmap metric + explode: false + in: query + name: metric + schema: + default: messages + description: Heatmap metric + enum: + - messages + - sessions + - output_tokens type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ActivityReport" - text/event-stream: - schema: - type: string + $ref: "#/components/schemas/DbHeatmapResponse" description: OK "400": content: @@ -7559,165 +8789,119 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get activity report + summary: Get analytics heatmap tags: - - Activity - /api/v1/activity/report/{report_id}/sessions: + - Analytics + /api/v1/analytics/hour-of-week: get: - operationId: get-api-v1-activity-report-report-id-sessions + operationId: get-api-v1-analytics-hour-of-week parameters: - - description: Signed Activity report ID - in: path - name: report_id - required: true + - description: Range start date + explode: false + in: query + name: from schema: - description: Signed Activity report ID + description: Range start date + format: date type: string - - description: Maximum session rows + - description: Range end date explode: false in: query - name: limit + name: to schema: - description: Maximum session rows - format: int64 - maximum: 500 - minimum: 0 - type: integer - - description: Opaque page cursor + description: Range end date + format: date + type: string + - description: IANA timezone name explode: false in: query - name: cursor + name: timezone schema: - description: Opaque page cursor + description: IANA timezone name type: string - - description: Session sort + - description: Filter by machine explode: false in: query - name: sort + name: machine schema: - description: Session sort - enum: - - agent_minutes - - cost - - first_active - - project - - agent + description: Filter by machine type: string - - description: Sort direction + - description: Filter by project explode: false in: query - name: direction + name: project schema: - description: Sort direction - enum: - - asc - - desc + description: Filter by project type: string - - description: First timeline bucket in the half-open range + - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + explode: false + in: query + name: git_branch + schema: + description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + type: string + - description: Filter by agent + explode: false + in: query + name: agent + schema: + description: Filter by agent + type: string + - description: Comma-separated model filter + explode: false + in: query + name: model + schema: + description: Comma-separated model filter + type: string + - description: Day of week, Monday=0 through Sunday=6 + explode: false + in: query + name: dow + schema: + description: Day of week, Monday=0 through Sunday=6 + format: int64 + maximum: 6 + minimum: 0 + type: integer + - description: Hour of day, 0 through 23 + explode: false + in: query + name: hour + schema: + description: Hour of day, 0 through 23 + format: int64 + maximum: 23 + minimum: 0 + type: integer + - description: Minimum user message count explode: false in: query - name: bucket_start + name: min_user_messages schema: - description: First timeline bucket in the half-open range + description: Minimum user message count format: int64 minimum: 0 type: integer - - description: Exclusive end of the timeline bucket range + - description: Filter sessions active since this RFC3339 timestamp explode: false in: query - name: bucket_end + name: active_since schema: - description: Exclusive end of the timeline bucket range - format: int64 - minimum: 1 - type: integer - - description: Include full report metadata for stateless clients + description: Filter sessions active since this RFC3339 timestamp + format: date-time + type: string + - description: Automation scope explode: false in: query - name: include_report + name: automated_scope schema: - description: Include full report metadata for stateless clients - type: boolean - responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/ActivityReportSessionsResponse" - description: OK - "400": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Bad Request - "401": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Unauthorized - "403": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Forbidden - "404": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Not Found - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Conflict - "422": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Unprocessable Entity - "500": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Internal Server Error - "501": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Not Implemented - "502": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Bad Gateway - "503": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Service Unavailable - "504": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Gateway Timeout - summary: Page activity report sessions - tags: - - Activity - /api/v1/agents: - get: - operationId: get-api-v1-agents - parameters: + description: Automation scope + enum: + - human + - all + - automated + type: string - description: Include one-shot sessions explode: false in: query @@ -7732,12 +8916,19 @@ paths: schema: description: Include automated sessions type: boolean + - description: Filter by termination reason + explode: false + in: query + name: termination + schema: + description: Filter by termination reason + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/AgentsResponse" + $ref: "#/components/schemas/DbHourOfWeekResponse" description: OK "400": content: @@ -7805,12 +8996,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: List agents + summary: Get analytics by hour of week tags: - - Metadata - /api/v1/analytics/activity: + - Analytics + /api/v1/analytics/projects: get: - operationId: get-api-v1-analytics-activity + operationId: get-api-v1-analytics-projects parameters: - description: Range start date explode: false @@ -7939,24 +9130,12 @@ paths: schema: description: Filter by termination reason type: string - - description: Time bucket granularity - explode: false - in: query - name: granularity - schema: - default: day - description: Time bucket granularity - enum: - - day - - week - - month - type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbActivityResponse" + $ref: "#/components/schemas/DbProjectsAnalyticsResponse" description: OK "400": content: @@ -8024,12 +9203,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get analytics activity + summary: Get analytics by project tags: - Analytics - /api/v1/analytics/heatmap: + /api/v1/analytics/sessions: get: - operationId: get-api-v1-analytics-heatmap + operationId: get-api-v1-analytics-sessions parameters: - description: Range start date explode: false @@ -8158,24 +9337,12 @@ paths: schema: description: Filter by termination reason type: string - - description: Heatmap metric - explode: false - in: query - name: metric - schema: - default: messages - description: Heatmap metric - enum: - - messages - - sessions - - output_tokens - type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbHeatmapResponse" + $ref: "#/components/schemas/DbSessionShapeResponse" description: OK "400": content: @@ -8243,12 +9410,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get analytics heatmap + summary: Get session shape analytics tags: - Analytics - /api/v1/analytics/hour-of-week: + /api/v1/analytics/signal-sessions: get: - operationId: get-api-v1-analytics-hour-of-week + operationId: get-api-v1-analytics-signal-sessions parameters: - description: Range start date explode: false @@ -8377,12 +9544,31 @@ paths: schema: description: Filter by termination reason type: string + - description: Signal name + explode: false + in: query + name: signal + required: true + schema: + description: Signal name + type: string + - description: Maximum number of session examples + explode: false + in: query + name: limit + schema: + default: 10 + description: Maximum number of session examples + format: int64 + maximum: 20 + minimum: 0 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbHourOfWeekResponse" + $ref: "#/components/schemas/DbSignalSessionsResponse" description: OK "400": content: @@ -8450,12 +9636,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get analytics by hour of week + summary: Get signal session examples tags: - Analytics - /api/v1/analytics/projects: + /api/v1/analytics/signals: get: - operationId: get-api-v1-analytics-projects + operationId: get-api-v1-analytics-signals parameters: - description: Range start date explode: false @@ -8589,7 +9775,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DbProjectsAnalyticsResponse" + $ref: "#/components/schemas/DbSignalsAnalyticsResponse" description: OK "400": content: @@ -8657,12 +9843,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get analytics by project + summary: Get signal analytics tags: - Analytics - /api/v1/analytics/sessions: + /api/v1/analytics/skills: get: - operationId: get-api-v1-analytics-sessions + operationId: get-api-v1-analytics-skills parameters: - description: Range start date explode: false @@ -8791,12 +9977,24 @@ paths: schema: description: Filter by termination reason type: string + - description: Trend bucket granularity + explode: false + in: query + name: granularity + schema: + default: week + description: Trend bucket granularity + enum: + - day + - week + - month + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbSessionShapeResponse" + $ref: "#/components/schemas/DbSkillsAnalyticsResponse" description: OK "400": content: @@ -8864,12 +10062,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get session shape analytics + summary: Get skill analytics tags: - Analytics - /api/v1/analytics/signal-sessions: + /api/v1/analytics/summary: get: - operationId: get-api-v1-analytics-signal-sessions + operationId: get-api-v1-analytics-summary parameters: - description: Range start date explode: false @@ -8998,31 +10196,12 @@ paths: schema: description: Filter by termination reason type: string - - description: Signal name - explode: false - in: query - name: signal - required: true - schema: - description: Signal name - type: string - - description: Maximum number of session examples - explode: false - in: query - name: limit - schema: - default: 10 - description: Maximum number of session examples - format: int64 - maximum: 20 - minimum: 0 - type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbSignalSessionsResponse" + $ref: "#/components/schemas/DbAnalyticsSummary" description: OK "400": content: @@ -9090,12 +10269,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get signal session examples + summary: Get analytics summary tags: - Analytics - /api/v1/analytics/signals: + /api/v1/analytics/tools: get: - operationId: get-api-v1-analytics-signals + operationId: get-api-v1-analytics-tools parameters: - description: Range start date explode: false @@ -9229,7 +10408,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DbSignalsAnalyticsResponse" + $ref: "#/components/schemas/DbToolsAnalyticsResponse" description: OK "400": content: @@ -9297,12 +10476,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get signal analytics + summary: Get tool analytics tags: - Analytics - /api/v1/analytics/skills: + /api/v1/analytics/top-sessions: get: - operationId: get-api-v1-analytics-skills + operationId: get-api-v1-analytics-top-sessions parameters: - description: Range start date explode: false @@ -9431,24 +10610,24 @@ paths: schema: description: Filter by termination reason type: string - - description: Trend bucket granularity + - description: Ranking metric explode: false in: query - name: granularity + name: metric schema: - default: week - description: Trend bucket granularity + default: messages + description: Ranking metric enum: - - day - - week - - month + - messages + - duration + - output_tokens type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbSkillsAnalyticsResponse" + $ref: "#/components/schemas/DbTopSessionsResponse" description: OK "400": content: @@ -9516,12 +10695,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get skill analytics + summary: Get top sessions tags: - Analytics - /api/v1/analytics/summary: + /api/v1/analytics/velocity: get: - operationId: get-api-v1-analytics-summary + operationId: get-api-v1-analytics-velocity parameters: - description: Range start date explode: false @@ -9655,7 +10834,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/DbAnalyticsSummary" + $ref: "#/components/schemas/DbVelocityResponse" description: OK "400": content: @@ -9723,119 +10902,200 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get analytics summary + summary: Get velocity analytics tags: - Analytics - /api/v1/analytics/tools: + /api/v1/artifacts/exchange: + post: + operationId: post-api-v1-artifacts-exchange + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactExchangeRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ArtifactSyncResult" + description: OK + "400": + content: + text/plain: + schema: + type: string + description: Request failed + "401": + content: + text/plain: + schema: + type: string + description: Request failed + "403": + content: + text/plain: + schema: + type: string + description: Request failed + "404": + content: + text/plain: + schema: + type: string + description: Request failed + "405": + content: + text/plain: + schema: + type: string + description: Request failed + "413": + content: + text/plain: + schema: + type: string + description: Request failed + "426": + content: + text/plain: + schema: + type: string + description: Request failed + "500": + content: + text/plain: + schema: + type: string + description: Request failed + "501": + content: + text/plain: + schema: + type: string + description: Request failed + "502": + content: + text/plain: + schema: + type: string + description: Request failed + "503": + content: + text/plain: + schema: + type: string + description: Request failed + summary: Exchange artifacts with a local folder + tags: + - Artifacts + /api/v1/assets/{filename}: get: - operationId: get-api-v1-analytics-tools + operationId: get-api-v1-assets-filename parameters: - - description: Range start date - explode: false - in: query - name: from - schema: - description: Range start date - format: date - type: string - - description: Range end date - explode: false - in: query - name: to - schema: - description: Range end date - format: date - type: string - - description: IANA timezone name - explode: false - in: query - name: timezone - schema: - description: IANA timezone name - type: string - - description: Filter by machine - explode: false - in: query - name: machine - schema: - description: Filter by machine - type: string - - description: Filter by project - explode: false - in: query - name: project - schema: - description: Filter by project - type: string - - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint - explode: false - in: query - name: git_branch - schema: - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint - type: string - - description: Filter by agent - explode: false - in: query - name: agent - schema: - description: Filter by agent - type: string - - description: Comma-separated model filter - explode: false - in: query - name: model - schema: - description: Comma-separated model filter - type: string - - description: Day of week, Monday=0 through Sunday=6 - explode: false - in: query - name: dow - schema: - description: Day of week, Monday=0 through Sunday=6 - format: int64 - maximum: 6 - minimum: 0 - type: integer - - description: Hour of day, 0 through 23 - explode: false - in: query - name: hour - schema: - description: Hour of day, 0 through 23 - format: int64 - maximum: 23 - minimum: 0 - type: integer - - description: Minimum user message count - explode: false - in: query - name: min_user_messages - schema: - description: Minimum user message count - format: int64 - minimum: 0 - type: integer - - description: Filter sessions active since this RFC3339 timestamp - explode: false - in: query - name: active_since - schema: - description: Filter sessions active since this RFC3339 timestamp - format: date-time - type: string - - description: Automation scope - explode: false - in: query - name: automated_scope + - description: Asset filename + in: path + name: filename + required: true schema: - description: Automation scope - enum: - - human - - all - - automated + description: Asset filename type: string + responses: + "200": + content: + application/octet-stream: + schema: + contentMediaType: application/octet-stream + format: binary + type: string + description: OK + headers: + Cache-Control: + schema: + type: string + Content-Disposition: + schema: + type: string + Content-Type: + schema: + type: string + X-Content-Type-Options: + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: Get imported asset + tags: + - Assets + /api/v1/branches: + get: + operationId: get-api-v1-branches + parameters: - description: Include one-shot sessions explode: false in: query @@ -9850,19 +11110,12 @@ paths: schema: description: Include automated sessions type: boolean - - description: Filter by termination reason - explode: false - in: query - name: termination - schema: - description: Filter by termination reason - type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbToolsAnalyticsResponse" + $ref: "#/components/schemas/BranchesResponse" description: OK "400": content: @@ -9930,158 +11183,96 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get tool analytics + summary: List branches tags: - - Analytics - /api/v1/analytics/top-sessions: + - Metadata + /api/v1/config/github: get: - operationId: get-api-v1-analytics-top-sessions - parameters: - - description: Range start date - explode: false - in: query - name: from - schema: - description: Range start date - format: date - type: string - - description: Range end date - explode: false - in: query - name: to - schema: - description: Range end date - format: date - type: string - - description: IANA timezone name - explode: false - in: query - name: timezone - schema: - description: IANA timezone name - type: string - - description: Filter by machine - explode: false - in: query - name: machine - schema: - description: Filter by machine - type: string - - description: Filter by project - explode: false - in: query - name: project - schema: - description: Filter by project - type: string - - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint - explode: false - in: query - name: git_branch - schema: - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint - type: string - - description: Filter by agent - explode: false - in: query - name: agent - schema: - description: Filter by agent - type: string - - description: Comma-separated model filter - explode: false - in: query - name: model - schema: - description: Comma-separated model filter - type: string - - description: Day of week, Monday=0 through Sunday=6 - explode: false - in: query - name: dow - schema: - description: Day of week, Monday=0 through Sunday=6 - format: int64 - maximum: 6 - minimum: 0 - type: integer - - description: Hour of day, 0 through 23 - explode: false - in: query - name: hour - schema: - description: Hour of day, 0 through 23 - format: int64 - maximum: 23 - minimum: 0 - type: integer - - description: Minimum user message count - explode: false - in: query - name: min_user_messages - schema: - description: Minimum user message count - format: int64 - minimum: 0 - type: integer - - description: Filter sessions active since this RFC3339 timestamp - explode: false - in: query - name: active_since - schema: - description: Filter sessions active since this RFC3339 timestamp - format: date-time - type: string - - description: Automation scope - explode: false - in: query - name: automated_scope - schema: - description: Automation scope - enum: - - human - - all - - automated - type: string - - description: Include one-shot sessions - explode: false - in: query - name: include_one_shot - schema: - description: Include one-shot sessions - type: boolean - - description: Include automated sessions - explode: false - in: query - name: include_automated - schema: - description: Include automated sessions - type: boolean - - description: Filter by termination reason - explode: false - in: query - name: termination - schema: - description: Filter by termination reason - type: string - - description: Ranking metric - explode: false - in: query - name: metric - schema: - default: messages - description: Ranking metric - enum: - - messages - - duration - - output_tokens - type: string + operationId: get-api-v1-config-github + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GithubConfigResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: Get GitHub config + tags: + - Config + post: + operationId: post-api-v1-config-github + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SetGithubConfigInputBody" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbTopSessionsResponse" + $ref: "#/components/schemas/SetGithubConfigResponse" description: OK "400": content: @@ -10149,146 +11340,96 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get top sessions + summary: Set GitHub config tags: - - Analytics - /api/v1/analytics/velocity: + - Config + /api/v1/config/terminal: get: - operationId: get-api-v1-analytics-velocity - parameters: - - description: Range start date - explode: false - in: query - name: from - schema: - description: Range start date - format: date - type: string - - description: Range end date - explode: false - in: query - name: to - schema: - description: Range end date - format: date - type: string - - description: IANA timezone name - explode: false - in: query - name: timezone - schema: - description: IANA timezone name - type: string - - description: Filter by machine - explode: false - in: query - name: machine - schema: - description: Filter by machine - type: string - - description: Filter by project - explode: false - in: query - name: project - schema: - description: Filter by project - type: string - - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint - explode: false - in: query - name: git_branch - schema: - description: Filter by git branch; opaque (project, branch) tokens from the /branches endpoint - type: string - - description: Filter by agent - explode: false - in: query - name: agent - schema: - description: Filter by agent - type: string - - description: Comma-separated model filter - explode: false - in: query - name: model - schema: - description: Comma-separated model filter - type: string - - description: Day of week, Monday=0 through Sunday=6 - explode: false - in: query - name: dow - schema: - description: Day of week, Monday=0 through Sunday=6 - format: int64 - maximum: 6 - minimum: 0 - type: integer - - description: Hour of day, 0 through 23 - explode: false - in: query - name: hour - schema: - description: Hour of day, 0 through 23 - format: int64 - maximum: 23 - minimum: 0 - type: integer - - description: Minimum user message count - explode: false - in: query - name: min_user_messages - schema: - description: Minimum user message count - format: int64 - minimum: 0 - type: integer - - description: Filter sessions active since this RFC3339 timestamp - explode: false - in: query - name: active_since - schema: - description: Filter sessions active since this RFC3339 timestamp - format: date-time - type: string - - description: Automation scope - explode: false - in: query - name: automated_scope - schema: - description: Automation scope - enum: - - human - - all - - automated - type: string - - description: Include one-shot sessions - explode: false - in: query - name: include_one_shot - schema: - description: Include one-shot sessions - type: boolean - - description: Include automated sessions - explode: false - in: query - name: include_automated - schema: - description: Include automated sessions - type: boolean - - description: Filter by termination reason - explode: false - in: query - name: termination - schema: - description: Filter by termination reason - type: string + operationId: get-api-v1-config-terminal + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/TerminalConfigBody" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Request + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Forbidden + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Found + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Conflict + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Internal Server Error + "501": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Not Implemented + "502": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Bad Gateway + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Service Unavailable + "504": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Gateway Timeout + summary: Get terminal config + tags: + - Config + post: + operationId: post-api-v1-config-terminal + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TerminalConfigBody" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbVelocityResponse" + $ref: "#/components/schemas/TerminalConfigBody" description: OK "400": content: @@ -10356,127 +11497,25 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get velocity analytics + summary: Set terminal config tags: - - Analytics - /api/v1/artifacts/exchange: + - Config + /api/v1/data/compact: post: - operationId: post-api-v1-artifacts-exchange + operationId: post-api-v1-data-compact requestBody: content: application/json: schema: - $ref: "#/components/schemas/ArtifactExchangeRequest" + $ref: "#/components/schemas/DataCompactRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ArtifactSyncResult" - description: OK - "400": - content: - text/plain: - schema: - type: string - description: Request failed - "401": - content: - text/plain: - schema: - type: string - description: Request failed - "403": - content: - text/plain: - schema: - type: string - description: Request failed - "404": - content: - text/plain: - schema: - type: string - description: Request failed - "405": - content: - text/plain: - schema: - type: string - description: Request failed - "413": - content: - text/plain: - schema: - type: string - description: Request failed - "426": - content: - text/plain: - schema: - type: string - description: Request failed - "500": - content: - text/plain: - schema: - type: string - description: Request failed - "501": - content: - text/plain: - schema: - type: string - description: Request failed - "502": - content: - text/plain: - schema: - type: string - description: Request failed - "503": - content: - text/plain: - schema: - type: string - description: Request failed - summary: Exchange artifacts with a local folder - tags: - - Artifacts - /api/v1/assets/{filename}: - get: - operationId: get-api-v1-assets-filename - parameters: - - description: Asset filename - in: path - name: filename - required: true - schema: - description: Asset filename - type: string - responses: - "200": - content: - application/octet-stream: - schema: - contentMediaType: application/octet-stream - format: binary - type: string + $ref: "#/components/schemas/DbCompactResult" description: OK - headers: - Cache-Control: - schema: - type: string - Content-Disposition: - schema: - type: string - Content-Type: - schema: - type: string - X-Content-Type-Options: - schema: - type: string "400": content: application/json: @@ -10543,33 +11582,57 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get imported asset + summary: Compact local archive tags: - - Assets - /api/v1/branches: + - Data + /api/v1/data/project-reclassification/candidates: get: - operationId: get-api-v1-branches + operationId: get-api-v1-data-project-reclassification-candidates parameters: - - description: Include one-shot sessions + - description: Session activity range start, inclusive explode: false in: query - name: include_one_shot + name: date_from schema: - description: Include one-shot sessions - type: boolean - - description: Include automated sessions + description: Session activity range start, inclusive + format: date + type: string + - description: Session activity range end, inclusive explode: false in: query - name: include_automated + name: date_to schema: - description: Include automated sessions - type: boolean + description: Session activity range end, inclusive + format: date + type: string + - description: Timezone for date bounds + explode: false + in: query + name: timezone + schema: + description: Timezone for date bounds + type: string + - description: Project display label + explode: false + in: query + name: project_label + schema: + description: Project display label + type: string + - description: Opaque project identity key + explode: false + in: query + name: project_key + required: true + schema: + description: Opaque project identity key + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/BranchesResponse" + $ref: "#/components/schemas/DataCandidatesResponse" description: OK "400": content: @@ -10637,18 +11700,26 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: List branches + summary: List archive-wide reclassification candidates tags: - - Metadata - /api/v1/config/github: + - Data + /api/v1/data/project-rules: get: - operationId: get-api-v1-config-github + operationId: get-api-v1-data-project-rules + parameters: + - description: Machine to list rules for + explode: false + in: query + name: machine + schema: + description: Machine to list rules for + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/GithubConfigResponse" + $ref: "#/components/schemas/DataProjectRulesResponse" description: OK "400": content: @@ -10680,6 +11751,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity "500": content: application/json: @@ -10710,23 +11787,42 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get GitHub config + summary: List project rules tags: - - Config - post: - operationId: post-api-v1-config-github - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/SetGithubConfigInputBody" - required: true + - Data + /api/v1/data/projects: + get: + operationId: get-api-v1-data-projects + parameters: + - description: Session activity range start, inclusive + explode: false + in: query + name: date_from + schema: + description: Session activity range start, inclusive + format: date + type: string + - description: Session activity range end, inclusive + explode: false + in: query + name: date_to + schema: + description: Session activity range end, inclusive + format: date + type: string + - description: Timezone for date bounds + explode: false + in: query + name: timezone + schema: + description: Timezone for date bounds + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/SetGithubConfigResponse" + $ref: "#/components/schemas/DbProjectInventory" description: OK "400": content: @@ -10794,18 +11890,72 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Set GitHub config + summary: Get project inventory tags: - - Config - /api/v1/config/terminal: + - Data + /api/v1/data/projects/{project_key}/sessions: get: - operationId: get-api-v1-config-terminal + operationId: get-api-v1-data-projects-project-key-sessions + parameters: + - description: Session activity range start, inclusive + explode: false + in: query + name: date_from + schema: + description: Session activity range start, inclusive + format: date + type: string + - description: Session activity range end, inclusive + explode: false + in: query + name: date_to + schema: + description: Session activity range end, inclusive + format: date + type: string + - description: Timezone for date bounds + explode: false + in: query + name: timezone + schema: + description: Timezone for date bounds + type: string + - description: Opaque project identity key + in: path + name: project_key + required: true + schema: + description: Opaque project identity key + type: string + - description: Opaque pagination cursor + explode: false + in: query + name: cursor + schema: + description: Opaque pagination cursor + type: string + - description: Maximum number of results per page + explode: false + in: query + name: limit + schema: + description: Maximum number of results per page + format: int64 + minimum: 0 + type: integer + - description: Include automated sessions + explode: false + in: query + name: include_automated + schema: + description: Include automated sessions + type: boolean responses: "200": content: application/json: schema: - $ref: "#/components/schemas/TerminalConfigBody" + $ref: "#/components/schemas/DbSessionPage" description: OK "400": content: @@ -10837,6 +11987,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity "500": content: application/json: @@ -10867,23 +12023,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get terminal config + summary: List sessions for an opaque project identity tags: - - Config + - Data + /api/v1/data/strip-images: post: - operationId: post-api-v1-config-terminal + operationId: post-api-v1-data-strip-images requestBody: content: application/json: schema: - $ref: "#/components/schemas/TerminalConfigBody" + $ref: "#/components/schemas/DataStripImagesApplyRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/TerminalConfigBody" + $ref: "#/components/schemas/DbStripImagesReport" description: OK "400": content: @@ -10951,24 +12108,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Set terminal config + summary: Remove retained inline tool-result images tags: - - Config - /api/v1/data/compact: + - Data + /api/v1/data/strip-images/preview: post: - operationId: post-api-v1-data-compact + operationId: post-api-v1-data-strip-images-preview requestBody: content: application/json: schema: - $ref: "#/components/schemas/DataCompactRequest" + $ref: "#/components/schemas/DataStripImagesRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbCompactResult" + $ref: "#/components/schemas/DbStripImagesReport" description: OK "400": content: @@ -11036,58 +12193,25 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Compact local archive + summary: Preview inline tool-result image removal tags: - Data - /api/v1/data/project-reclassification/candidates: - get: - operationId: get-api-v1-data-project-reclassification-candidates - parameters: - - description: Session activity range start, inclusive - explode: false - in: query - name: date_from - schema: - description: Session activity range start, inclusive - format: date - type: string - - description: Session activity range end, inclusive - explode: false - in: query - name: date_to - schema: - description: Session activity range end, inclusive - format: date - type: string - - description: Timezone for date bounds - explode: false - in: query - name: timezone - schema: - description: Timezone for date bounds - type: string - - description: Project display label - explode: false - in: query - name: project_label - schema: - description: Project display label - type: string - - description: Opaque project identity key - explode: false - in: query - name: project_key - required: true - schema: - description: Opaque project identity key - type: string + /api/v1/embeddings/build: + post: + operationId: post-api-v1-embeddings-build + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EmbeddingsBuildRequest" + required: true responses: - "200": + "202": content: application/json: schema: - $ref: "#/components/schemas/DataCandidatesResponse" - description: OK + $ref: "#/components/schemas/EmbeddingsBuildResponse" + description: Accepted "400": content: application/json: @@ -11154,26 +12278,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: List archive-wide reclassification candidates + summary: Start an embeddings build tags: - - Data - /api/v1/data/project-rules: + - Embeddings + /api/v1/embeddings/generations: get: - operationId: get-api-v1-data-project-rules + operationId: get-api-v1-embeddings-generations parameters: - - description: Machine to list rules for - explode: false + - explode: false in: query - name: machine + name: store schema: - description: Machine to list rules for type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DataProjectRulesResponse" + $ref: "#/components/schemas/EmbeddingsGenerationsResponse" description: OK "400": content: @@ -11241,43 +12363,35 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: List project rules + summary: List embedding generations tags: - - Data - /api/v1/data/projects: - get: - operationId: get-api-v1-data-projects + - Embeddings + /api/v1/embeddings/generations/{id}/activate: + post: + operationId: post-api-v1-embeddings-generations-id-activate parameters: - - description: Session activity range start, inclusive - explode: false - in: query - name: date_from - schema: - description: Session activity range start, inclusive - format: date - type: string - - description: Session activity range end, inclusive - explode: false - in: query - name: date_to + - description: Generation ordinal ID + in: path + name: id + required: true schema: - description: Session activity range end, inclusive - format: date - type: string - - description: Timezone for date bounds - explode: false + description: Generation ordinal ID + format: int64 + type: integer + - explode: false in: query - name: timezone + name: store schema: - description: Timezone for date bounds type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EmbeddingsGenerationActionRequest" + required: true responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/DbProjectInventory" - description: OK + "204": + description: No Content "400": content: application/json: @@ -11344,73 +12458,35 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Get project inventory + summary: Activate an embedding generation tags: - - Data - /api/v1/data/projects/{project_key}/sessions: - get: - operationId: get-api-v1-data-projects-project-key-sessions + - Embeddings + /api/v1/embeddings/generations/{id}/retire: + post: + operationId: post-api-v1-embeddings-generations-id-retire parameters: - - description: Session activity range start, inclusive - explode: false - in: query - name: date_from - schema: - description: Session activity range start, inclusive - format: date - type: string - - description: Session activity range end, inclusive - explode: false - in: query - name: date_to - schema: - description: Session activity range end, inclusive - format: date - type: string - - description: Timezone for date bounds - explode: false - in: query - name: timezone - schema: - description: Timezone for date bounds - type: string - - description: Opaque project identity key + - description: Generation ordinal ID in: path - name: project_key + name: id required: true schema: - description: Opaque project identity key - type: string - - description: Opaque pagination cursor - explode: false - in: query - name: cursor - schema: - description: Opaque pagination cursor - type: string - - description: Maximum number of results per page - explode: false - in: query - name: limit - schema: - description: Maximum number of results per page + description: Generation ordinal ID format: int64 - minimum: 0 type: integer - - description: Include automated sessions - explode: false + - explode: false in: query - name: include_automated + name: store schema: - description: Include automated sessions - type: boolean + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/EmbeddingsGenerationActionRequest" + required: true responses: - "200": - content: - application/json: - schema: - $ref: "#/components/schemas/DbSessionPage" - description: OK + "204": + description: No Content "400": content: application/json: @@ -11477,24 +12553,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: List sessions for an opaque project identity + summary: Retire an embedding generation tags: - - Data - /api/v1/data/strip-images: - post: - operationId: post-api-v1-data-strip-images - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/DataStripImagesApplyRequest" - required: true + - Embeddings + /api/v1/embeddings/status: + get: + operationId: get-api-v1-embeddings-status + parameters: + - explode: false + in: query + name: store + schema: + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/DbStripImagesReport" + $ref: "#/components/schemas/VectorBuildStatus" description: OK "400": content: @@ -11562,24 +12638,18 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Remove retained inline tool-result images + summary: Embeddings build status tags: - - Data - /api/v1/data/strip-images/preview: - post: - operationId: post-api-v1-data-strip-images-preview - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/DataStripImagesRequest" - required: true + - Embeddings + /api/v1/events: + get: + operationId: get-api-v1-events responses: "200": content: - application/json: + text/event-stream: schema: - $ref: "#/components/schemas/DbStripImagesReport" + type: string description: OK "400": content: @@ -11611,12 +12681,6 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Conflict - "422": - content: - application/json: - schema: - $ref: "#/components/schemas/ApiErrorResponse" - description: Unprocessable Entity "500": content: application/json: @@ -11647,25 +12711,34 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Preview inline tool-result image removal + summary: Watch server events tags: - - Data - /api/v1/embeddings/build: - post: - operationId: post-api-v1-embeddings-build - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/EmbeddingsBuildRequest" - required: true + - Sessions + /api/v1/friction/digests: + get: + operationId: get-api-v1-friction-digests + parameters: + - description: First digest date (YYYY-MM-DD) + explode: false + in: query + name: from + schema: + description: First digest date (YYYY-MM-DD) + type: string + - description: Last digest date (YYYY-MM-DD) + explode: false + in: query + name: to + schema: + description: Last digest date (YYYY-MM-DD) + type: string responses: - "202": + "200": content: application/json: schema: - $ref: "#/components/schemas/EmbeddingsBuildResponse" - description: Accepted + $ref: "#/components/schemas/FrictionDigestsResponse" + description: OK "400": content: application/json: @@ -11732,24 +12805,26 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Start an embeddings build + summary: List friction digests tags: - - Embeddings - /api/v1/embeddings/generations: + - Friction + /api/v1/friction/digests/{date}: get: - operationId: get-api-v1-embeddings-generations + operationId: get-api-v1-friction-digests-date parameters: - - explode: false - in: query - name: store + - description: Digest date (YYYY-MM-DD) + in: path + name: date + required: true schema: + description: Digest date (YYYY-MM-DD) type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/EmbeddingsGenerationsResponse" + $ref: "#/components/schemas/FrictionDigestResponse" description: OK "400": content: @@ -11817,35 +12892,42 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: List embedding generations + summary: Get friction digest tags: - - Embeddings - /api/v1/embeddings/generations/{id}/activate: - post: - operationId: post-api-v1-embeddings-generations-id-activate + - Friction + /api/v1/friction/digests/{date}/md: + get: + operationId: get-api-v1-friction-digests-date-md parameters: - - description: Generation ordinal ID + - description: Digest date (YYYY-MM-DD) in: path - name: id + name: date required: true schema: - description: Generation ordinal ID - format: int64 - type: integer - - explode: false - in: query - name: store - schema: + description: Digest date (YYYY-MM-DD) type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/EmbeddingsGenerationActionRequest" - required: true responses: - "204": - description: No Content + "200": + content: + text/markdown: + schema: + contentMediaType: application/octet-stream + format: binary + type: string + description: OK + headers: + Cache-Control: + schema: + type: string + Content-Disposition: + schema: + type: string + Content-Type: + schema: + type: string + X-Content-Type-Options: + schema: + type: string "400": content: application/json: @@ -11912,35 +12994,63 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Activate an embedding generation + summary: Get friction digest as Markdown tags: - - Embeddings - /api/v1/embeddings/generations/{id}/retire: - post: - operationId: post-api-v1-embeddings-generations-id-retire + - Friction + /api/v1/friction/findings: + get: + operationId: get-api-v1-friction-findings parameters: - - description: Generation ordinal ID - in: path - name: id - required: true + - description: Only findings from sessions in this digest (YYYY-MM-DD) + explode: false + in: query + name: date schema: - description: Generation ordinal ID + description: Only findings from sessions in this digest (YYYY-MM-DD) + type: string + - description: correction, error, workaround, deferral, pattern, frustration or interruption + explode: false + in: query + name: kind + schema: + description: correction, error, workaround, deferral, pattern, frustration or interruption + type: string + - description: Session ID + explode: false + in: query + name: session_id + schema: + description: Session ID + type: string + - description: Pattern fingerprint (fl1:) + explode: false + in: query + name: fingerprint + schema: + description: Pattern fingerprint (fl1:) + type: string + - description: Page size, 1-1000 (default 100) + explode: false + in: query + name: limit + schema: + description: Page size, 1-1000 (default 100) format: int64 type: integer - - explode: false + - description: Cursor from next_cursor + explode: false in: query - name: store + name: cursor schema: + description: Cursor from next_cursor type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/EmbeddingsGenerationActionRequest" - required: true responses: - "204": - description: No Content + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/FrictionFindingsResponse" + description: OK "400": content: application/json: @@ -12007,24 +13117,55 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Retire an embedding generation + summary: List friction findings tags: - - Embeddings - /api/v1/embeddings/status: + - Friction + /api/v1/friction/patterns: get: - operationId: get-api-v1-embeddings-status + operationId: get-api-v1-friction-patterns parameters: - - explode: false + - description: correction, error, workaround, deferral, pattern, frustration or interruption + explode: false in: query - name: store + name: kind + schema: + description: correction, error, workaround, deferral, pattern, frustration or interruption + type: string + - description: linked, unlinked, pending, failed, needs_human or abandoned + explode: false + in: query + name: link_state + schema: + description: linked, unlinked, pending, failed, needs_human or abandoned + type: string + - description: Only patterns last seen on or after this date (YYYY-MM-DD) + explode: false + in: query + name: since + schema: + description: Only patterns last seen on or after this date (YYYY-MM-DD) + type: string + - description: Page size, 1-1000 (default 100) + explode: false + in: query + name: limit + schema: + description: Page size, 1-1000 (default 100) + format: int64 + type: integer + - description: Cursor from next_cursor + explode: false + in: query + name: cursor schema: + description: Cursor from next_cursor type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/VectorBuildStatus" + $ref: "#/components/schemas/FrictionPatternsResponse" description: OK "400": content: @@ -12092,18 +13233,24 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Embeddings build status + summary: List friction patterns tags: - - Embeddings - /api/v1/events: - get: - operationId: get-api-v1-events + - Friction + /api/v1/friction/run: + post: + operationId: post-api-v1-friction-run + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/FrictionRunRequest" + required: true responses: "200": content: - text/event-stream: + application/json: schema: - type: string + $ref: "#/components/schemas/FrictionRunResponse" description: OK "400": content: @@ -12135,6 +13282,12 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Conflict + "422": + content: + application/json: + schema: + $ref: "#/components/schemas/ApiErrorResponse" + description: Unprocessable Entity "500": content: application/json: @@ -12165,9 +13318,9 @@ paths: schema: $ref: "#/components/schemas/ApiErrorResponse" description: Gateway Timeout - summary: Watch server events + summary: Run friction review tags: - - Sessions + - Friction /api/v1/import/chatgpt: post: operationId: post-api-v1-import-chatgpt From bf889f22d9df2c3dc704c4bd4dfb132774de4a1a Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:46:50 +0000 Subject: [PATCH 58/62] feat(service): friction digest and pattern reads --- internal/friction/review/run_request_test.go | 1 + internal/friction/summary_canon.go | 22 +++ internal/friction/summary_canon_test.go | 37 +++++ internal/server/server.go | 5 +- internal/service/friction.go | 82 ++++++++++ internal/service/friction_direct.go | 77 +++++++++ internal/service/friction_direct_test.go | 86 ++++++++++ internal/servicehttp/friction.go | 159 +++++++++++++++++++ internal/servicehttp/friction_test.go | 67 ++++++++ internal/servicehttp/http.go | 6 +- 10 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 internal/friction/summary_canon.go create mode 100644 internal/friction/summary_canon_test.go create mode 100644 internal/service/friction.go create mode 100644 internal/service/friction_direct.go create mode 100644 internal/service/friction_direct_test.go create mode 100644 internal/servicehttp/friction.go create mode 100644 internal/servicehttp/friction_test.go diff --git a/internal/friction/review/run_request_test.go b/internal/friction/review/run_request_test.go index 2e7340e2a6..ef62b8b3c2 100644 --- a/internal/friction/review/run_request_test.go +++ b/internal/friction/review/run_request_test.go @@ -13,6 +13,7 @@ import ( func TestRunnerRun(t *testing.T) { now := time.Date(2026, 9, 16, 2, 30, 0, 0, time.UTC) newRunner := func(t *testing.T) *Runner { + t.Helper() return &Runner{ Store: dbtest.OpenTestDB(t), Loc: time.UTC, Now: func() time.Time { return now }, BackfillDays: 7, diff --git a/internal/friction/summary_canon.go b/internal/friction/summary_canon.go new file mode 100644 index 0000000000..9e0603eba7 --- /dev/null +++ b/internal/friction/summary_canon.go @@ -0,0 +1,22 @@ +package friction + +import ( + "fmt" + + "go.kenn.io/agentsview/internal/serdejson" +) + +// CanonicalSummaryJSON re-renders a summary JSON document in the byte form +// RenderSummaryJSON produces (serde pretty, sorted keys, trailing newline), +// so a summary that crossed the HTTP API prints the same bytes as the store. +func CanonicalSummaryJSON(raw []byte) ([]byte, error) { + v, err := serdejson.Decode(raw) + if err != nil { + return nil, fmt.Errorf("decoding friction summary: %w", err) + } + out, err := serdejson.Pretty(v) + if err != nil { + return nil, fmt.Errorf("encoding friction summary: %w", err) + } + return append(out, '\n'), nil +} diff --git a/internal/friction/summary_canon_test.go b/internal/friction/summary_canon_test.go new file mode 100644 index 0000000000..cd18809d8c --- /dev/null +++ b/internal/friction/summary_canon_test.go @@ -0,0 +1,37 @@ +package friction + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCanonicalSummaryJSON(t *testing.T) { + golden, err := os.ReadFile(filepath.Join("testdata", "golden", "summary.json")) + require.NoError(t, err) + tests := []struct { + name string + in []byte + }{ + {"identity_on_golden", golden}, + {"compact_reencoding", []byte(`{"schema_version":3,"frustrations":0,"interruptions":0,"corrections":0,"errors":0,"workarounds":0,"deferrals":0,"patterns":0,"p0_alerts":{"bash":["session-a","session-b"]},"sessions_scanned":3,"tracker_failures":0,"spend":null,"created_issues":[],"personas":{},"digest_path":null}`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out, err := CanonicalSummaryJSON(tt.in) + require.NoError(t, err) + assert.Equal(t, byte('\n'), out[len(out)-1]) + again, err := CanonicalSummaryJSON(out) + require.NoError(t, err) + assert.Equal(t, out, again, "canonical form is a fixed point") + }) + } + out, err := CanonicalSummaryJSON(golden) + require.NoError(t, err) + assert.Equal(t, string(golden), string(out)) + _, err = CanonicalSummaryJSON([]byte("{")) + require.Error(t, err) +} diff --git a/internal/server/server.go b/internal/server/server.go index b024d00b26..151a81ef43 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -54,7 +54,10 @@ type VersionInfo struct { // Bump it when a client-visible contract cannot be decoded safely by an older // CLI or daemon. const ( - APIVersion = 10 + APIVersion = 11 + // FrictionAPIVersion is the first daemon API that serves + // /api/v1/friction digests, findings, patterns and runs. + FrictionAPIVersion = 11 // ScopedWatchPushAPIVersion is the first daemon API that accepts bounded // watcher batches and their authoritative recovery scope on push requests. ScopedWatchPushAPIVersion = 7 diff --git a/internal/service/friction.go b/internal/service/friction.go new file mode 100644 index 0000000000..09e407773c --- /dev/null +++ b/internal/service/friction.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "encoding/json/jsontext" + "errors" + "time" +) + +// ErrFrictionDigestNotFound is returned when the requested (or latest) +// digest does not exist. HTTP transports map a 404 back to it. +var ErrFrictionDigestNotFound = errors.New("friction digest not found") + +// FrictionCapability is implemented by services that can read friction +// digests and patterns. MCP registers the friction tools only when true. +type FrictionCapability interface { + SupportsFriction() bool +} + +// FrictionService reads stored friction digests and recurrence patterns. +// Ledger reads live in service.LedgerQueryCapability (PR 15), not here. +type FrictionService interface { + FrictionDigest(ctx context.Context, date string) (*FrictionDigestView, error) + FrictionPatterns(ctx context.Context, f FrictionPatternFilter) ([]FrictionPatternView, error) +} + +// SupportsFriction reports whether svc can serve friction reads. +func SupportsFriction(svc SessionService) bool { + capability, ok := svc.(FrictionCapability) + if !ok || !capability.SupportsFriction() { + return false + } + _, ok = svc.(FrictionService) + return ok +} + +// FrictionDigestView is one stored digest. Summary holds the canonical +// schema-v3 bytes (RenderSummaryJSON form). Date "" in a request means latest. +type FrictionDigestView struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + BuiltAt time.Time `json:"built_at"` + Revision int `json:"revision"` + Markdown string `json:"markdown"` + Summary jsontext.Value `json:"summary"` + WebURL string `json:"web_url,omitempty"` +} + +// FrictionPatternFilter narrows FrictionPatterns. Linked nil = all. +type FrictionPatternFilter struct { + Kind string + Since string + Limit int + Linked *bool +} + +// FrictionPatternView is one recurrence row plus browser links. The Kata +// fields stay empty until issue filing exists (PR 10). +type FrictionPatternView struct { + Fingerprint string `json:"fingerprint"` + Kind string `json:"kind"` + Title string `json:"title"` + FirstSeenDate string `json:"first_seen_date"` + LastSeenDate string `json:"last_seen_date"` + OccurrenceCount int `json:"occurrence_count"` + SessionCount int `json:"session_count"` + LastSubjectID string `json:"last_subject_id"` + LastOrdinal *int `json:"last_ordinal,omitempty"` + LastSessionURL string `json:"last_session_url,omitempty"` + IssueQualifiedID string `json:"issue_qualified_id,omitempty"` + IssueWebURL string `json:"issue_web_url,omitempty"` +} + +func frictionLinkState(linked *bool) string { + if linked == nil { + return "" + } + if *linked { + return "linked" + } + return "unlinked" +} diff --git a/internal/service/friction_direct.go b/internal/service/friction_direct.go new file mode 100644 index 0000000000..a1f66082fd --- /dev/null +++ b/internal/service/friction_direct.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "fmt" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/timeutil" +) + +func (b *directBackend) SupportsFriction() bool { return true } + +func (b *directBackend) FrictionDigest( + ctx context.Context, date string, +) (*FrictionDigestView, error) { + if date == "" { + latest, err := b.db.LatestFrictionDigestDate(ctx) + if err != nil { + return nil, err + } + if latest == "" { + return nil, ErrFrictionDigestNotFound + } + date = latest + } + if !timeutil.IsValidDate(date) { + return nil, fmt.Errorf("invalid date %q: use YYYY-MM-DD", date) + } + d, err := b.db.GetFrictionDigest(ctx, date) + if err != nil { + return nil, err + } + if d == nil { + return nil, ErrFrictionDigestNotFound + } + return &FrictionDigestView{ + Date: d.Date, Timezone: d.Timezone, BuiltAt: d.BuiltAt.UTC(), + Revision: d.Revision, Markdown: string(d.Markdown), Summary: d.SummaryJSON, + }, nil +} + +func (b *directBackend) FrictionPatterns( + ctx context.Context, f FrictionPatternFilter, +) ([]FrictionPatternView, error) { + rows, _, err := b.db.ListFrictionPatterns(ctx, db.FrictionPatternFilter{ + Kind: f.Kind, Since: f.Since, Limit: f.Limit, + LinkState: frictionLinkState(f.Linked), + }) + if err != nil { + return nil, err + } + return frictionPatternViews(rows, func(string, *int) string { return "" }), nil +} + +func frictionPatternViews( + rows []db.FrictionPattern, sessionURL func(id string, ordinal *int) string, +) []FrictionPatternView { + out := make([]FrictionPatternView, 0, len(rows)) + for _, p := range rows { + out = append(out, FrictionPatternView{ + Fingerprint: p.Fingerprint, Kind: p.Kind, Title: p.Title, + FirstSeenDate: p.FirstSeenDate, LastSeenDate: p.LastSeenDate, + OccurrenceCount: p.OccurrenceCount, SessionCount: p.SessionCount, + LastSubjectID: p.LastSubjectID, LastOrdinal: p.LastOrdinal, + LastSessionURL: sessionURL(p.LastSubjectID, p.LastOrdinal), + }) + } + return out +} + +// FrictionPatternViews is exported for the HTTP backend, which decodes the +// same row shape from the API. +func FrictionPatternViews( + rows []db.FrictionPattern, sessionURL func(id string, ordinal *int) string, +) []FrictionPatternView { + return frictionPatternViews(rows, sessionURL) +} diff --git a/internal/service/friction_direct_test.go b/internal/service/friction_direct_test.go new file mode 100644 index 0000000000..c6dad00f24 --- /dev/null +++ b/internal/service/friction_direct_test.go @@ -0,0 +1,86 @@ +package service_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/service" +) + +func seedServiceDigest(t *testing.T, d *db.DB, date string) { + t.Helper() + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: date, Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC), Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{\n \"schema_version\": 3\n}\n"), + Markdown: []byte("# Friction Log — " + date + "\n"), MarkdownSHA256: "s", RunID: "r", + }, nil, []db.FrictionPatternUpdate{ + {Fingerprint: "fl1:" + date, Kind: "error", Title: "t", Date: date, SubjectID: "claude:s1", Ordinal: new(2), Occurrences: 1}, + })) +} + +func TestDirectFrictionService(t *testing.T) { + d := dbtest.OpenTestDB(t) + svc := service.NewDirectBackend(d, nil) + require.True(t, service.SupportsFriction(svc)) + require.True(t, service.SupportsFriction(service.NewReadOnlyBackend(d))) + fs := svc.(service.FrictionService) + + _, err := fs.FrictionDigest(t.Context(), "") + require.ErrorIs(t, err, service.ErrFrictionDigestNotFound) + + seedServiceDigest(t, d, "2026-09-13") + seedServiceDigest(t, d, "2026-09-14") + tests := []struct { + name, in, wantDate string + wantErr error + }{ + {"latest_by_default", "", "2026-09-14", nil}, + {"explicit", "2026-09-13", "2026-09-13", nil}, + {"missing", "2026-01-01", "", service.ErrFrictionDigestNotFound}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + view, err := fs.FrictionDigest(t.Context(), tt.in) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantDate, view.Date) + assert.Equal(t, 1, view.Revision) + assert.Equal(t, "# Friction Log — "+tt.wantDate+"\n", view.Markdown) + assert.JSONEq(t, `{"schema_version":3}`, string(view.Summary)) + assert.Empty(t, view.WebURL, "direct reads have no browser address") + }) + } + t.Run("invalid_date", func(t *testing.T) { + _, err := fs.FrictionDigest(t.Context(), "tomorrow") + require.Error(t, err) + }) + + linked, unlinked := true, false + patternTests := []struct { + name string + filter service.FrictionPatternFilter + want int + }{ + {"all", service.FrictionPatternFilter{}, 2}, + {"since", service.FrictionPatternFilter{Since: "2026-09-14"}, 1}, + {"linked_empty_until_filing_exists", service.FrictionPatternFilter{Linked: &linked}, 0}, + {"unlinked_all", service.FrictionPatternFilter{Linked: &unlinked}, 2}, + {"limit", service.FrictionPatternFilter{Limit: 1}, 1}, + } + for _, tt := range patternTests { + t.Run("patterns_"+tt.name, func(t *testing.T) { + views, err := fs.FrictionPatterns(t.Context(), tt.filter) + require.NoError(t, err) + assert.Len(t, views, tt.want) + }) + } +} diff --git a/internal/servicehttp/friction.go b/internal/servicehttp/friction.go new file mode 100644 index 0000000000..6c52b3a346 --- /dev/null +++ b/internal/servicehttp/friction.go @@ -0,0 +1,159 @@ +package servicehttp + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "go.kenn.io/agentsview/internal/apiclient" + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/service" +) + +// frictionAPIVersion is the first daemon API that serves /api/v1/friction. +const frictionAPIVersion = 11 + +func (b *httpBackend) SupportsFriction() bool { return b.friction } + +func (b *httpBackend) browserPath(path string) string { + base, err := url.Parse(b.browserURL) + if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" { + return "" + } + base.User = nil + base.RawQuery, base.ForceQuery, base.Fragment, base.RawFragment = "", false, "", "" + return strings.TrimRight(base.String(), "/") + path +} + +func (b *httpBackend) FrictionDigest( + ctx context.Context, date string, +) (*service.FrictionDigestView, error) { + api, err := b.apiClient(b.client) + if err != nil { + return nil, err + } + if date == "" { + list, err := api.GetAPIV1FrictionDigestsWithResponse(ctx, &apiclient.GetAPIV1FrictionDigestsRequestOptions{}) + if list == nil { + return nil, err + } + if err := b.frictionError("friction digests", serviceResponseError(list.HTTPResponse, list.Body, err)); err != nil { + return nil, err + } + var page struct { + Digests []struct { + Date string `json:"date"` + } `json:"digests"` + } + if err := json.Unmarshal(list.Body, &page); err != nil { + return nil, fmt.Errorf("decoding friction digests: %w", err) + } + if len(page.Digests) == 0 { + return nil, service.ErrFrictionDigestNotFound + } + date = page.Digests[0].Date + } + detail, err := api.GetAPIV1FrictionDigestsDateWithResponse(ctx, &apiclient.GetAPIV1FrictionDigestsDateRequestOptions{ + PathParams: &apiclient.GetAPIV1FrictionDigestsDatePath{Date: url.PathEscape(date)}, + }) + if detail == nil { + return nil, err + } + if err := b.frictionError("friction digest", serviceResponseError(detail.HTTPResponse, detail.Body, err)); err != nil { + return nil, err + } + var body struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + BuiltAt time.Time `json:"built_at"` + Revision int `json:"revision"` + Summary jsontext.Value `json:"summary"` + } + if err := json.Unmarshal(detail.Body, &body); err != nil { + return nil, fmt.Errorf("decoding friction digest: %w", err) + } + summary, err := friction.CanonicalSummaryJSON(body.Summary) + if err != nil { + return nil, err + } + md, err := api.GetAPIV1FrictionDigestsDateMdWithResponse(ctx, &apiclient.GetAPIV1FrictionDigestsDateMdRequestOptions{ + PathParams: &apiclient.GetAPIV1FrictionDigestsDateMdPath{Date: url.PathEscape(date)}, + }) + if md == nil { + return nil, err + } + if err := b.frictionError("friction digest markdown", serviceResponseError(md.HTTPResponse, md.Body, err)); err != nil { + return nil, err + } + return &service.FrictionDigestView{ + Date: body.Date, Timezone: body.Timezone, BuiltAt: body.BuiltAt, + Revision: body.Revision, Markdown: string(md.Body), Summary: summary, + WebURL: b.browserPath("/friction/" + url.PathEscape(body.Date)), + }, nil +} + +func (b *httpBackend) FrictionPatterns( + ctx context.Context, f service.FrictionPatternFilter, +) ([]service.FrictionPatternView, error) { + api, err := b.apiClient(b.client) + if err != nil { + return nil, err + } + q := &apiclient.GetAPIV1FrictionPatternsQuery{} + if f.Kind != "" { + q.Kind = new(f.Kind) + } + if f.Since != "" { + q.Since = new(f.Since) + } + if f.Limit > 0 { + q.Limit = new(int64(f.Limit)) + } + if f.Linked != nil { + state := "unlinked" + if *f.Linked { + state = "linked" + } + q.LinkState = new(state) + } + resp, err := api.GetAPIV1FrictionPatternsWithResponse(ctx, &apiclient.GetAPIV1FrictionPatternsRequestOptions{Query: q}) + if resp == nil { + return nil, err + } + if err := b.frictionError("friction patterns", serviceResponseError(resp.HTTPResponse, resp.Body, err)); err != nil { + return nil, err + } + var page struct { + Patterns []db.FrictionPattern `json:"patterns"` + } + if err := json.Unmarshal(resp.Body, &page); err != nil { + return nil, fmt.Errorf("decoding friction patterns: %w", err) + } + return service.FrictionPatternViews(page.Patterns, func(id string, ordinal *int) string { + u := b.sessionWebURL(id) + if u == "" || ordinal == nil { + return u + } + return u + "?msg=" + strconv.Itoa(*ordinal) + }), nil +} + +func (b *httpBackend) frictionError(op string, err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, errHTTPNotFound): + return service.ErrFrictionDigestNotFound + case errors.Is(err, errHTTPNotImplemented): + return fmt.Errorf("%s: daemon at %s: %w", op, b.baseURL, db.ErrReadOnly) + default: + return err + } +} diff --git a/internal/servicehttp/friction_test.go b/internal/servicehttp/friction_test.go new file mode 100644 index 0000000000..e48e4fdba0 --- /dev/null +++ b/internal/servicehttp/friction_test.go @@ -0,0 +1,67 @@ +package servicehttp + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/service" +) + +func TestHTTPFrictionService(t *testing.T) { + golden, err := os.ReadFile(filepath.Join("..", "friction", "testdata", "golden", "summary.json")) + require.NoError(t, err) + var paths []string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path+"?"+r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/friction/digests": + _, _ = w.Write([]byte(`{"digests":[{"date":"2026-09-14","timezone":"UTC","rules_version":"friction-v1","built_at":"2026-09-15T01:00:00Z","revision":1,"sessions_scanned":3}]}`)) + case "/api/v1/friction/digests/2026-09-14": + _, _ = w.Write([]byte(`{"date":"2026-09-14","timezone":"UTC","rules_version":"friction-v1","built_at":"2026-09-15T01:00:00Z","revision":1,"sessions_scanned":3,"markdown_sha256":"s","web_url":"","summary":` + string(golden) + `,"signals":[],"p0_alerts":[]}`)) + case "/api/v1/friction/digests/2026-09-14/md": + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write([]byte("# Friction Log — 2026-09-14\n")) + case "/api/v1/friction/patterns": + _, _ = w.Write([]byte(`{"patterns":[{"fingerprint":"fl1:x","kind":"error","title":"t","first_seen_date":"2026-09-10","last_seen_date":"2026-09-14","occurrence_count":4,"session_count":3,"last_subject_id":"claude:s1","last_ordinal":9}],"next_cursor":""}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"friction digest not found"}`)) + } + })) + t.Cleanup(ts.Close) + + svc := NewHTTPBackend(ts.URL+"/", "", false, "") + require.True(t, service.SupportsFriction(svc)) + fs := svc.(service.FrictionService) + + view, err := fs.FrictionDigest(t.Context(), "") + require.NoError(t, err) + assert.Equal(t, "2026-09-14", view.Date) + assert.Equal(t, string(golden), string(view.Summary), "summary bytes are canonical, not re-encoded") + assert.Equal(t, "# Friction Log — 2026-09-14\n", view.Markdown) + assert.Equal(t, ts.URL+"/friction/2026-09-14", view.WebURL) + + _, err = fs.FrictionDigest(t.Context(), "2026-01-01") + require.ErrorIs(t, err, service.ErrFrictionDigestNotFound) + + linked := false + patterns, err := fs.FrictionPatterns(t.Context(), service.FrictionPatternFilter{Kind: "error", Since: "2026-09-01", Limit: 5, Linked: &linked}) + require.NoError(t, err) + require.Len(t, patterns, 1) + assert.Equal(t, ts.URL+"/sessions/claude/s1?msg=9", patterns[0].LastSessionURL) + assert.Contains(t, paths, "/api/v1/friction/patterns?kind=error&limit=5&link_state=unlinked&since=2026-09-01") +} + +func TestHTTPFrictionCapabilityFollowsAPIVersion(t *testing.T) { + old := NewHTTPBackendForServer("http://127.0.0.1:1", "", HTTPServerCapabilities{APIVersion: 10}) + assert.False(t, service.SupportsFriction(old)) + current := NewHTTPBackendForServer("http://127.0.0.1:1", "", HTTPServerCapabilities{APIVersion: frictionAPIVersion}) + assert.True(t, service.SupportsFriction(current)) +} diff --git a/internal/servicehttp/http.go b/internal/servicehttp/http.go index e7c6457939..c48306980d 100644 --- a/internal/servicehttp/http.go +++ b/internal/servicehttp/http.go @@ -82,6 +82,7 @@ type httpBackend struct { longRunningClient *http.Client readOnly bool recallQueries bool + friction bool token string } @@ -103,6 +104,7 @@ type HTTPServerCapabilities struct { // an empty value uses baseURL. func NewHTTPBackend(baseURL, token string, readOnly bool, browserURL string) service.SessionService { b := newHTTPBackend(baseURL, token, readOnly, !readOnly) + b.friction = true if browserURL != "" { b.browserURL = browserURL } @@ -114,13 +116,15 @@ func NewHTTPBackend(baseURL, token string, readOnly bool, browserURL string) ser func NewHTTPBackendForServer( baseURL, token string, capabilities HTTPServerCapabilities, ) service.SessionService { - return newHTTPBackend( + b := newHTTPBackend( baseURL, token, capabilities.ReadOnly, !capabilities.ReadOnly && capabilities.APIVersion >= recallNonRecordingAPIVersion, ) + b.friction = capabilities.APIVersion >= frictionAPIVersion + return b } func newHTTPBackend( From d00b39b520b5f96e01ec05f6f7ad01ac01e0b8d4 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:55:29 +0000 Subject: [PATCH 59/62] feat(cli): add friction run, digest, findings and patterns --- cmd/agentsview/cli.go | 1 + cmd/agentsview/friction.go | 285 ++++++++++++++++++++ cmd/agentsview/friction_backend.go | 408 +++++++++++++++++++++++++++++ cmd/agentsview/friction_test.go | 230 ++++++++++++++++ 4 files changed, 924 insertions(+) create mode 100644 cmd/agentsview/friction.go create mode 100644 cmd/agentsview/friction_backend.go create mode 100644 cmd/agentsview/friction_test.go diff --git a/cmd/agentsview/cli.go b/cmd/agentsview/cli.go index 9dd725a210..db976e4f7b 100644 --- a/cmd/agentsview/cli.go +++ b/cmd/agentsview/cli.go @@ -130,6 +130,7 @@ func newRootCommand() *cobra.Command { root.AddCommand(newMCPCommand()) root.AddCommand(newRecallCommand()) root.AddCommand(newInsightCommand()) + root.AddCommand(newFrictionCommand()) root.AddCommand(newStatsCommand()) root.AddCommand(newParseDiffCommand()) root.AddCommand(newClassifierCommand()) diff --git a/cmd/agentsview/friction.go b/cmd/agentsview/friction.go new file mode 100644 index 0000000000..bd71a1e56d --- /dev/null +++ b/cmd/agentsview/friction.go @@ -0,0 +1,285 @@ +package main + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "io" + "slices" + "strconv" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction/review" +) + +func newFrictionCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "friction", + Short: "Build and read the Friction Log (heuristic session review)", + GroupID: groupData, + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.PersistentFlags().String("server", "", "Remote daemon URL for friction API requests") + cmd.PersistentFlags().String("server-token-file", "", + "File containing bearer token for explicit --server requests") + cmd.AddCommand(newFrictionRunCommand()) + cmd.AddCommand(newFrictionDigestCommand()) + cmd.AddCommand(newFrictionFindingsCommand()) + cmd.AddCommand(newFrictionPatternsCommand()) + return cmd +} + +func newFrictionRunCommand() *cobra.Command { + var req review.RunRequest + var asJSON bool + cmd := &cobra.Command{ + Use: "run", + Short: "Build missing digests, or one date with --date", + Long: "Without flags, builds every complete local date that has no digest yet\n" + + "(within [friction] backfill_days). --date builds one date; --rebuild\n" + + "re-renders an existing one; --dry-run computes without writing.", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + backend, cleanup, err := resolveFrictionBackend(cmd) + if err != nil { + return err + } + defer cleanup() + outcomes, err := backend.Run(cmd.Context(), req) + if err != nil { + return err + } + w := cmd.OutOrStdout() + if asJSON { + for _, o := range outcomes { + if _, err := w.Write(o.SummaryJSON); err != nil { + return err + } + } + return nil + } + if len(outcomes) == 0 { + _, err := fmt.Fprintln(w, "No complete dates need a digest.") + return err + } + for _, o := range outcomes { + human := o.Human + if !strings.HasSuffix(human, "\n") { + human += "\n" + } + if _, err := io.WriteString(w, human); err != nil { + return err + } + } + return nil + }, + } + cmd.Flags().StringVar(&req.Date, "date", "", "Digest date (YYYY-MM-DD)") + cmd.Flags().BoolVar(&req.Rebuild, "rebuild", false, "Rebuild an existing digest, keeping its membership") + cmd.Flags().BoolVar(&req.DryRun, "dry-run", false, "Compute and render without writing") + cmd.Flags().BoolVar(&asJSON, "json", false, "Print the summary JSON object instead of the human summary") + return cmd +} + +func newFrictionDigestCommand() *cobra.Command { + var date, from, to, format string + cmd := &cobra.Command{ + Use: "digest", + Short: "Print a stored digest (Markdown or summary JSON)", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if format != "md" && format != "json" { + return errors.New("--format must be md or json") + } + if date != "" && (from != "" || to != "") { + return errors.New("--date cannot be combined with --from/--to") + } + backend, cleanup, err := resolveFrictionBackend(cmd) + if err != nil { + return err + } + defer cleanup() + w := cmd.OutOrStdout() + if from != "" || to != "" { + items, err := backend.Digests(cmd.Context(), from, to) + if err != nil { + return err + } + for _, item := range slices.Backward(items) { // oldest first + out, err := backend.Digest(cmd.Context(), item.Date) + if err != nil { + return err + } + payload := out.Markdown + if format == "json" { + payload = out.SummaryJSON + } + if _, err := w.Write(payload); err != nil { + return err + } + } + return nil + } + out, err := backend.Digest(cmd.Context(), date) + if errors.Is(err, errFrictionDigestMissing) { + if date == "" { + return errors.New("no friction digests have been built yet; run `agentsview friction run`") + } + return fmt.Errorf("no friction digest for %s", date) + } + if err != nil { + return err + } + payload := out.Markdown + if format == "json" { + payload = out.SummaryJSON + } + _, err = w.Write(payload) + return err + }, + } + cmd.Flags().StringVar(&date, "date", "", "Digest date (YYYY-MM-DD); default is the latest digest") + cmd.Flags().StringVar(&from, "from", "", "First date of a range (YYYY-MM-DD)") + cmd.Flags().StringVar(&to, "to", "", "Last date of a range (YYYY-MM-DD)") + cmd.Flags().StringVar(&format, "format", "md", "Output format: md or json") + return cmd +} + +func newFrictionFindingsCommand() *cobra.Command { + var f db.FrictionFindingFilter + var format string + cmd := &cobra.Command{ + Use: "findings", + Short: "List stored friction findings", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if format != "table" && format != "json" { + return errors.New("--format must be table or json") + } + backend, cleanup, err := resolveFrictionBackend(cmd) + if err != nil { + return err + } + defer cleanup() + page, err := backend.Findings(cmd.Context(), f) + if err != nil { + return err + } + if format == "json" { + return json.MarshalEncode(jsontext.NewEncoder(cmd.OutOrStdout()), page) + } + return printFrictionFindings(cmd.OutOrStdout(), page) + }, + } + cmd.Flags().StringVar(&f.Date, "date", "", "Only sessions in this digest date (YYYY-MM-DD)") + cmd.Flags().StringVar(&f.Kind, "kind", "", "correction, error, workaround, deferral, pattern, frustration or interruption") + cmd.Flags().StringVar(&f.SessionID, "session", "", "Session ID") + cmd.Flags().IntVar(&f.Limit, "limit", 0, "Page size (default 100, max 1000)") + cmd.Flags().StringVar(&f.Cursor, "cursor", "", "Cursor from a previous page") + cmd.Flags().StringVar(&format, "format", "table", "Output format: table or json") + return cmd +} + +func newFrictionPatternsCommand() *cobra.Command { + var f db.FrictionPatternFilter + var linked, unlinked bool + var format string + cmd := &cobra.Command{ + Use: "patterns", + Short: "List recurring friction patterns", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if format != "table" && format != "json" { + return errors.New("--format must be table or json") + } + switch { + case linked: + f.LinkState = db.FrictionLinkStateLinked + case unlinked: + f.LinkState = db.FrictionLinkStateUnlinked + } + backend, cleanup, err := resolveFrictionBackend(cmd) + if err != nil { + return err + } + defer cleanup() + page, err := backend.Patterns(cmd.Context(), f) + if err != nil { + return err + } + if format == "json" { + return json.MarshalEncode(jsontext.NewEncoder(cmd.OutOrStdout()), page) + } + return printFrictionPatterns(cmd.OutOrStdout(), page) + }, + } + cmd.Flags().StringVar(&f.Kind, "kind", "", "correction, error, workaround, deferral, pattern, frustration or interruption") + cmd.Flags().StringVar(&f.Since, "since", "", "Only patterns last seen on or after this date (YYYY-MM-DD)") + cmd.Flags().BoolVar(&linked, "linked", false, "Only patterns linked to a Kata issue") + cmd.Flags().BoolVar(&unlinked, "unlinked", false, "Only patterns without a Kata issue") + cmd.MarkFlagsMutuallyExclusive("linked", "unlinked") + cmd.Flags().IntVar(&f.Limit, "limit", 0, "Page size (default 100, max 1000)") + cmd.Flags().StringVar(&f.Cursor, "cursor", "", "Cursor from a previous page") + cmd.Flags().StringVar(&format, "format", "table", "Output format: table or json") + return cmd +} + +func printFrictionFindings(w io.Writer, page frictionFindingsPage) error { + if len(page.Findings) == 0 { + _, err := fmt.Fprintln(w, "(no friction findings)") + return err + } + tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "SESSION\tMSG\tKIND\tDETECTOR\tTITLE") + for _, f := range page.Findings { + msg := "-" + if f.MessageOrdinal != nil { + msg = strconv.Itoa(*f.MessageOrdinal) + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", sanitizeTerminal(f.SessionID), msg, + sanitizeTerminal(f.Kind), sanitizeTerminal(f.Detector), sanitizeTerminal(f.Title)) + } + if err := tw.Flush(); err != nil { + return err + } + if page.NextCursor != "" { + _, err := fmt.Fprintf(w, "next page: --cursor %s\n", page.NextCursor) + return err + } + return nil +} + +func printFrictionPatterns(w io.Writer, page frictionPatternsPage) error { + if len(page.Patterns) == 0 { + _, err := fmt.Fprintln(w, "(no friction patterns)") + return err + } + tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "COUNT\tSESSIONS\tLAST SEEN\tKIND\tFINGERPRINT\tTITLE") + for _, p := range page.Patterns { + _, _ = fmt.Fprintf(tw, "%d\t%d\t%s\t%s\t%s\t%s\n", p.OccurrenceCount, p.SessionCount, + sanitizeTerminal(p.LastSeenDate), sanitizeTerminal(p.Kind), + sanitizeTerminal(p.Fingerprint), sanitizeTerminal(p.Title)) + } + if err := tw.Flush(); err != nil { + return err + } + if page.NextCursor != "" { + _, err := fmt.Fprintf(w, "next page: --cursor %s\n", page.NextCursor) + return err + } + return nil +} diff --git a/cmd/agentsview/friction_backend.go b/cmd/agentsview/friction_backend.go new file mode 100644 index 0000000000..71f6dfddc2 --- /dev/null +++ b/cmd/agentsview/friction_backend.go @@ -0,0 +1,408 @@ +package main + +import ( + "context" + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/spf13/cobra" + + "go.kenn.io/agentsview/internal/apiclient" + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" +) + +var errFrictionDigestMissing = errors.New("friction digest not found") + +type frictionRunOutcome struct { + Date string + Written bool + SummaryJSON []byte + Human string +} + +type frictionDigestOut struct { + Date string + SummaryJSON []byte + Markdown []byte +} + +type frictionDigestListItem struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + RulesVersion string `json:"rules_version"` + BuiltAt time.Time `json:"built_at"` + Revision int `json:"revision"` + SessionsScanned int `json:"sessions_scanned"` +} + +type frictionFindingOut struct { + SessionID string `json:"session_id"` + Kind string `json:"kind"` + Detector string `json:"detector"` + MessageOrdinal *int `json:"message_ordinal"` + CallIndex *int `json:"call_index"` + ToolName string `json:"tool_name"` + Label string `json:"label"` + Text string `json:"text"` + Evidence string `json:"evidence"` + Title string `json:"title"` + Fingerprint string `json:"fingerprint"` + OccurredAt *time.Time `json:"occurred_at"` + Seq int `json:"seq"` + RulesVersion string `json:"rules_version"` +} + +type frictionFindingsPage struct { + Findings []frictionFindingOut `json:"findings"` + NextCursor string `json:"next_cursor"` +} + +type frictionPatternsPage struct { + Patterns []db.FrictionPattern `json:"patterns"` + NextCursor string `json:"next_cursor"` +} + +type frictionBackend interface { + Run(ctx context.Context, req review.RunRequest) ([]frictionRunOutcome, error) + Digest(ctx context.Context, date string) (frictionDigestOut, error) + Digests(ctx context.Context, from, to string) ([]frictionDigestListItem, error) + Findings(ctx context.Context, f db.FrictionFindingFilter) (frictionFindingsPage, error) + Patterns(ctx context.Context, f db.FrictionPatternFilter) (frictionPatternsPage, error) +} + +// resolveFrictionBackend uses --server, else a running (or auto-started) +// daemon, else the local archive directly (AGENTSVIEW_NO_DAEMON=1). +func resolveFrictionBackend(cmd *cobra.Command) (frictionBackend, func(), error) { + if remote, _ := cmd.Flags().GetString("server"); strings.TrimSpace(remote) != "" { + token, err := explicitServerToken(cmd) + if err != nil { + return nil, nil, err + } + return frictionDaemonBackend{baseURL: strings.TrimSpace(remote), token: token}, func() {}, nil + } + cfg, err := config.LoadPFlags(cmd.Flags()) + if err != nil { + return nil, nil, fmt.Errorf("loading config: %w", err) + } + tr, err := ensureTransportContext(cmd.Context(), &cfg, transportIntentArchiveWrite, 0) + if err != nil { + return nil, nil, err + } + if tr.Mode == transportHTTP { + return frictionDaemonBackend{baseURL: tr.URL, token: cfg.AuthToken}, func() {}, nil + } + database, lock, err := openWriteDB(cmd.Context(), cfg) + if err != nil { + return nil, nil, fmt.Errorf("opening database: %w", err) + } + runner, err := newFrictionRunner(cfg, database, time.Now) + if err != nil { + closeWriteDB(database, lock) + return nil, nil, err + } + return frictionLocalBackend{db: database, runner: runner}, + func() { closeWriteDB(database, lock) }, nil +} + +// --- local --- + +type frictionLocalBackend struct { + db *db.DB + runner *review.Runner +} + +func (b frictionLocalBackend) Run(ctx context.Context, req review.RunRequest) ([]frictionRunOutcome, error) { + if b.runner == nil { + return nil, errors.New("friction review is disabled; remove [friction] enabled = false from config.toml") + } + reports, err := b.runner.Run(ctx, req) + if err != nil { + return nil, err + } + out := make([]frictionRunOutcome, 0, len(reports)) + for _, r := range reports { + out = append(out, frictionRunOutcome{ + Date: r.Date, Written: r.Written, + SummaryJSON: friction.RenderSummaryJSON(r.Snapshot, r.Meta), + Human: friction.HumanSummary(r.Snapshot, r.Meta), + }) + } + return out, nil +} + +func (b frictionLocalBackend) Digest(ctx context.Context, date string) (frictionDigestOut, error) { + if date == "" { + latest, err := b.db.LatestFrictionDigestDate(ctx) + if err != nil { + return frictionDigestOut{}, err + } + if latest == "" { + return frictionDigestOut{}, errFrictionDigestMissing + } + date = latest + } + d, err := b.db.GetFrictionDigest(ctx, date) + if err != nil { + return frictionDigestOut{}, err + } + if d == nil { + return frictionDigestOut{}, errFrictionDigestMissing + } + return frictionDigestOut{Date: d.Date, SummaryJSON: d.SummaryJSON, Markdown: d.Markdown}, nil +} + +func (b frictionLocalBackend) Digests(ctx context.Context, from, to string) ([]frictionDigestListItem, error) { + rows, err := b.db.ListFrictionDigests(ctx, from, to) + if err != nil { + return nil, err + } + out := make([]frictionDigestListItem, 0, len(rows)) + for _, d := range rows { + out = append(out, frictionDigestListItem{ + Date: d.Date, Timezone: d.Timezone, + RulesVersion: d.RulesVersion, BuiltAt: d.BuiltAt.UTC(), Revision: d.Revision, + SessionsScanned: d.SessionsScanned, + }) + } + return out, nil +} + +func (b frictionLocalBackend) Findings(ctx context.Context, f db.FrictionFindingFilter) (frictionFindingsPage, error) { + rows, next, err := b.db.ListFrictionFindings(ctx, f) + if err != nil { + return frictionFindingsPage{}, err + } + page := frictionFindingsPage{Findings: make([]frictionFindingOut, 0, len(rows)), NextCursor: next} + for _, r := range rows { + page.Findings = append(page.Findings, frictionFindingOut{ + SessionID: r.SessionID, Kind: r.Kind, Detector: r.Detector, + MessageOrdinal: r.MessageOrdinal, CallIndex: r.CallIndex, ToolName: r.ToolName, + Label: r.Label, Text: r.Text, Evidence: r.Evidence, Title: r.Title, + Fingerprint: r.Fingerprint, OccurredAt: r.OccurredAt, Seq: r.Seq, + RulesVersion: r.RulesVersion, + }) + } + return page, nil +} + +func (b frictionLocalBackend) Patterns(ctx context.Context, f db.FrictionPatternFilter) (frictionPatternsPage, error) { + rows, next, err := b.db.ListFrictionPatterns(ctx, f) + if err != nil { + return frictionPatternsPage{}, err + } + return frictionPatternsPage{Patterns: rows, NextCursor: next}, nil +} + +// --- daemon --- + +type frictionDaemonBackend struct { + baseURL string + token string +} + +func (b frictionDaemonBackend) client() (*apiclient.Client, error) { + return apiclient.NewHTTPClient(b.baseURL, b.token, &http.Client{Timeout: 0}) +} + +// frictionHTTPCheck turns a non-200 into an error carrying the server's message. +func frictionHTTPCheck(op string, resp *http.Response, body []byte, err error) error { + if resp == nil { + return fmt.Errorf("%s: %w", op, err) + } + if resp.StatusCode == http.StatusNotFound { + return errFrictionDigestMissing + } + if resp.StatusCode != http.StatusOK { + var apiErr struct { + Error string `json:"error"` + } + msg := strings.TrimSpace(string(body)) + if json.Unmarshal(body, &apiErr) == nil && apiErr.Error != "" { + msg = apiErr.Error + } + return fmt.Errorf("%s: HTTP %d: %s", op, resp.StatusCode, msg) + } + return err +} + +func (b frictionDaemonBackend) Run(ctx context.Context, req review.RunRequest) ([]frictionRunOutcome, error) { + api, err := b.client() + if err != nil { + return nil, err + } + resp, err := api.PostAPIV1FrictionRunWithResponse(ctx, &apiclient.PostAPIV1FrictionRunRequestOptions{ + Body: &apiclient.PostAPIV1FrictionRunBody{Date: new(req.Date), Rebuild: new(req.Rebuild), DryRun: new(req.DryRun)}, + }) + if resp == nil { + return nil, fmt.Errorf("friction run: %w", err) + } + if err := frictionHTTPCheck("friction run", resp.HTTPResponse, resp.Body, err); err != nil { + return nil, err + } + var body struct { + Reports []struct { + Date string `json:"date"` + Written bool `json:"written"` + Human string `json:"human"` + Summary jsontext.Value `json:"summary"` + } `json:"reports"` + } + if err := json.Unmarshal(resp.Body, &body); err != nil { + return nil, fmt.Errorf("decoding friction run: %w", err) + } + out := make([]frictionRunOutcome, 0, len(body.Reports)) + for _, r := range body.Reports { + summary, err := friction.CanonicalSummaryJSON(r.Summary) + if err != nil { + return nil, err + } + out = append(out, frictionRunOutcome{Date: r.Date, Written: r.Written, SummaryJSON: summary, Human: r.Human}) + } + return out, nil +} + +func (b frictionDaemonBackend) Digest(ctx context.Context, date string) (frictionDigestOut, error) { + api, err := b.client() + if err != nil { + return frictionDigestOut{}, err + } + if date == "" { + items, err := b.Digests(ctx, "", "") + if err != nil { + return frictionDigestOut{}, err + } + if len(items) == 0 { + return frictionDigestOut{}, errFrictionDigestMissing + } + date = items[0].Date + } + detail, err := api.GetAPIV1FrictionDigestsDateWithResponse(ctx, &apiclient.GetAPIV1FrictionDigestsDateRequestOptions{ + PathParams: &apiclient.GetAPIV1FrictionDigestsDatePath{Date: url.PathEscape(date)}, + }) + if detail == nil { + return frictionDigestOut{}, fmt.Errorf("friction digest: %w", err) + } + if err := frictionHTTPCheck("friction digest", detail.HTTPResponse, detail.Body, err); err != nil { + return frictionDigestOut{}, err + } + var body struct { + Date string `json:"date"` + Summary jsontext.Value `json:"summary"` + } + if err := json.Unmarshal(detail.Body, &body); err != nil { + return frictionDigestOut{}, fmt.Errorf("decoding friction digest: %w", err) + } + summary, err := friction.CanonicalSummaryJSON(body.Summary) + if err != nil { + return frictionDigestOut{}, err + } + md, err := api.GetAPIV1FrictionDigestsDateMdWithResponse(ctx, &apiclient.GetAPIV1FrictionDigestsDateMdRequestOptions{ + PathParams: &apiclient.GetAPIV1FrictionDigestsDateMdPath{Date: url.PathEscape(date)}, + }) + if md == nil { + return frictionDigestOut{}, fmt.Errorf("friction digest markdown: %w", err) + } + if err := frictionHTTPCheck("friction digest markdown", md.HTTPResponse, md.Body, err); err != nil { + return frictionDigestOut{}, err + } + return frictionDigestOut{Date: body.Date, SummaryJSON: summary, Markdown: md.Body}, nil +} + +func (b frictionDaemonBackend) Digests(ctx context.Context, from, to string) ([]frictionDigestListItem, error) { + api, err := b.client() + if err != nil { + return nil, err + } + q := &apiclient.GetAPIV1FrictionDigestsQuery{} + if from != "" { + q.From = new(from) + } + if to != "" { + q.To = new(to) + } + resp, err := api.GetAPIV1FrictionDigestsWithResponse(ctx, &apiclient.GetAPIV1FrictionDigestsRequestOptions{Query: q}) + if resp == nil { + return nil, fmt.Errorf("friction digests: %w", err) + } + if err := frictionHTTPCheck("friction digests", resp.HTTPResponse, resp.Body, err); err != nil { + return nil, err + } + var body struct { + Digests []frictionDigestListItem `json:"digests"` + } + if err := json.Unmarshal(resp.Body, &body); err != nil { + return nil, fmt.Errorf("decoding friction digests: %w", err) + } + return body.Digests, nil +} + +func (b frictionDaemonBackend) Findings(ctx context.Context, f db.FrictionFindingFilter) (frictionFindingsPage, error) { + api, err := b.client() + if err != nil { + return frictionFindingsPage{}, err + } + q := &apiclient.GetAPIV1FrictionFindingsQuery{} + for _, p := range []struct { + dst **string + v string + }{{&q.Date, f.Date}, {&q.Kind, f.Kind}, {&q.SessionID, f.SessionID}, {&q.Fingerprint, f.Fingerprint}, {&q.Cursor, f.Cursor}} { + if p.v != "" { + *p.dst = new(p.v) + } + } + if f.Limit > 0 { + q.Limit = new(int64(f.Limit)) + } + resp, err := api.GetAPIV1FrictionFindingsWithResponse(ctx, &apiclient.GetAPIV1FrictionFindingsRequestOptions{Query: q}) + if resp == nil { + return frictionFindingsPage{}, fmt.Errorf("friction findings: %w", err) + } + if err := frictionHTTPCheck("friction findings", resp.HTTPResponse, resp.Body, err); err != nil { + return frictionFindingsPage{}, err + } + var page frictionFindingsPage + if err := json.Unmarshal(resp.Body, &page); err != nil { + return frictionFindingsPage{}, fmt.Errorf("decoding friction findings: %w", err) + } + return page, nil +} + +func (b frictionDaemonBackend) Patterns(ctx context.Context, f db.FrictionPatternFilter) (frictionPatternsPage, error) { + api, err := b.client() + if err != nil { + return frictionPatternsPage{}, err + } + q := &apiclient.GetAPIV1FrictionPatternsQuery{} + for _, p := range []struct { + dst **string + v string + }{{&q.Kind, f.Kind}, {&q.LinkState, f.LinkState}, {&q.Since, f.Since}, {&q.Cursor, f.Cursor}} { + if p.v != "" { + *p.dst = new(p.v) + } + } + if f.Limit > 0 { + q.Limit = new(int64(f.Limit)) + } + resp, err := api.GetAPIV1FrictionPatternsWithResponse(ctx, &apiclient.GetAPIV1FrictionPatternsRequestOptions{Query: q}) + if resp == nil { + return frictionPatternsPage{}, fmt.Errorf("friction patterns: %w", err) + } + if err := frictionHTTPCheck("friction patterns", resp.HTTPResponse, resp.Body, err); err != nil { + return frictionPatternsPage{}, err + } + var page frictionPatternsPage + if err := json.Unmarshal(resp.Body, &page); err != nil { + return frictionPatternsPage{}, fmt.Errorf("decoding friction patterns: %w", err) + } + return page, nil +} diff --git a/cmd/agentsview/friction_test.go b/cmd/agentsview/friction_test.go new file mode 100644 index 0000000000..0c08ee32bb --- /dev/null +++ b/cmd/agentsview/friction_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/config" + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/friction" + "go.kenn.io/agentsview/internal/friction/review" + "go.kenn.io/agentsview/internal/server" +) + +func readFrictionGolden(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", "internal", "friction", "testdata", "golden", name)) + require.NoError(t, err) + return b +} + +// seedGoldenDigest stores the PR 4 golden summary and Markdown as the +// digest for 2026-05-10, the date in jilog's review-nightly.json fixture. +func seedGoldenDigest(t *testing.T, dataDir string) *db.DB { + t.Helper() + d := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db")) + snapJSON, err := review.EncodeSnapshot(friction.DigestSnapshot{Date: "2026-05-10", Timezone: "UTC", RulesVersion: friction.RulesVersion}) + require.NoError(t, err) + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2026-05-10", Timezone: "UTC", RulesVersion: friction.RulesVersion, + BuiltAt: time.Date(2026, 5, 11, 1, 0, 0, 0, time.UTC), Revision: 1, SessionsScanned: 3, + SnapshotJSON: snapJSON, SummaryJSON: readFrictionGolden(t, "summary.json"), + Markdown: readFrictionGolden(t, "friction-log.md"), MarkdownSHA256: "s", RunID: "r", + }, nil, nil)) + return d +} + +// localServerProxy serves the real API over httptest while presenting the +// Host/Origin the server was configured for. +func localServerProxy(t *testing.T, d *db.DB, dataDir string, opts ...server.Option) *httptest.Server { + t.Helper() + h := server.New(config.Config{Host: "127.0.0.1", DataDir: dataDir}, d, nil, opts...).Handler() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Host = "127.0.0.1:0" + if r.Header.Get("Origin") != "" { + r.Header.Set("Origin", "http://127.0.0.1:0") + } + h.ServeHTTP(w, r) + })) + t.Cleanup(ts.Close) + return ts +} + +func TestNewFrictionCommand_RegistersSubcommands(t *testing.T) { + root := newRootCommand() + cmd, _, err := root.Find([]string{"friction"}) + require.NoError(t, err) + assert.Equal(t, "friction", cmd.Name()) + for _, name := range []string{"run", "digest", "findings", "patterns"} { + child, _, err := root.Find([]string{"friction", name}) + require.NoError(t, err) + assert.Equal(t, name, child.Name()) + } + for _, name := range []string{"server", "server-token-file"} { + assert.NotNil(t, cmd.PersistentFlags().Lookup(name), name) + } +} + +func TestFrictionDigestJSONMatchesGolden(t *testing.T) { + golden := readFrictionGolden(t, "summary.json") + goldenMD := readFrictionGolden(t, "friction-log.md") + tests := []struct { + name string + daemon bool + }{{"local_without_daemon", false}, {"through_daemon", true}} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dataDir := testDataDir(t) + t.Setenv("AGENTSVIEW_NO_DAEMON", "1") + d := seedGoldenDigest(t, dataDir) + args := []string{"friction", "digest", "--date", "2026-05-10"} + if tt.daemon { + ts := localServerProxy(t, d, dataDir) + args = append(args, "--server", ts.URL) + } else { + require.NoError(t, d.Close()) + } + stdout, stderr, err := runInsightCommand(t, append(args, "--format", "json")...) + require.NoError(t, err, stderr) + assert.Equal(t, string(golden), stdout, "byte-identical to the golden") + + stdout, _, err = runInsightCommand(t, args...) + require.NoError(t, err) + assert.Equal(t, string(goldenMD), stdout, "default format is the stored Markdown") + + stdout, _, err = runInsightCommand(t, "friction", "digest", "--format", "json") + if tt.daemon { + stdout, _, err = runInsightCommand(t, "friction", "digest", "--format", "json", "--server", args[len(args)-1]) + } + require.NoError(t, err) + assert.Equal(t, string(golden), stdout, "no --date means the latest digest") + }) + } +} + +func TestFrictionDigestErrors(t *testing.T) { + dataDir := testDataDir(t) + t.Setenv("AGENTSVIEW_NO_DAEMON", "1") + require.NoError(t, dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db")).Close()) + tests := []struct { + name string + args []string + want string + }{ + {"none_yet", []string{"friction", "digest"}, "no friction digests have been built yet"}, + {"missing_date", []string{"friction", "digest", "--date", "2026-01-01"}, "no friction digest for 2026-01-01"}, + {"bad_format", []string{"friction", "digest", "--format", "yaml"}, "--format must be md or json"}, + {"date_and_range", []string{"friction", "digest", "--date", "2026-01-01", "--from", "2026-01-01"}, "--date cannot be combined with --from/--to"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, _, err := runInsightCommand(t, tt.args...) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + assert.Empty(t, stdout) + }) + } +} + +func TestFrictionRunLocalWithoutDaemon(t *testing.T) { + dataDir := testDataDir(t) + t.Setenv("AGENTSVIEW_NO_DAEMON", "1") + require.NoError(t, dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db")).Close()) + + // On by default (D34): no config needed. + stdout, _, err := runInsightCommand(t, "friction", "run", "--date", "2026-09-14", "--dry-run") + require.NoError(t, err) + assert.Contains(t, stdout, "0 corrections, 0 errors, 0 workarounds, 0 deferrals, 0 patterns") + assert.Contains(t, stdout, "0 session(s) scanned") + assert.NotContains(t, stdout, "Digest:", "dry run omits the digest line (jilog review.rs:180-182)") + + stdout, _, err = runInsightCommand(t, "friction", "run", "--date", "2026-09-14", "--json") + require.NoError(t, err) + assert.Contains(t, stdout, "\"schema_version\": 3") + assert.Contains(t, stdout, "\"frustrations\": 0") + assert.Contains(t, stdout, "\"interruptions\": 0") + assert.Contains(t, stdout, "\"digest_path\": \"friction:2026-09-14\"") + + _, _, err = runInsightCommand(t, "friction", "run", "--date", "2999-01-01") + require.Error(t, err) + assert.Contains(t, err.Error(), "not a complete local day") + + require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.toml"), + []byte("[friction]\nenabled = false\n"), 0o600)) + stdout, _, err = runInsightCommand(t, "friction", "run", "--date", "2026-09-13") + require.Error(t, err) + assert.Contains(t, err.Error(), "friction review is disabled") + assert.Empty(t, stdout) +} + +func TestFrictionRunThroughDaemon(t *testing.T) { + dataDir := testDataDir(t) + d := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db")) + fixed := time.Date(2026, 9, 16, 2, 0, 0, 0, time.UTC) + runner := &review.Runner{Store: d, Loc: time.UTC, Now: func() time.Time { return fixed }, BackfillDays: 7} + ts := localServerProxy(t, d, dataDir, server.WithFriction(runner, nil)) + + stdout, _, err := runInsightCommand(t, "friction", "run", "--server", ts.URL, "--date", "2026-09-14", "--json") + require.NoError(t, err) + out, err := friction.CanonicalSummaryJSON([]byte(stdout)) + require.NoError(t, err) + assert.Equal(t, string(out), stdout, "daemon run output is canonical summary bytes") + + stdout, _, err = runInsightCommand(t, "friction", "findings", "--server", ts.URL, "--format", "json") + require.NoError(t, err) + assert.JSONEq(t, `{"findings":[],"next_cursor":""}`, stdout) + + stdout, _, err = runInsightCommand(t, "friction", "patterns", "--server", ts.URL) + require.NoError(t, err) + assert.Equal(t, "(no friction patterns)\n", stdout) +} + +func TestFrictionFindingsAndPatternsLocal(t *testing.T) { + dataDir := testDataDir(t) + t.Setenv("AGENTSVIEW_NO_DAEMON", "1") + d := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db")) + dbtest.SeedSession(t, d, "claude:s1", "proj") + require.NoError(t, d.ReplaceSessionFriction(t.Context(), "claude:s1", []db.FrictionFinding{ + { + SessionID: "claude:s1", Kind: "error", Detector: "error", ToolName: "Bash", MessageOrdinal: new(3), + Title: "[friction/error] Bash: boom", Fingerprint: "fl1:x", Seq: 0, RulesVersion: friction.RulesVersion, + }, + }, nil, friction.RulesVersion, "h")) + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2026-09-14", Timezone: "UTC", RulesVersion: friction.RulesVersion, BuiltAt: time.Now().UTC(), + Revision: 1, SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{}\n"), Markdown: []byte("x"), + MarkdownSHA256: "s", RunID: "r", + }, []db.FrictionDigestSubject{{SubjectID: "claude:s1", Date: "2026-09-14", SubjectKind: "session"}}, + []db.FrictionPatternUpdate{{Fingerprint: "fl1:x", Kind: "error", Title: "[friction/error] Bash: boom", Date: "2026-09-14", SubjectID: "claude:s1", Ordinal: new(3), Occurrences: 1}})) + require.NoError(t, d.Close()) + + tests := []struct { + name string + args []string + want []string + }{ + {"findings_table", []string{"friction", "findings", "--date", "2026-09-14"}, []string{"SESSION", "claude:s1", "error", "[friction/error] Bash: boom"}}, + {"findings_kind_filter_empty", []string{"friction", "findings", "--kind", "deferral"}, []string{"(no friction findings)"}}, + {"patterns_table", []string{"friction", "patterns"}, []string{"COUNT", "fl1:x", "2026-09-14"}}, + {"patterns_linked_empty", []string{"friction", "patterns", "--linked"}, []string{"(no friction patterns)"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, _, err := runInsightCommand(t, tt.args...) + require.NoError(t, err) + for _, w := range tt.want { + assert.Contains(t, stdout, w) + } + }) + } + _, _, err := runInsightCommand(t, "friction", "patterns", "--linked", "--unlinked") + require.Error(t, err) +} From e88defbb1330253c8ac1e57fa752a08163243be1 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:59:46 +0000 Subject: [PATCH 60/62] feat(mcp): add read-only friction digest and pattern tools --- cmd/agentsview/mcp.go | 35 +++++++- internal/mcp/friction_tools.go | 126 ++++++++++++++++++++++++++++ internal/mcp/friction_tools_test.go | 104 +++++++++++++++++++++++ internal/mcp/server.go | 35 ++++++-- internal/mcp/server_test.go | 6 +- internal/mcp/tools_test.go | 1 + 6 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 internal/mcp/friction_tools.go create mode 100644 internal/mcp/friction_tools_test.go diff --git a/cmd/agentsview/mcp.go b/cmd/agentsview/mcp.go index b33843ccb6..c6f9dfe0e8 100644 --- a/cmd/agentsview/mcp.go +++ b/cmd/agentsview/mcp.go @@ -36,7 +36,8 @@ func newMCPCommand() *cobra.Command { StreamableHTTP, exposing read-only tools for searching and reading recorded agent sessions: search_sessions, list_sessions, get_session_overview, get_messages, search_content, and -get_usage_summary, plus query_recall for distilled session knowledge. +get_usage_summary, plus query_recall for distilled session knowledge and +get_friction_digest/list_friction_patterns for the Friction Log. The server reads through the daemon path. By default each tool call talks to the local agentsview daemon, starting it when needed so a long-lived MCP server @@ -200,6 +201,38 @@ func (s *mcpDaemonService) SupportsRecallQueries() bool { return runtime == nil || !runtime.ReadOnly } +// SupportsFriction is true: the daemon (started on demand) serves the +// friction read routes; an older daemon is upgraded by the transport. +func (s *mcpDaemonService) SupportsFriction() bool { return true } + +func (s *mcpDaemonService) FrictionDigest( + ctx context.Context, date string, +) (*service.FrictionDigestView, error) { + svc, err := s.daemonService(ctx) + if err != nil { + return nil, err + } + fs, ok := svc.(service.FrictionService) + if !ok { + return nil, errors.New("daemon backend does not serve friction digests") + } + return fs.FrictionDigest(ctx, date) +} + +func (s *mcpDaemonService) FrictionPatterns( + ctx context.Context, f service.FrictionPatternFilter, +) ([]service.FrictionPatternView, error) { + svc, err := s.daemonService(ctx) + if err != nil { + return nil, err + } + fs, ok := svc.(service.FrictionService) + if !ok { + return nil, errors.New("daemon backend does not serve friction patterns") + } + return fs.FrictionPatterns(ctx, f) +} + func (s *mcpDaemonService) daemonService( ctx context.Context, ) (service.SessionService, error) { diff --git a/internal/mcp/friction_tools.go b/internal/mcp/friction_tools.go new file mode 100644 index 0000000000..fdc2a814b0 --- /dev/null +++ b/internal/mcp/friction_tools.go @@ -0,0 +1,126 @@ +package mcp + +import ( + "cmp" + "context" + "encoding/json/v2" + "errors" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "go.kenn.io/agentsview/internal/service" +) + +const ( + defaultFrictionPatternLimit = 20 + maxFrictionPatternLimit = 100 +) + +// --- get_friction_digest --- + +type frictionDigestIn struct { + Date string `json:"date,omitempty" jsonschema:"Digest date (YYYY-MM-DD). Defaults to the latest digest."` + Format string `json:"format,omitempty" jsonschema:"markdown (default) or summary (the counts-only JSON object)."` +} + +type frictionDigestOut struct { + Date string `json:"date"` + Timezone string `json:"timezone"` + Revision int `json:"revision"` + Format string `json:"format"` + Markdown string `json:"markdown,omitempty"` + Summary map[string]any `json:"summary,omitempty"` + WebURL string `json:"web_url,omitempty" jsonschema:"Browser URL for this digest; use this URL when linking to it."` +} + +func (t *toolset) frictionDigest( + ctx context.Context, _ *mcp.CallToolRequest, in frictionDigestIn, +) (*mcp.CallToolResult, frictionDigestOut, error) { + fs, ok := t.svc.(service.FrictionService) + if !ok { + return nil, frictionDigestOut{}, errors.New("friction digests are not available from this backend") + } + format := cmp.Or(in.Format, "markdown") + if format != "markdown" && format != "summary" { + return nil, frictionDigestOut{}, errors.New("format must be markdown or summary") + } + view, err := fs.FrictionDigest(ctx, in.Date) + if errors.Is(err, service.ErrFrictionDigestNotFound) { + if in.Date == "" { + return nil, frictionDigestOut{}, errors.New("no friction digests have been built yet") + } + return nil, frictionDigestOut{}, fmt.Errorf("no friction digest for %s", in.Date) + } + if err != nil { + return nil, frictionDigestOut{}, err + } + out := frictionDigestOut{ + Date: view.Date, Timezone: view.Timezone, Revision: view.Revision, + Format: format, WebURL: view.WebURL, + } + if format == "markdown" { + out.Markdown = view.Markdown + return nil, out, nil + } + if err := json.Unmarshal(view.Summary, &out.Summary); err != nil { + return nil, frictionDigestOut{}, fmt.Errorf("decoding friction summary: %w", err) + } + return nil, out, nil +} + +// --- list_friction_patterns --- + +type listFrictionPatternsIn struct { + Kind string `json:"kind,omitempty" jsonschema:"correction, error, workaround, deferral, pattern, frustration or interruption."` + Since string `json:"since,omitempty" jsonschema:"Only patterns last seen on or after this date (YYYY-MM-DD)."` + Limit int `json:"limit,omitempty" jsonschema:"Maximum patterns (default 20, max 100)."` + Linked *bool `json:"linked,omitempty" jsonschema:"true: only patterns linked to a Kata issue; false: only unlinked."` +} + +type frictionPatternOut struct { + Fingerprint string `json:"fingerprint"` + Kind string `json:"kind"` + Title string `json:"title"` + FirstSeenDate string `json:"first_seen_date"` + LastSeenDate string `json:"last_seen_date"` + OccurrenceCount int `json:"occurrence_count"` + SessionCount int `json:"session_count"` + LastSessionID string `json:"last_session_id"` + LastOrdinal *int `json:"last_ordinal,omitempty"` + WebURL string `json:"web_url,omitempty" jsonschema:"Browser URL for the most recent occurrence; use this URL when linking to it."` + KataQualifiedID string `json:"kata_qualified_id,omitempty"` + KataWebURL string `json:"kata_web_url,omitempty"` +} + +type listFrictionPatternsOut struct { + Patterns []frictionPatternOut `json:"patterns"` +} + +func (t *toolset) listFrictionPatterns( + ctx context.Context, _ *mcp.CallToolRequest, in listFrictionPatternsIn, +) (*mcp.CallToolResult, listFrictionPatternsOut, error) { + fs, ok := t.svc.(service.FrictionService) + if !ok { + return nil, listFrictionPatternsOut{}, errors.New("friction patterns are not available from this backend") + } + views, err := fs.FrictionPatterns(ctx, service.FrictionPatternFilter{ + Kind: in.Kind, Since: in.Since, Linked: in.Linked, + Limit: clampLimit(in.Limit, defaultFrictionPatternLimit, maxFrictionPatternLimit), + }) + if err != nil { + return nil, listFrictionPatternsOut{}, err + } + out := listFrictionPatternsOut{Patterns: make([]frictionPatternOut, 0, len(views))} + for _, v := range views { + out.Patterns = append(out.Patterns, frictionPatternOut{ + Fingerprint: v.Fingerprint, Kind: v.Kind, Title: v.Title, + FirstSeenDate: v.FirstSeenDate, LastSeenDate: v.LastSeenDate, + OccurrenceCount: v.OccurrenceCount, SessionCount: v.SessionCount, + LastSessionID: v.LastSubjectID, LastOrdinal: v.LastOrdinal, + WebURL: v.LastSessionURL, KataQualifiedID: v.IssueQualifiedID, + KataWebURL: v.IssueWebURL, + }) + } + return nil, out, nil +} diff --git a/internal/mcp/friction_tools_test.go b/internal/mcp/friction_tools_test.go new file mode 100644 index 0000000000..0f2bea242a --- /dev/null +++ b/internal/mcp/friction_tools_test.go @@ -0,0 +1,104 @@ +package mcp + +import ( + "encoding/json/v2" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.kenn.io/agentsview/internal/db" + "go.kenn.io/agentsview/internal/dbtest" + "go.kenn.io/agentsview/internal/service" +) + +func TestFrictionTools(t *testing.T) { + d := dbtest.OpenTestDB(t) + srv := newServer(ServeOptions{Service: service.NewDirectBackend(d, nil), Now: func() time.Time { return fixedNow }}) + st, ct := newInMemoryPair(t, srv) + defer func() { + require.NoError(t, ct.Close()) + require.NoError(t, st.Wait()) + }() + + res, err := ct.CallTool(t.Context(), callParams(ToolGetFrictionDigest, map[string]any{})) + require.NoError(t, err) + assert.True(t, res.IsError, "no digests yet is a tool error") + + require.NoError(t, d.SaveFrictionDigest(t.Context(), db.FrictionDigest{ + Date: "2026-09-14", Timezone: "UTC", RulesVersion: "friction-v1", + BuiltAt: time.Date(2026, 9, 15, 1, 0, 0, 0, time.UTC), Revision: 1, + SnapshotJSON: []byte("{}"), SummaryJSON: []byte("{\n \"corrections\": 2,\n \"schema_version\": 3\n}\n"), + Markdown: []byte("# Friction Log — 2026-09-14\n"), MarkdownSHA256: "s", RunID: "r", + }, nil, []db.FrictionPatternUpdate{ + {Fingerprint: "fl1:a", Kind: "error", Title: "[friction/error] Bash: boom", Date: "2026-09-14", SubjectID: "claude:s1", Occurrences: 3}, + {Fingerprint: "fl1:b", Kind: "correction", Title: "[friction/correction] claude:s2: no", Date: "2026-09-14", SubjectID: "claude:s2", Occurrences: 1}, + })) + + tests := []struct { + name string + args map[string]any + isErr bool + check func(t *testing.T, out map[string]any) + }{ + {"markdown_latest", map[string]any{}, false, func(t *testing.T, out map[string]any) { + t.Helper() + assert.Equal(t, "2026-09-14", out["date"]) + assert.Equal(t, "markdown", out["format"]) + assert.Equal(t, "# Friction Log — 2026-09-14\n", out["markdown"]) + assert.Nil(t, out["summary"]) + }}, + {"summary", map[string]any{"date": "2026-09-14", "format": "summary"}, false, func(t *testing.T, out map[string]any) { + t.Helper() + summary, ok := out["summary"].(map[string]any) + require.True(t, ok) + assert.EqualValues(t, 2, summary["corrections"]) + assert.Nil(t, out["markdown"]) + }}, + {"missing_date", map[string]any{"date": "2026-01-01"}, true, nil}, + {"bad_format", map[string]any{"format": "html"}, true, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res, err := ct.CallTool(t.Context(), callParams(ToolGetFrictionDigest, tt.args)) + require.NoError(t, err) + require.Equal(t, tt.isErr, res.IsError) + if tt.check == nil { + return + } + raw, err := json.Marshal(res.StructuredContent) + require.NoError(t, err) + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + tt.check(t, out) + }) + } + + patternTests := []struct { + name string + args map[string]any + want []string + }{ + {"ranked", map[string]any{}, []string{"fl1:a", "fl1:b"}}, + {"kind", map[string]any{"kind": "correction"}, []string{"fl1:b"}}, + {"limit", map[string]any{"limit": 1}, []string{"fl1:a"}}, + {"linked_empty", map[string]any{"linked": true}, []string{}}, + } + for _, tt := range patternTests { + t.Run("patterns_"+tt.name, func(t *testing.T) { + res, err := ct.CallTool(t.Context(), callParams(ToolListFrictionPatterns, tt.args)) + require.NoError(t, err) + require.False(t, res.IsError) + raw, err := json.Marshal(res.StructuredContent) + require.NoError(t, err) + var out listFrictionPatternsOut + require.NoError(t, json.Unmarshal(raw, &out)) + got := make([]string, 0, len(out.Patterns)) + for _, p := range out.Patterns { + got = append(got, p.Fingerprint) + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 484ec4ea77..398ff45d80 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -24,13 +24,15 @@ import ( // Tool names. The same constant is used to register a tool and to refer // to it in tests. const ( - ToolSearchSessions = "search_sessions" - ToolQueryRecall = "query_recall" - ToolListSessions = "list_sessions" - ToolGetSessionOverview = "get_session_overview" - ToolGetMessages = "get_messages" - ToolSearchContent = "search_content" - ToolGetUsageSummary = "get_usage_summary" + ToolSearchSessions = "search_sessions" + ToolQueryRecall = "query_recall" + ToolListSessions = "list_sessions" + ToolGetSessionOverview = "get_session_overview" + ToolGetMessages = "get_messages" + ToolSearchContent = "search_content" + ToolGetUsageSummary = "get_usage_summary" + ToolGetFrictionDigest = "get_friction_digest" + ToolListFrictionPatterns = "list_friction_patterns" ) // ServeOptions configures the MCP server. Service is required; Version is @@ -146,6 +148,25 @@ func newServer(opts ServeOptions) *mcp.Server { Annotations: readOnly, }, t.usageSummary) + if service.SupportsFriction(opts.Service) { + mcp.AddTool(s, &mcp.Tool{ + Name: ToolGetFrictionDigest, + Description: "Read one dated Friction Log digest: heuristic findings (user corrections, " + + "tool errors, workarounds, deferrals, health patterns, frustration, interruptions) and P0 " + + "alerts for one local day. " + + "format markdown returns the rendered digest; summary returns the counts-only object. " + + "Findings are deterministic heuristics, not ground truth.", + Annotations: readOnly, + }, t.frictionDigest) + mcp.AddTool(s, &mcp.Tool{ + Name: ToolListFrictionPatterns, + Description: "List recurring friction patterns ranked by occurrences across digests, with " + + "first/last seen dates, distinct session counts, and a web_url for the latest occurrence. " + + "Filter by kind, since (YYYY-MM-DD) or linked.", + Annotations: readOnly, + }, t.listFrictionPatterns) + } + return s } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 5e7713ac09..6010a294a8 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -43,7 +43,7 @@ func callParams(name string, args map[string]any) *mcp.CallToolParams { return &mcp.CallToolParams{Name: name, Arguments: args} } -func TestNewServer_RegistersSevenReadOnlyTools(t *testing.T) { +func TestNewServer_RegistersNineReadOnlyTools(t *testing.T) { d := dbtest.OpenTestDB(t) srv := newServer(ServeOptions{ Service: service.NewDirectBackend(d, nil), @@ -54,7 +54,7 @@ func TestNewServer_RegistersSevenReadOnlyTools(t *testing.T) { st, ct := newInMemoryPair(t, srv) tools, err := ct.ListTools(t.Context(), nil) require.NoError(t, err) - require.Len(t, tools.Tools, 7) + require.Len(t, tools.Tools, 9) for _, tl := range tools.Tools { require.NotNil(t, tl.Annotations, "tool %s missing annotations", tl.Name) require.True(t, tl.Annotations.ReadOnlyHint, @@ -74,7 +74,7 @@ func TestNewServer_OmitsRecallToolForUnsupportedBackend(t *testing.T) { st, ct := newInMemoryPair(t, srv) tools, err := ct.ListTools(t.Context(), nil) require.NoError(t, err) - require.Len(t, tools.Tools, 6) + require.Len(t, tools.Tools, 8) for _, tool := range tools.Tools { assert.NotEqual(t, ToolQueryRecall, tool.Name) } diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go index 5822d1d5b8..62c155c4b1 100644 --- a/internal/mcp/tools_test.go +++ b/internal/mcp/tools_test.go @@ -1298,6 +1298,7 @@ func TestServer_EndToEnd(t *testing.T) { assert.ElementsMatch(t, []string{ ToolSearchSessions, ToolQueryRecall, ToolListSessions, ToolGetSessionOverview, ToolGetMessages, ToolSearchContent, ToolGetUsageSummary, + ToolGetFrictionDigest, ToolListFrictionPatterns, }, names) res, err := ct.CallTool(ctx, callParams("search_sessions", map[string]any{ From 947f80f18710f74eb45ab62b91d59fe01e4696cb Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:26:56 +0000 Subject: [PATCH 61/62] docs: add friction log reader guide and command reference --- docs/changelog.md | 7 +++ docs/commands.md | 33 +++++++++++++ docs/friction-log.md | 84 ++++++++++++++++++++++++++++++++ docs/llms.txt | 1 + docs/mcp.md | 26 ++++++---- docs/scripts/check_built_site.py | 1 + docs/zensical.toml | 1 + 7 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 docs/friction-log.md diff --git a/docs/changelog.md b/docs/changelog.md index f880ff6090..6ba1896403 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,4 +1,5 @@ --- +last_edited: 2026-09-24 title: Changelog description: Release history for AgentsView --- @@ -10,6 +11,12 @@ The latest published release is **New features** +- Friction Log digests, findings and recurring patterns can now be read and + built from the API, CLI and MCP. `agentsview friction run` builds missing + complete days, while `friction digest`, `friction findings` and + `friction patterns` read the results. MCP clients can read digests and + patterns with two new tools. The findings are heuristics, so check the + linked session before acting on one. - AgentsView now builds a Friction Log digest for every completed local day, including days the daemon was not running (up to `backfill_days`, default 7). It is on by default; set `[friction] enabled = false` to turn it off. diff --git a/docs/commands.md b/docs/commands.md index 6afe8069ae..c85d7f0ca4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,4 +1,5 @@ --- +last_edited: 2026-09-24 title: CLI Reference description: All AgentsView commands, flags, and environment variables --- @@ -1560,6 +1561,38 @@ sent to an explicitly supplied server. ______________________________________________________________________ +### `agentsview friction` + +Build and read the [Friction Log](friction-log.md), a daily heuristic review of +corrections, tool errors, workarounds, deferrals, frustration, interruptions and +recurring patterns. It is on by default; `[friction] enabled = false` turns it +off. + +```bash +agentsview friction run [--date YYYY-MM-DD] [--rebuild] [--dry-run] [--json] +agentsview friction digest [--date D | --from D --to D] [--format md|json] +agentsview friction findings [--date D] [--kind K] [--session ID] [--format table|json] +agentsview friction patterns [--kind K] [--since D] [--linked | --unlinked] [--format table|json] +``` + +`friction run` without flags builds every complete local day that has no digest +yet. It prints jilog-compatible summary lines, or the summary JSON object per +day with `--json`, and exits non-zero only when the review fails. +`friction digest` prints the latest digest unless `--date` or a range is given; +`--format json` prints the counts-only summary object, byte-identical whether it +comes from the daemon or the local archive. `--linked` and `--unlinked` filter +patterns by issue linkage. Issue filing arrives in a later update, so currently +no pattern is linked. `--kind` accepts `correction`, `error`, `workaround`, +`deferral`, `pattern`, `frustration` and `interruption`. + +Commands use the running daemon (starting one when needed) or, with +`AGENTSVIEW_NO_DAEMON=1`, read and write the local archive directly. An explicit +`--server ` targets a running server; provide its token with +`AGENTSVIEW_SERVER_TOKEN` or `--server-token-file `. `friction run` +refuses to run where `[friction] enabled = false`. + +______________________________________________________________________ + ### `agentsview mcp` Run a read-only Model Context Protocol server for assistant clients that can diff --git a/docs/friction-log.md b/docs/friction-log.md new file mode 100644 index 0000000000..654f1a54bf --- /dev/null +++ b/docs/friction-log.md @@ -0,0 +1,84 @@ +--- +last_edited: 2026-09-24 +title: Friction Log +description: Daily heuristic review of corrections, tool errors, workarounds, deferrals, frustration, interruptions and recurring patterns +--- + +The Friction Log reviews your archived sessions once per local day and writes a +dated digest of the places where work went wrong. It raises a P0 alert when the +same tool fails in three or more sessions, and it tracks how often each pattern +recurs across days. + +These findings are heuristics, not ground truth. Every finding comes from +regular expressions and thresholds applied to stored session data; no model +decides a finding. Expect false positives, especially for corrections, +workarounds and frustration, and use the links to read the session before acting +on one. + +The review logic is adapted from [jilog](https://github.com/Joi/jilog)'s session +review (MIT). + +## What it detects + +| Kind | What it means | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Correction | A short user reply right after an assistant turn, between two assistant turns | +| Error | A tool call that failed or was cancelled, minus known noise such as a bare command timeout | +| Workaround | An assistant message that says "for now", "temporary", "hack", "TODO" and similar | +| Deferral | An assistant message that puts work off ("I'll come back to this", "next session") | +| Pattern | Session health: retry loops, runaway tool loops, edit churn, mid-task compaction, context pressure, and long stretches of tool calls with no user message | +| Frustration | A user message with frustration phrases ("still broken", "same error") or mostly capital letters, using the same rule as the Quality page | +| Interruption | A turn the user interrupted. Only Claude Code sessions record interruptions today | + +## On by default + +The Friction Log is on by default. The daemon checks every hour for complete +local days without a digest and builds them. Today is never built, because it is +not finished. Each session is reviewed once, in the digest for the day of its +last activity; messages added after that day's digest was built still show on +the session, but not in a later digest. + +To turn it off, or to pick the time zone that dates the digests: + +``` +[friction] +enabled = false # default true +timezone = "" # IANA zone; empty uses the machine's local zone +backfill_days = 7 # how far back the first run looks +``` + +Issue filing is planned for a later update. Digests and pattern reads work +without it. + +## Read digests + +- CLI: `agentsview friction digest` prints the latest digest as Markdown. + `--date YYYY-MM-DD` picks a day, and `--format json` prints the counts-only + summary object. +- API: `GET /api/v1/friction/digests`, `GET /api/v1/friction/digests/{date}` and + `GET /api/v1/friction/digests/{date}/md`. +- MCP: `get_friction_digest` and `list_friction_patterns`. + +## Build on demand + +`agentsview friction run` builds every missing complete day. `--date` builds one +day, `--rebuild` re-renders an existing day while keeping the sessions it +already covered, and `--dry-run` computes without writing. The command prints +one summary line per day, or the summary object with `--json`. It exits non-zero +only when the review itself fails. + +## Findings and patterns + +`agentsview friction findings` lists stored findings, filtered by `--date`, +`--kind` or `--session`. `agentsview friction patterns` ranks recurring patterns +by how many times they occurred, with the first and last day seen. Each pattern +has a fingerprint (`fl1:` plus a SHA-256 of its title) that stays the same +across days. + +## Limits + +- The review reads only stored session data; it needs no transcript files. +- On `pg serve`, the hub builds its own digests from pushed sessions. Digests + are not pushed between stores. +- DuckDB and ClickHouse mirrors do not serve the Friction Log. +- Session links in digests need `public_url` to be set. diff --git a/docs/llms.txt b/docs/llms.txt index 7c37cc0931..78b79f8fbf 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -31,6 +31,7 @@ The Markdown pages are canonical for machine readers; the HTML routes drop the - [Session Intelligence](https://agentsview.io/docs/session-intelligence.md): Health scores, outcomes, and session-quality analytics. - [Quality](https://agentsview.io/docs/quality.md): Deterministic quality signals and recommendations computed from session data. +- [Friction Log](https://agentsview.io/docs/friction-log.md): Daily heuristic review of corrections, tool errors, workarounds, deferrals, frustration, interruptions and recurring patterns. - [Semantic Search](https://agentsview.io/docs/semantic-search.md): Vector search over session messages, plus hybrid search and cursor-based context retrieval. - [Semantic Search Internals](https://agentsview.io/docs/semantic-search-internals.md): Architecture and invariants behind the vector index. - [Recall](https://agentsview.io/docs/recall.md): Experimental, provenance-linked durable knowledge over the local session archive. diff --git a/docs/mcp.md b/docs/mcp.md index 583dfe3647..329c5e723c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,4 +1,5 @@ --- +last_edited: 2026-09-24 title: MCP Server description: Connect assistant clients to your AgentsView session history with MCP --- @@ -56,15 +57,22 @@ For local desktop-style MCP clients, use stdio: Restart or reload your MCP client after adding the server. Once connected, the client will see these tools: -| Tool | Purpose | -| ---------------------- | ------------------------------------------------------------------------ | -| `search_sessions` | Full-text search across recorded sessions | -| `list_sessions` | List recent or filtered sessions | -| `get_session_overview` | Fetch metadata and a compact message preview | -| `get_messages` | Read paginated message bodies from one session | -| `search_content` | Substring, regex, terms, semantic, or hybrid search over session text | -| `get_usage_summary` | Aggregate token and cost usage | -| `query_recall` | Search extracted Recall entries when the backend supports Recall queries | +| Tool | Purpose | +| ------------------------ | ------------------------------------------------------------------------ | +| `search_sessions` | Full-text search across recorded sessions | +| `list_sessions` | List recent or filtered sessions | +| `get_session_overview` | Fetch metadata and a compact message preview | +| `get_messages` | Read paginated message bodies from one session | +| `search_content` | Substring, regex, terms, semantic, or hybrid search over session text | +| `get_usage_summary` | Aggregate token and cost usage | +| `query_recall` | Search extracted Recall entries when the backend supports Recall queries | +| `get_friction_digest` | Read a dated Friction Log digest as Markdown or the summary object | +| `list_friction_patterns` | List recurring friction patterns ranked by occurrences | + +`get_friction_digest` defaults to the latest digest; pass `date` (YYYY-MM-DD) +and `format` (`markdown` or `summary`). `list_friction_patterns` accepts `kind`, +`since`, `limit` (default 20, max 100) and `linked`. Both are read-only and +report [Friction Log](friction-log.md) heuristics, not ground truth. `search_sessions` accepts optional `date_from` and `date_to` bounds in `YYYY-MM-DD` format, just like `list_sessions` and `search_content`. Dates diff --git a/docs/scripts/check_built_site.py b/docs/scripts/check_built_site.py index c096ff9673..9b71d4e0d9 100755 --- a/docs/scripts/check_built_site.py +++ b/docs/scripts/check_built_site.py @@ -29,6 +29,7 @@ "one-shot-capture", "chat-import", "quality", + "friction-log", "commands", "session-export", "conversation-export", diff --git a/docs/zensical.toml b/docs/zensical.toml index 3fbb92370c..38e2cbce68 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -26,6 +26,7 @@ nav = [ {"One-shot CI Capture" = "one-shot-capture.md"}, {"Chat Import" = "chat-import.md"}, {"Quality" = "quality.md"}, + {"Friction Log" = "friction-log.md"}, {"CLI Reference" = "commands.md"}, {"Session Export" = "session-export.md"}, {"Conversation Export" = "conversation-export.md"}, From 0d0343f5ea83aa276b4088f3e12ca1d12f4c6deb Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:44:52 +0000 Subject: [PATCH 62/62] fix(cli): pair friction format and json flags --- cmd/agentsview/friction.go | 21 ++++++++++++++++----- cmd/agentsview/friction_test.go | 12 ++++++++++++ docs/commands.md | 9 +++++---- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cmd/agentsview/friction.go b/cmd/agentsview/friction.go index bd71a1e56d..2641924566 100644 --- a/cmd/agentsview/friction.go +++ b/cmd/agentsview/friction.go @@ -41,6 +41,7 @@ func newFrictionCommand() *cobra.Command { func newFrictionRunCommand() *cobra.Command { var req review.RunRequest var asJSON bool + var format string cmd := &cobra.Command{ Use: "run", Short: "Build missing digests, or one date with --date", @@ -50,6 +51,9 @@ func newFrictionRunCommand() *cobra.Command { Args: cobra.NoArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { + if format != "human" && format != "json" { + return errors.New("--format must be human or json") + } backend, cleanup, err := resolveFrictionBackend(cmd) if err != nil { return err @@ -60,7 +64,7 @@ func newFrictionRunCommand() *cobra.Command { return err } w := cmd.OutOrStdout() - if asJSON { + if asJSON || format == "json" { for _, o := range outcomes { if _, err := w.Write(o.SummaryJSON); err != nil { return err @@ -87,12 +91,14 @@ func newFrictionRunCommand() *cobra.Command { cmd.Flags().StringVar(&req.Date, "date", "", "Digest date (YYYY-MM-DD)") cmd.Flags().BoolVar(&req.Rebuild, "rebuild", false, "Rebuild an existing digest, keeping its membership") cmd.Flags().BoolVar(&req.DryRun, "dry-run", false, "Compute and render without writing") + cmd.Flags().StringVar(&format, "format", "human", "Output format: human or json") cmd.Flags().BoolVar(&asJSON, "json", false, "Print the summary JSON object instead of the human summary") return cmd } func newFrictionDigestCommand() *cobra.Command { var date, from, to, format string + var asJSON bool cmd := &cobra.Command{ Use: "digest", Short: "Print a stored digest (Markdown or summary JSON)", @@ -122,7 +128,7 @@ func newFrictionDigestCommand() *cobra.Command { return err } payload := out.Markdown - if format == "json" { + if asJSON || format == "json" { payload = out.SummaryJSON } if _, err := w.Write(payload); err != nil { @@ -142,7 +148,7 @@ func newFrictionDigestCommand() *cobra.Command { return err } payload := out.Markdown - if format == "json" { + if asJSON || format == "json" { payload = out.SummaryJSON } _, err = w.Write(payload) @@ -153,12 +159,14 @@ func newFrictionDigestCommand() *cobra.Command { cmd.Flags().StringVar(&from, "from", "", "First date of a range (YYYY-MM-DD)") cmd.Flags().StringVar(&to, "to", "", "Last date of a range (YYYY-MM-DD)") cmd.Flags().StringVar(&format, "format", "md", "Output format: md or json") + cmd.Flags().BoolVar(&asJSON, "json", false, "Print summary JSON (alias for --format json)") return cmd } func newFrictionFindingsCommand() *cobra.Command { var f db.FrictionFindingFilter var format string + var asJSON bool cmd := &cobra.Command{ Use: "findings", Short: "List stored friction findings", @@ -177,7 +185,7 @@ func newFrictionFindingsCommand() *cobra.Command { if err != nil { return err } - if format == "json" { + if asJSON || format == "json" { return json.MarshalEncode(jsontext.NewEncoder(cmd.OutOrStdout()), page) } return printFrictionFindings(cmd.OutOrStdout(), page) @@ -189,6 +197,7 @@ func newFrictionFindingsCommand() *cobra.Command { cmd.Flags().IntVar(&f.Limit, "limit", 0, "Page size (default 100, max 1000)") cmd.Flags().StringVar(&f.Cursor, "cursor", "", "Cursor from a previous page") cmd.Flags().StringVar(&format, "format", "table", "Output format: table or json") + cmd.Flags().BoolVar(&asJSON, "json", false, "Print JSON (alias for --format json)") return cmd } @@ -196,6 +205,7 @@ func newFrictionPatternsCommand() *cobra.Command { var f db.FrictionPatternFilter var linked, unlinked bool var format string + var asJSON bool cmd := &cobra.Command{ Use: "patterns", Short: "List recurring friction patterns", @@ -220,7 +230,7 @@ func newFrictionPatternsCommand() *cobra.Command { if err != nil { return err } - if format == "json" { + if asJSON || format == "json" { return json.MarshalEncode(jsontext.NewEncoder(cmd.OutOrStdout()), page) } return printFrictionPatterns(cmd.OutOrStdout(), page) @@ -234,6 +244,7 @@ func newFrictionPatternsCommand() *cobra.Command { cmd.Flags().IntVar(&f.Limit, "limit", 0, "Page size (default 100, max 1000)") cmd.Flags().StringVar(&f.Cursor, "cursor", "", "Cursor from a previous page") cmd.Flags().StringVar(&format, "format", "table", "Output format: table or json") + cmd.Flags().BoolVar(&asJSON, "json", false, "Print JSON (alias for --format json)") return cmd } diff --git a/cmd/agentsview/friction_test.go b/cmd/agentsview/friction_test.go index 0c08ee32bb..5a4314d934 100644 --- a/cmd/agentsview/friction_test.go +++ b/cmd/agentsview/friction_test.go @@ -95,6 +95,9 @@ func TestFrictionDigestJSONMatchesGolden(t *testing.T) { stdout, stderr, err := runInsightCommand(t, append(args, "--format", "json")...) require.NoError(t, err, stderr) assert.Equal(t, string(golden), stdout, "byte-identical to the golden") + alias, stderr, err := runInsightCommand(t, append(args, "--json")...) + require.NoError(t, err, stderr) + assert.Equal(t, stdout, alias, "--json aliases --format json") stdout, _, err = runInsightCommand(t, args...) require.NoError(t, err) @@ -152,6 +155,9 @@ func TestFrictionRunLocalWithoutDaemon(t *testing.T) { assert.Contains(t, stdout, "\"frustrations\": 0") assert.Contains(t, stdout, "\"interruptions\": 0") assert.Contains(t, stdout, "\"digest_path\": \"friction:2026-09-14\"") + formatted, _, err := runInsightCommand(t, "friction", "run", "--date", "2026-09-14", "--format", "json") + require.NoError(t, err) + assert.Equal(t, stdout, formatted) _, _, err = runInsightCommand(t, "friction", "run", "--date", "2999-01-01") require.Error(t, err) @@ -181,10 +187,16 @@ func TestFrictionRunThroughDaemon(t *testing.T) { stdout, _, err = runInsightCommand(t, "friction", "findings", "--server", ts.URL, "--format", "json") require.NoError(t, err) assert.JSONEq(t, `{"findings":[],"next_cursor":""}`, stdout) + alias, _, err := runInsightCommand(t, "friction", "findings", "--server", ts.URL, "--json") + require.NoError(t, err) + assert.JSONEq(t, stdout, alias) stdout, _, err = runInsightCommand(t, "friction", "patterns", "--server", ts.URL) require.NoError(t, err) assert.Equal(t, "(no friction patterns)\n", stdout) + jsonPatterns, _, err := runInsightCommand(t, "friction", "patterns", "--server", ts.URL, "--json") + require.NoError(t, err) + assert.JSONEq(t, `{"patterns":[],"next_cursor":""}`, jsonPatterns) } func TestFrictionFindingsAndPatternsLocal(t *testing.T) { diff --git a/docs/commands.md b/docs/commands.md index c85d7f0ca4..2f98f435f9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1569,15 +1569,16 @@ recurring patterns. It is on by default; `[friction] enabled = false` turns it off. ```bash -agentsview friction run [--date YYYY-MM-DD] [--rebuild] [--dry-run] [--json] -agentsview friction digest [--date D | --from D --to D] [--format md|json] -agentsview friction findings [--date D] [--kind K] [--session ID] [--format table|json] -agentsview friction patterns [--kind K] [--since D] [--linked | --unlinked] [--format table|json] +agentsview friction run [--date YYYY-MM-DD] [--rebuild] [--dry-run] [--format human|json | --json] +agentsview friction digest [--date D | --from D --to D] [--format md|json | --json] +agentsview friction findings [--date D] [--kind K] [--session ID] [--format table|json | --json] +agentsview friction patterns [--kind K] [--since D] [--linked | --unlinked] [--format table|json | --json] ``` `friction run` without flags builds every complete local day that has no digest yet. It prints jilog-compatible summary lines, or the summary JSON object per day with `--json`, and exits non-zero only when the review fails. +`--json` is an alias for `--format json` on every friction command. `friction digest` prints the latest digest unless `--date` or a range is given; `--format json` prints the counts-only summary object, byte-identical whether it comes from the daemon or the local archive. `--linked` and `--unlinked` filter