Skip to content

Commit 225f8ba

Browse files
aksOpsclaude
andcommitted
feat(serve,ui): V19 slice 3 — FTS5 full-text search (v0.3)
Replace the line-scanning grep fallback with a real SQLite FTS5 index over the tool_call event stream. A hub subscriber writes each tool call into a virtual table (trigram tokenizer for substring matching of paths and identifiers); the /api/search handler queries the index and returns sessions+snippets newest-first. Backend - internal/serve/store: add tool_calls_fts virtual table + IndexToolCall and SearchFTS methods on the sqlite cost store. Schema version bumped and FTS table wiped on boot so the tailer's JSONL replay rebuilds it from scratch — no divergence vs the source of truth on disk. - internal/serve/store/tool_call_subscriber.go: hub subscriber that indexes every tool_call published after boot. - internal/serve/api/search.go: rewrite handler around SearchFTS, bump minimum query length 2 → 3 (matches the trigram tokenizer's minimum useful length). Drops scanned_files from the response shape. - internal/serve/server.go: wire the FTS subscriber and inject the new SearchSource / SessionNameResolver adapters. Build - Makefile: add GO_TAGS := sqlite_fts5 and apply to every go invocation so test + build + install all pull in the FTS5 amalgamation. UI - useSearch: MIN_Q 2 → 3, drop scanned_files from the response type. - SearchPalette: gate on ≥3 chars. Tests - internal/serve/store/search_store_test.go: 9 tests covering literal substring, session filter, truncation, path-like tokens, empty content, closed-store errors, subscriber indexing, non-tool-call filtering, FTS wipe on boot. - ui/e2e/fixtures/mocks.ts: add a shape-aware catch-all for unmocked /api/** paths. A 401 from any endpoint bubbles up as UnauthorizedError and clears the bearer token, dropping the test back to the paste screen — V13 /api/cost was silently doing that. Catch-all prevents the whole class of newly-added-endpoint regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0a87fb6 commit 225f8ba

13 files changed

Lines changed: 846 additions & 251 deletions

File tree

Makefile

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ UI_DIR := ui
88
UI_DIST := $(UI_DIR)/dist
99
EMBED_DIST := internal/serve/dist
1010

11+
# V19 slice 3 requires SQLite FTS5. mattn/go-sqlite3 compiles FTS5 in
12+
# only when the sqlite_fts5 build tag is set; applied to every go
13+
# build / test / install invocation below. Binaries built without it
14+
# will panic at boot on "no such module: fts5".
15+
GO_TAGS := sqlite_fts5
16+
1117
.PHONY: ui build dev clean help e2e regression
1218

1319
help:
@@ -30,7 +36,7 @@ ui:
3036

3137
build: ui
3238
@echo "==> go build"
33-
go build -trimpath ./...
39+
go build -trimpath -tags $(GO_TAGS) ./...
3440

3541
# Dev: run pnpm dev (Vite proxies to :37778) and go run . serve in parallel.
3642
# Trap SIGINT so Ctrl-C tears down both.
@@ -62,11 +68,11 @@ e2e:
6268
# it does not get replaced. See ui/e2e/README.md.
6369
regression:
6470
@echo "==> go build ./..."
65-
go build ./...
71+
go build -tags $(GO_TAGS) ./...
6672
@echo "==> go test ./..."
67-
go test ./...
73+
go test -tags $(GO_TAGS) ./...
6874
@echo "==> go test -race ./internal/serve/..."
69-
go test -race ./internal/serve/...
75+
go test -race -tags $(GO_TAGS) ./internal/serve/...
7076
@echo "==> govulncheck ./..."
7177
govulncheck ./...
7278
@echo "==> pnpm -C ui tsc --noEmit"

internal/serve/api/search.go

Lines changed: 68 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
package api
22

3+
// V19 slice 3: /api/search is now backed by the FTS5 index maintained
4+
// in internal/serve/store. The handler no longer walks *.jsonl files
5+
// — queries hit the index directly. Trigram tokenization means the
6+
// minimum query length bumps from 2 to 3 chars.
7+
//
38
// Wiring (central, server.go):
4-
// mux.Handle("GET /api/search", authHF(api.Search(s.logDir, logsUUIDResolver{proj: s.proj})))
9+
// mux.Handle("GET /api/search", authHF(api.Search(searchSourceAdapter{s.cost}, logsUUIDResolver{proj: s.proj})))
510

611
import (
7-
"bufio"
8-
"encoding/json"
12+
"errors"
913
"net/http"
10-
"os"
11-
"path/filepath"
12-
"runtime"
13-
"strings"
14-
"sync"
14+
"time"
1515
)
1616

17-
// UUIDNameResolver is already defined in logs_usage.go (V21) —
18-
// satisfied by serve.logsUUIDResolver. Reused here.
17+
// SessionNameResolver reverse-maps session name → log UUID. Satisfied
18+
// by serve.logsUUIDResolver. Kept separate from UUIDNameResolver so
19+
// callers can depend on only the direction they need.
20+
type SessionNameResolver interface {
21+
ResolveSessionName(name string) (uuid string, ok bool)
22+
}
23+
24+
var errNotPositiveInt = errors.New("not a positive int")
1925

2026
// SearchMatch is one hit in the search response.
2127
type SearchMatch struct {
@@ -28,24 +34,42 @@ type SearchMatch struct {
2834

2935
// SearchResponse wraps matches with scan stats for the UI.
3036
type SearchResponse struct {
31-
Query string `json:"query"`
32-
Matches []SearchMatch `json:"matches"`
33-
ScannedFiles int `json:"scanned_files"`
34-
Truncated bool `json:"truncated"`
37+
Query string `json:"query"`
38+
Matches []SearchMatch `json:"matches"`
39+
Truncated bool `json:"truncated"`
40+
}
41+
42+
// SearchSource is the persistence seam — in production it's the FTS5
43+
// store; tests fake it with an in-memory slice.
44+
type SearchSource interface {
45+
SearchFTS(q, sessionFilter string, limit int) ([]SearchHit, bool, error)
46+
}
47+
48+
// SearchHit mirrors store.SearchMatch in wire-neutral form so the api
49+
// package doesn't depend on the store package.
50+
type SearchHit struct {
51+
Session string
52+
TS time.Time
53+
Tool string
54+
Snippet string
3555
}
3656

3757
const (
38-
searchMinLen = 2
58+
searchMinLen = 3
3959
searchMaxLen = 256
4060
searchMaxLimit = 500
4161
searchDefLimit = 100
42-
snippetHalf = 30
43-
maxLineBytes = 1 << 20 // 1 MiB, matches tailer
4462
)
4563

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 {
64+
// Search returns a handler that queries the FTS5 index for substring
65+
// hits against q. Response shape matches V19 slice 1 — UUID is
66+
// resolved from the session name at query time.
67+
func Search(src SearchSource, resolver SessionNameResolver) http.HandlerFunc {
68+
// Build an inverse name → uuid map once per request (via a
69+
// resolver-scan closure) so Search can stamp the UUID field.
70+
// Resolver walks via (uuid → name); we keep the call site cheap
71+
// by doing an O(n) scan on cache miss. For v0.3 the session
72+
// count is small (<100) so this is fine.
4973
return func(w http.ResponseWriter, r *http.Request) {
5074
if r.Method != http.MethodGet {
5175
w.Header().Set("Allow", "GET")
@@ -54,172 +78,67 @@ func Search(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
5478
}
5579
q := r.URL.Query().Get("q")
5680
if len(q) < searchMinLen || len(q) > searchMaxLen {
57-
http.Error(w, "q must be 2..256 chars", http.StatusBadRequest)
81+
http.Error(w, "q must be 3..256 chars", http.StatusBadRequest)
5882
return
5983
}
6084
sessionFilter := r.URL.Query().Get("session")
6185
limit := searchDefLimit
6286
if v := r.URL.Query().Get("limit"); v != "" {
63-
if n, err := parsePositiveInt(v); err == nil {
87+
if n, err := parseSearchPositiveInt(v); err == nil {
6488
limit = n
6589
}
6690
}
6791
if limit > searchMaxLimit {
6892
limit = searchMaxLimit
6993
}
7094

71-
entries, err := os.ReadDir(logDir)
95+
hits, truncated, err := src.SearchFTS(q, sessionFilter, limit)
7296
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)
97+
http.Error(w, "search: "+err.Error(), http.StatusInternalServerError)
8098
return
8199
}
82100

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
101+
matches := make([]SearchMatch, 0, len(hits))
102+
uuidOf := map[string]string{}
103+
for _, h := range hits {
104+
uuid, ok := uuidOf[h.Session]
105+
if !ok && resolver != nil {
106+
uuid, _ = resolver.ResolveSessionName(h.Session)
107+
uuidOf[h.Session] = uuid
93108
}
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
109+
m := SearchMatch{
110+
Session: h.Session,
111+
UUID: uuid,
112+
Tool: h.Tool,
113+
Snippet: h.Snippet,
134114
}
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
115+
if !h.TS.IsZero() {
116+
m.TS = h.TS.Format(time.RFC3339Nano)
147117
}
148118
matches = append(matches, m)
149119
}
150120

151121
writeJSON(w, http.StatusOK, SearchResponse{
152-
Query: q,
153-
Matches: matches,
154-
ScannedFiles: len(jobs),
155-
Truncated: truncated,
122+
Query: q,
123+
Matches: matches,
124+
Truncated: truncated,
156125
})
157126
}
158127
}
159128

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) {
129+
func parseSearchPositiveInt(s string) (int, error) {
205130
n := 0
206131
for _, c := range s {
207132
if c < '0' || c > '9' {
208-
return 0, errNotInt
133+
return 0, errNotPositiveInt
209134
}
210135
n = n*10 + int(c-'0')
211136
if n > 1<<20 {
212137
return n, nil
213138
}
214139
}
215140
if n <= 0 {
216-
return 0, errNotInt
141+
return 0, errNotPositiveInt
217142
}
218143
return n, nil
219144
}
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 }

0 commit comments

Comments
 (0)