Skip to content

Commit 7e4acd7

Browse files
aksOpsclaude
andcommitted
feat(serve): V19 slice 1 — /api/search grep over JSONL logs
GET /api/search?q=<term>&session=<name?>&limit=<n?> - q: 2..256 chars, 400 on violation - session: optional filter by resolved session name - limit: default 100, max 500; truncated flag set when limit hit Worker pool bounded by GOMAXPROCS. Missing log dir treated as empty (UI shouldn't blow up before the first session runs). 60-char snippet centered on hit. Best-effort ts/tool extraction per row. Slice 2 (UI bar) + slice 3 (FTS5) still pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 696859a commit 7e4acd7

3 files changed

Lines changed: 397 additions & 0 deletions

File tree

internal/serve/api/search.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package api
2+
3+
// Wiring (central, server.go):
4+
// mux.Handle("GET /api/search", authHF(api.Search(s.logDir, logsUUIDResolver{proj: s.proj})))
5+
6+
import (
7+
"bufio"
8+
"encoding/json"
9+
"net/http"
10+
"os"
11+
"path/filepath"
12+
"runtime"
13+
"strings"
14+
"sync"
15+
)
16+
17+
// UUIDNameResolver is already defined in logs_usage.go (V21) —
18+
// satisfied by serve.logsUUIDResolver. Reused here.
19+
20+
// SearchMatch is one hit in the search response.
21+
type SearchMatch struct {
22+
Session string `json:"session"`
23+
UUID string `json:"uuid"`
24+
TS string `json:"ts,omitempty"`
25+
Tool string `json:"tool,omitempty"`
26+
Snippet string `json:"snippet"`
27+
}
28+
29+
// SearchResponse wraps matches with scan stats for the UI.
30+
type SearchResponse struct {
31+
Query string `json:"query"`
32+
Matches []SearchMatch `json:"matches"`
33+
ScannedFiles int `json:"scanned_files"`
34+
Truncated bool `json:"truncated"`
35+
}
36+
37+
const (
38+
searchMinLen = 2
39+
searchMaxLen = 256
40+
searchMaxLimit = 500
41+
searchDefLimit = 100
42+
snippetHalf = 30
43+
maxLineBytes = 1 << 20 // 1 MiB, matches tailer
44+
)
45+
46+
// Search returns a handler that scans *.jsonl files under logDir for
47+
// substring hits against q. Slice 1 of V19 — regex and FTS5 come later.
48+
func Search(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
49+
return func(w http.ResponseWriter, r *http.Request) {
50+
if r.Method != http.MethodGet {
51+
w.Header().Set("Allow", "GET")
52+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
53+
return
54+
}
55+
q := r.URL.Query().Get("q")
56+
if len(q) < searchMinLen || len(q) > searchMaxLen {
57+
http.Error(w, "q must be 2..256 chars", http.StatusBadRequest)
58+
return
59+
}
60+
sessionFilter := r.URL.Query().Get("session")
61+
limit := searchDefLimit
62+
if v := r.URL.Query().Get("limit"); v != "" {
63+
if n, err := parsePositiveInt(v); err == nil {
64+
limit = n
65+
}
66+
}
67+
if limit > searchMaxLimit {
68+
limit = searchMaxLimit
69+
}
70+
71+
entries, err := os.ReadDir(logDir)
72+
if err != nil {
73+
// Missing dir is treated as empty — the UI shouldn't blow up
74+
// before the first session ever runs.
75+
if os.IsNotExist(err) {
76+
writeJSON(w, http.StatusOK, SearchResponse{Query: q, Matches: []SearchMatch{}})
77+
return
78+
}
79+
http.Error(w, "read log dir", http.StatusInternalServerError)
80+
return
81+
}
82+
83+
// Pre-resolve file list, with session-filter short-circuit when set.
84+
type job struct {
85+
path string
86+
uuid string
87+
name string
88+
}
89+
var jobs []job
90+
for _, e := range entries {
91+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
92+
continue
93+
}
94+
uuid := strings.TrimSuffix(e.Name(), ".jsonl")
95+
name := ""
96+
if resolver != nil {
97+
name, _ = resolver.ResolveUUID(uuid)
98+
}
99+
if sessionFilter != "" && name != sessionFilter {
100+
continue
101+
}
102+
jobs = append(jobs, job{
103+
path: filepath.Join(logDir, e.Name()),
104+
uuid: uuid,
105+
name: name,
106+
})
107+
}
108+
109+
// Worker pool over files. Slice 1: single pass, no early-abort via
110+
// ctx — handlers inherit request ctx and the OS cancels on client
111+
// disconnect via the file close path.
112+
workers := runtime.GOMAXPROCS(0)
113+
if workers > len(jobs) {
114+
workers = len(jobs)
115+
}
116+
if workers < 1 {
117+
workers = 1
118+
}
119+
jobCh := make(chan job)
120+
resCh := make(chan SearchMatch, 256)
121+
var wg sync.WaitGroup
122+
for i := 0; i < workers; i++ {
123+
wg.Add(1)
124+
go func() {
125+
defer wg.Done()
126+
for j := range jobCh {
127+
scanFile(j.path, j.uuid, j.name, q, resCh)
128+
}
129+
}()
130+
}
131+
go func() {
132+
for _, j := range jobs {
133+
jobCh <- j
134+
}
135+
close(jobCh)
136+
wg.Wait()
137+
close(resCh)
138+
}()
139+
140+
matches := make([]SearchMatch, 0, limit)
141+
truncated := false
142+
for m := range resCh {
143+
if len(matches) >= limit {
144+
truncated = true
145+
// Keep draining so workers finish, but stop appending.
146+
continue
147+
}
148+
matches = append(matches, m)
149+
}
150+
151+
writeJSON(w, http.StatusOK, SearchResponse{
152+
Query: q,
153+
Matches: matches,
154+
ScannedFiles: len(jobs),
155+
Truncated: truncated,
156+
})
157+
}
158+
}
159+
160+
// scanFile emits one match per matching line. Slice 1 behaviour: plain
161+
// substring match with a 60-char snippet centered on the hit.
162+
func scanFile(path, uuid, session, q string, out chan<- SearchMatch) {
163+
f, err := os.Open(path)
164+
if err != nil {
165+
return
166+
}
167+
defer f.Close()
168+
169+
sc := bufio.NewScanner(f)
170+
sc.Buffer(make([]byte, 0, 64*1024), maxLineBytes)
171+
for sc.Scan() {
172+
line := sc.Text()
173+
idx := strings.Index(line, q)
174+
if idx < 0 {
175+
continue
176+
}
177+
// Parse just enough JSON for ts/tool — best-effort only. Rows
178+
// that don't parse still count as hits (the snippet is what the
179+
// user wants to see).
180+
var row struct {
181+
TS string `json:"ts"`
182+
Tool string `json:"tool"`
183+
}
184+
_ = json.Unmarshal([]byte(line), &row)
185+
186+
start := idx - snippetHalf
187+
if start < 0 {
188+
start = 0
189+
}
190+
end := idx + len(q) + snippetHalf
191+
if end > len(line) {
192+
end = len(line)
193+
}
194+
out <- SearchMatch{
195+
Session: session,
196+
UUID: uuid,
197+
TS: row.TS,
198+
Tool: row.Tool,
199+
Snippet: line[start:end],
200+
}
201+
}
202+
}
203+
204+
func parsePositiveInt(s string) (int, error) {
205+
n := 0
206+
for _, c := range s {
207+
if c < '0' || c > '9' {
208+
return 0, errNotInt
209+
}
210+
n = n*10 + int(c-'0')
211+
if n > 1<<20 {
212+
return n, nil
213+
}
214+
}
215+
if n <= 0 {
216+
return 0, errNotInt
217+
}
218+
return n, nil
219+
}
220+
221+
var errNotInt = &apiError{msg: "not a positive int"}
222+
223+
type apiError struct{ msg string }
224+
225+
func (e *apiError) Error() string { return e.msg }

internal/serve/api/search_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func seedSearchLogs(t *testing.T, dir string, files map[string][]string) {
14+
t.Helper()
15+
if err := os.MkdirAll(dir, 0o755); err != nil {
16+
t.Fatalf("mkdir: %v", err)
17+
}
18+
for name, lines := range files {
19+
p := filepath.Join(dir, name)
20+
if err := os.WriteFile(p, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
21+
t.Fatalf("write %s: %v", p, err)
22+
}
23+
}
24+
}
25+
26+
func TestSearch_FindsSubstringMatchesWithSnippet(t *testing.T) {
27+
dir := t.TempDir()
28+
seedSearchLogs(t, dir, map[string][]string{
29+
"11111111-0000-0000-0000-000000000001.jsonl": {
30+
`{"ts":"2026-04-21T10:00:00Z","tool":"Bash","cmd":"echo hello-needle-world"}`,
31+
`{"ts":"2026-04-21T10:00:01Z","tool":"Read","path":"/tmp/foo"}`,
32+
},
33+
"22222222-0000-0000-0000-000000000002.jsonl": {
34+
`{"ts":"2026-04-21T11:00:00Z","tool":"Grep","pattern":"needle"}`,
35+
},
36+
})
37+
resolver := fakeResolver{m: map[string]string{
38+
"11111111-0000-0000-0000-000000000001": "alpha",
39+
"22222222-0000-0000-0000-000000000002": "beta",
40+
}}
41+
42+
h := Search(dir, resolver)
43+
req := httptest.NewRequest(http.MethodGet, "/api/search?q=needle", nil)
44+
rr := httptest.NewRecorder()
45+
h(rr, req)
46+
47+
if rr.Code != http.StatusOK {
48+
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
49+
}
50+
var resp SearchResponse
51+
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
52+
t.Fatalf("decode: %v", err)
53+
}
54+
if resp.Query != "needle" {
55+
t.Errorf("query=%q want needle", resp.Query)
56+
}
57+
if resp.ScannedFiles != 2 {
58+
t.Errorf("scanned_files=%d want 2", resp.ScannedFiles)
59+
}
60+
if len(resp.Matches) != 2 {
61+
t.Fatalf("matches=%d want 2 (%v)", len(resp.Matches), resp.Matches)
62+
}
63+
// Snippet should contain the substring.
64+
for _, m := range resp.Matches {
65+
if !strings.Contains(m.Snippet, "needle") {
66+
t.Errorf("snippet %q missing query", m.Snippet)
67+
}
68+
if m.UUID == "" {
69+
t.Errorf("uuid empty")
70+
}
71+
}
72+
}
73+
74+
func TestSearch_SessionFilter(t *testing.T) {
75+
dir := t.TempDir()
76+
seedSearchLogs(t, dir, map[string][]string{
77+
"aaaaaaaa-0000-0000-0000-000000000001.jsonl": {
78+
`{"ts":"2026-04-21T10:00:00Z","tool":"Bash","cmd":"needle-one"}`,
79+
},
80+
"bbbbbbbb-0000-0000-0000-000000000002.jsonl": {
81+
`{"ts":"2026-04-21T11:00:00Z","tool":"Bash","cmd":"needle-two"}`,
82+
},
83+
})
84+
resolver := fakeResolver{m: map[string]string{
85+
"aaaaaaaa-0000-0000-0000-000000000001": "alpha",
86+
"bbbbbbbb-0000-0000-0000-000000000002": "beta",
87+
}}
88+
h := Search(dir, resolver)
89+
90+
req := httptest.NewRequest(http.MethodGet, "/api/search?q=needle&session=beta", nil)
91+
rr := httptest.NewRecorder()
92+
h(rr, req)
93+
if rr.Code != http.StatusOK {
94+
t.Fatalf("status=%d", rr.Code)
95+
}
96+
var resp SearchResponse
97+
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
98+
if resp.ScannedFiles != 1 {
99+
t.Errorf("scanned_files=%d want 1 (session filter)", resp.ScannedFiles)
100+
}
101+
if len(resp.Matches) != 1 || resp.Matches[0].Session != "beta" {
102+
t.Errorf("matches=%v want 1 from beta", resp.Matches)
103+
}
104+
}
105+
106+
func TestSearch_TruncationFlag(t *testing.T) {
107+
dir := t.TempDir()
108+
lines := make([]string, 0, 10)
109+
for i := 0; i < 10; i++ {
110+
lines = append(lines, `{"ts":"2026-04-21T10:00:00Z","tool":"Bash","cmd":"has-needle-row"}`)
111+
}
112+
seedSearchLogs(t, dir, map[string][]string{
113+
"cccccccc-0000-0000-0000-000000000003.jsonl": lines,
114+
})
115+
h := Search(dir, fakeResolver{m: map[string]string{"cccccccc-0000-0000-0000-000000000003": "gamma"}})
116+
req := httptest.NewRequest(http.MethodGet, "/api/search?q=needle&limit=3", nil)
117+
rr := httptest.NewRecorder()
118+
h(rr, req)
119+
120+
var resp SearchResponse
121+
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
122+
if len(resp.Matches) != 3 {
123+
t.Errorf("matches=%d want 3", len(resp.Matches))
124+
}
125+
if !resp.Truncated {
126+
t.Errorf("truncated=false want true")
127+
}
128+
}
129+
130+
func TestSearch_BadQuery400(t *testing.T) {
131+
h := Search(t.TempDir(), nil)
132+
for _, q := range []string{"", "x"} {
133+
req := httptest.NewRequest(http.MethodGet, "/api/search?q="+q, nil)
134+
rr := httptest.NewRecorder()
135+
h(rr, req)
136+
if rr.Code != http.StatusBadRequest {
137+
t.Errorf("q=%q status=%d want 400", q, rr.Code)
138+
}
139+
}
140+
}
141+
142+
func TestSearch_MissingDirReturnsEmpty(t *testing.T) {
143+
h := Search(filepath.Join(t.TempDir(), "does-not-exist"), nil)
144+
req := httptest.NewRequest(http.MethodGet, "/api/search?q=needle", nil)
145+
rr := httptest.NewRecorder()
146+
h(rr, req)
147+
if rr.Code != http.StatusOK {
148+
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
149+
}
150+
var resp SearchResponse
151+
_ = json.Unmarshal(rr.Body.Bytes(), &resp)
152+
if len(resp.Matches) != 0 || resp.ScannedFiles != 0 {
153+
t.Errorf("expected empty response, got %+v", resp)
154+
}
155+
}
156+
157+
func TestSearch_405OnPost(t *testing.T) {
158+
h := Search(t.TempDir(), nil)
159+
req := httptest.NewRequest(http.MethodPost, "/api/search?q=needle", nil)
160+
rr := httptest.NewRecorder()
161+
h(rr, req)
162+
if rr.Code != http.StatusMethodNotAllowed {
163+
t.Errorf("status=%d want 405", rr.Code)
164+
}
165+
if rr.Header().Get("Allow") != "GET" {
166+
t.Errorf("Allow=%q want GET", rr.Header().Get("Allow"))
167+
}
168+
}

0 commit comments

Comments
 (0)