@hybridlabor-api/bdb-synapse 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/synapse +0 -0
- package/cmd/rubriceval/main.go +308 -0
- package/cmd/synapse/main.go +280 -0
- package/cmd/synapse/main_test.go +16 -0
- package/go.mod +7 -0
- package/go.sum +6 -0
- package/internal/adapter/adapter.go +1117 -0
- package/internal/adapter/adapter_test.go +518 -0
- package/internal/adapter/agy/adapter.go +193 -0
- package/internal/adapter/claudecode/adapter.go +415 -0
- package/internal/adapter/claudecode/adapter_test.go +260 -0
- package/internal/adapter/claudecode/agents.go +387 -0
- package/internal/adapter/claudecode/agents_test.go +480 -0
- package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
- package/internal/adapter/codex/adapter.go +919 -0
- package/internal/adapter/codex/adapter_test.go +920 -0
- package/internal/adapter/codex/agents.go +401 -0
- package/internal/adapter/codex/agents_test.go +610 -0
- package/internal/adapter/codex/summary_inputs_test.go +20 -0
- package/internal/adapter/pi/adapter.go +483 -0
- package/internal/adapter/pi/adapter_test.go +517 -0
- package/internal/citymap/builder.go +1124 -0
- package/internal/citymap/builder_test.go +818 -0
- package/internal/judge/cache.go +180 -0
- package/internal/judge/cli.go +240 -0
- package/internal/judge/cli_test.go +68 -0
- package/internal/judge/fresh_summary_test.go +37 -0
- package/internal/judge/input.go +233 -0
- package/internal/judge/judge.go +416 -0
- package/internal/judge/judge_test.go +288 -0
- package/internal/judge/prompt.go +129 -0
- package/internal/judge/rubric.go +275 -0
- package/internal/judge/rubric_test.go +642 -0
- package/internal/model/agent.go +63 -0
- package/internal/model/agent_schema_test.go +86 -0
- package/internal/model/agent_test.go +64 -0
- package/internal/model/model.go +175 -0
- package/internal/model/report.go +166 -0
- package/internal/model/stats.go +151 -0
- package/internal/model/stats_test.go +89 -0
- package/internal/model/trace_schema_test.go +67 -0
- package/internal/server/analyze.go +286 -0
- package/internal/server/analyze_test.go +297 -0
- package/internal/server/codex_index_test.go +40 -0
- package/internal/server/hardening_test.go +147 -0
- package/internal/server/reportindex.go +91 -0
- package/internal/server/reportindex_test.go +116 -0
- package/internal/server/server.go +1099 -0
- package/internal/server/server_test.go +1389 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
- package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
- package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
- package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
- package/internal/server/static/assets/index-C_adLrJr.js +3 -0
- package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
- package/internal/server/static/index.html +26 -0
- package/internal/server/tracestore.go +173 -0
- package/internal/server/tracestore_test.go +51 -0
- package/internal/textutil/truncate.go +30 -0
- package/internal/textutil/truncate_test.go +39 -0
- package/package.json +35 -0
- package/web/e2e/agent-lens.spec.ts +688 -0
- package/web/index.html +23 -0
- package/web/package-lock.json +1933 -0
- package/web/package.json +33 -0
- package/web/playwright.config.ts +24 -0
- package/web/src/App.tsx +876 -0
- package/web/src/api/client.ts +74 -0
- package/web/src/main.tsx +12 -0
- package/web/src/playback/recorder.ts +160 -0
- package/web/src/playback/reducer.ts +91 -0
- package/web/src/scene/CityScene.tsx +638 -0
- package/web/src/scene/TreeScene.tsx +656 -0
- package/web/src/scene/dirLabels.ts +145 -0
- package/web/src/scene/sceneUtils.ts +144 -0
- package/web/src/scene/textures.ts +60 -0
- package/web/src/scene/trail.ts +79 -0
- package/web/src/scene/treeLayout.ts +169 -0
- package/web/src/state/filters.ts +40 -0
- package/web/src/state/store.ts +83 -0
- package/web/src/styles.css +2565 -0
- package/web/src/types.ts +315 -0
- package/web/src/ui/AgentsPanel.tsx +376 -0
- package/web/src/ui/Dock.tsx +104 -0
- package/web/src/ui/Hud.tsx +335 -0
- package/web/src/ui/Inspector.tsx +107 -0
- package/web/src/ui/LogoMark.tsx +38 -0
- package/web/src/ui/ReportPanel.tsx +491 -0
- package/web/src/ui/SessionRail.tsx +316 -0
- package/web/src/ui/Timeline.tsx +458 -0
- package/web/src/ui/ViewPanel.tsx +45 -0
- package/web/src/ui/shortcuts.ts +4 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +28 -0
|
@@ -0,0 +1,1099 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"embed"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io/fs"
|
|
10
|
+
"log"
|
|
11
|
+
"mime"
|
|
12
|
+
"net"
|
|
13
|
+
"net/http"
|
|
14
|
+
"net/url"
|
|
15
|
+
"os"
|
|
16
|
+
"os/exec"
|
|
17
|
+
"path/filepath"
|
|
18
|
+
"runtime"
|
|
19
|
+
"runtime/debug"
|
|
20
|
+
"sort"
|
|
21
|
+
"strings"
|
|
22
|
+
"sync"
|
|
23
|
+
"time"
|
|
24
|
+
|
|
25
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter"
|
|
26
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter/claudecode"
|
|
27
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter/codex"
|
|
28
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter/agy"
|
|
29
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/adapter/pi"
|
|
30
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/citymap"
|
|
31
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/judge"
|
|
32
|
+
"github.com/hybridlabor-api/bdb-synapse/internal/model"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
//go:embed static
|
|
36
|
+
var embeddedStatic embed.FS
|
|
37
|
+
|
|
38
|
+
type Config struct {
|
|
39
|
+
Port int
|
|
40
|
+
ClaudeDir string
|
|
41
|
+
CodexDir string
|
|
42
|
+
PiDir string
|
|
43
|
+
AgyDir string
|
|
44
|
+
OpenSession string
|
|
45
|
+
Dev bool
|
|
46
|
+
RepoRoot string
|
|
47
|
+
MapOnly bool
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Server struct {
|
|
51
|
+
cfg Config
|
|
52
|
+
adapters []adapter.Source
|
|
53
|
+
mu sync.Mutex
|
|
54
|
+
scanMu sync.Mutex
|
|
55
|
+
sessions []model.SessionMeta
|
|
56
|
+
sessionCatalog map[string]model.SessionMeta
|
|
57
|
+
sessionAt time.Time
|
|
58
|
+
freshGen uint64
|
|
59
|
+
traceStore *traceStore
|
|
60
|
+
agentGraphs map[string]agentGraphCacheEntry
|
|
61
|
+
agentGraphLoads map[string]*inflightAgentGraph
|
|
62
|
+
summaries map[string]summaryCacheEntry
|
|
63
|
+
repoMaps map[string]repoMapEntry
|
|
64
|
+
repoMapMu sync.Mutex
|
|
65
|
+
buildCityMap func(string, *model.Trace) (*model.CityMap, error)
|
|
66
|
+
|
|
67
|
+
analyze analyzeState
|
|
68
|
+
reportCache judge.Cache
|
|
69
|
+
reportIndex reportIndex
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type repoMapEntry struct {
|
|
73
|
+
city *model.CityMap
|
|
74
|
+
builtAt time.Time
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type fileFingerprint struct {
|
|
78
|
+
size int64
|
|
79
|
+
modTime time.Time
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type agentGraphFingerprint struct {
|
|
83
|
+
digest [sha256.Size]byte
|
|
84
|
+
freshGen uint64
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type agentGraphCacheEntry struct {
|
|
88
|
+
fingerprint agentGraphFingerprint
|
|
89
|
+
graph *model.AgentGraph
|
|
90
|
+
used time.Time
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type inflightAgentGraph struct {
|
|
94
|
+
done chan struct{}
|
|
95
|
+
fingerprint agentGraphFingerprint
|
|
96
|
+
graph *model.AgentGraph
|
|
97
|
+
err error
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type summaryCacheEntry struct {
|
|
101
|
+
size int64
|
|
102
|
+
modTime time.Time
|
|
103
|
+
// sidecar is opaque validation material for the companion files the
|
|
104
|
+
// source declared via SummaryInputs; empty when there are none.
|
|
105
|
+
sidecar string
|
|
106
|
+
meta model.SessionMeta
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const (
|
|
110
|
+
sessionListTTL = 5 * time.Second
|
|
111
|
+
traceCacheTTL = 10 * time.Minute
|
|
112
|
+
traceCacheMaxEntries = 16
|
|
113
|
+
// repo map builds are relatively cheap; a short TTL keeps a long-running
|
|
114
|
+
// serve current as the tree changes without rebuilding on every request
|
|
115
|
+
repoMapTTL = 30 * time.Second
|
|
116
|
+
repoMapMaxEntries = 16
|
|
117
|
+
// agent graphs are small but were the one unbounded cache; bound them the
|
|
118
|
+
// same way as traces
|
|
119
|
+
agentGraphMaxEntries = 16
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
func New(cfg Config) *Server {
|
|
123
|
+
s := &Server{
|
|
124
|
+
cfg: cfg,
|
|
125
|
+
adapters: []adapter.Source{claudecode.Adapter{Dir: cfg.ClaudeDir}, codex.Adapter{Dir: cfg.CodexDir}, pi.Adapter{Dir: cfg.PiDir}, agy.Adapter{Dir: cfg.AgyDir}},
|
|
126
|
+
agentGraphs: map[string]agentGraphCacheEntry{},
|
|
127
|
+
agentGraphLoads: map[string]*inflightAgentGraph{},
|
|
128
|
+
summaries: map[string]summaryCacheEntry{},
|
|
129
|
+
sessionCatalog: map[string]model.SessionMeta{},
|
|
130
|
+
repoMaps: map[string]repoMapEntry{},
|
|
131
|
+
buildCityMap: citymap.Builder{}.Build,
|
|
132
|
+
|
|
133
|
+
analyze: analyzeState{jobs: map[string]*analyzeJob{}},
|
|
134
|
+
reportCache: judge.Cache{Dir: judge.DefaultCacheDir()},
|
|
135
|
+
}
|
|
136
|
+
// Method values, not results: the store observes buildCityMap overrides
|
|
137
|
+
// made after construction (tests swap it per case).
|
|
138
|
+
s.traceStore = newTraceStore(s.loadTraceAndMap, s.parseSessionTrace)
|
|
139
|
+
return s
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func (s *Server) Start(openBrowser bool) error {
|
|
143
|
+
port := s.cfg.Port
|
|
144
|
+
if port == 0 {
|
|
145
|
+
port = 0
|
|
146
|
+
}
|
|
147
|
+
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
|
148
|
+
if err != nil {
|
|
149
|
+
return err
|
|
150
|
+
}
|
|
151
|
+
addr := "http://" + ln.Addr().String()
|
|
152
|
+
// warm the session scan so the first page load doesn't wait on a cold walk
|
|
153
|
+
// over every session file. Map-only mode never lists sessions, so skip the
|
|
154
|
+
// scan of the whole Claude/Codex corpus.
|
|
155
|
+
if !s.cfg.MapOnly {
|
|
156
|
+
go func() { _, _ = s.listSessions() }()
|
|
157
|
+
}
|
|
158
|
+
if openBrowser {
|
|
159
|
+
pageURL := addr
|
|
160
|
+
switch {
|
|
161
|
+
case s.cfg.MapOnly:
|
|
162
|
+
pageURL += "/?map=1"
|
|
163
|
+
case s.cfg.OpenSession != "":
|
|
164
|
+
pageURL += "/?session=" + url.QueryEscape(s.openSessionKey())
|
|
165
|
+
}
|
|
166
|
+
_ = openURL(pageURL)
|
|
167
|
+
}
|
|
168
|
+
fmt.Printf("mindwalk serving %s\n", addr)
|
|
169
|
+
return http.Serve(ln, s.handler())
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func (s *Server) handler() http.Handler {
|
|
173
|
+
mux := http.NewServeMux()
|
|
174
|
+
mux.HandleFunc("/api/sessions", s.handleSessions)
|
|
175
|
+
mux.HandleFunc("/api/sessions/", s.handleSessionResource)
|
|
176
|
+
mux.HandleFunc("/api/repomap", s.handleRepoMap)
|
|
177
|
+
mux.HandleFunc("/", s.handleStatic)
|
|
178
|
+
return requireLoopback(mux)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// requireLoopback rejects requests that did not originate locally. Binding to
|
|
182
|
+
// 127.0.0.1 alone does not keep browsers out: any web page can fire
|
|
183
|
+
// cross-site requests straight at a loopback port, and DNS rebinding can
|
|
184
|
+
// point an attacker-controlled name at 127.0.0.1. The Host check pins the
|
|
185
|
+
// name rebinding would forge; the Origin / Sec-Fetch-Site check stops
|
|
186
|
+
// cross-site state changes — POST /analyze starts a judge run that costs
|
|
187
|
+
// tokens and minutes. An Origin must name this server exactly: another
|
|
188
|
+
// loopback origin (a different local server's page, or another port) is
|
|
189
|
+
// still cross-origin. Requests without an Origin header (curl, same-origin
|
|
190
|
+
// navigations) pass untouched.
|
|
191
|
+
func requireLoopback(next http.Handler) http.Handler {
|
|
192
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
193
|
+
if !loopbackHost(r.Host) {
|
|
194
|
+
http.Error(w, "forbidden: non-local Host", http.StatusForbidden)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if r.Method != http.MethodGet {
|
|
198
|
+
if origin := r.Header.Get("Origin"); origin != "" && !sameOrigin(origin, r.Host) {
|
|
199
|
+
http.Error(w, "forbidden: cross-site request", http.StatusForbidden)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
switch r.Header.Get("Sec-Fetch-Site") {
|
|
203
|
+
case "", "same-origin", "none":
|
|
204
|
+
default:
|
|
205
|
+
http.Error(w, "forbidden: cross-site request", http.StatusForbidden)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
next.ServeHTTP(w, r)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func loopbackHost(hostport string) bool {
|
|
214
|
+
host := hostport
|
|
215
|
+
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
|
216
|
+
host = h
|
|
217
|
+
}
|
|
218
|
+
host = normalizeHost(host)
|
|
219
|
+
return host == "127.0.0.1" || host == "localhost" || host == "::1"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// sameOrigin reports whether the Origin header names this server: scheme
|
|
223
|
+
// http (the server never terminates TLS) and the same normalized host:port
|
|
224
|
+
// the request was addressed to. "null" and malformed origins fail parsing
|
|
225
|
+
// and are rejected.
|
|
226
|
+
func sameOrigin(origin, requestHost string) bool {
|
|
227
|
+
parsed, err := url.Parse(origin)
|
|
228
|
+
if err != nil || parsed.Scheme != "http" || parsed.Host == "" {
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
return hostPortKey(parsed.Host) == hostPortKey(requestHost)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// hostPortKey normalizes a host:port for origin comparison: lowercased host,
|
|
235
|
+
// IPv6 brackets stripped, the http default port made explicit.
|
|
236
|
+
func hostPortKey(hostport string) string {
|
|
237
|
+
host, port, err := net.SplitHostPort(hostport)
|
|
238
|
+
if err != nil {
|
|
239
|
+
host, port = hostport, "80"
|
|
240
|
+
}
|
|
241
|
+
return normalizeHost(host) + ":" + port
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// normalizeHost lowercases a host and strips one matched pair of IPv6
|
|
245
|
+
// brackets. Unmatched brackets stay put, so a malformed host ("localhost]")
|
|
246
|
+
// remains malformed instead of normalizing into an allowed value.
|
|
247
|
+
func normalizeHost(host string) string {
|
|
248
|
+
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
|
249
|
+
host = host[1 : len(host)-1]
|
|
250
|
+
}
|
|
251
|
+
return strings.ToLower(host)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|
255
|
+
if r.Method != http.MethodGet {
|
|
256
|
+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
sessions, err := s.listSessionsFresh(r.URL.Query().Get("fresh") == "1")
|
|
260
|
+
if err != nil {
|
|
261
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
// annotate each session with its evaluation state so the rail can show
|
|
265
|
+
// running/finished badges without per-session report requests
|
|
266
|
+
items := make([]sessionListItem, len(sessions))
|
|
267
|
+
for i, meta := range sessions {
|
|
268
|
+
items[i] = sessionListItem{SessionMeta: meta, ReportState: s.reportStateFor(meta)}
|
|
269
|
+
}
|
|
270
|
+
writeJSON(w, items)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
type sessionListItem struct {
|
|
274
|
+
model.SessionMeta
|
|
275
|
+
ReportState string `json:"reportState,omitempty"`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
func (s *Server) handleSessionResource(w http.ResponseWriter, r *http.Request) {
|
|
279
|
+
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/sessions/"), "/")
|
|
280
|
+
if len(parts) == 2 && parts[1] == "agents" {
|
|
281
|
+
s.handleSessionAgents(w, r, parts[0])
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
if len(parts) == 4 && parts[1] == "agents" && parts[3] == "trace" {
|
|
285
|
+
s.handleSessionAgentTrace(w, r, parts[0], parts[2])
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if len(parts) != 2 {
|
|
289
|
+
http.NotFound(w, r)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
selector, resource := parts[0], parts[1]
|
|
293
|
+
switch resource {
|
|
294
|
+
case "snapshot":
|
|
295
|
+
trace, city, err := s.traceAndMap(selector)
|
|
296
|
+
if err != nil {
|
|
297
|
+
http.Error(w, err.Error(), http.StatusNotFound)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
writeJSON(w, struct {
|
|
301
|
+
Trace *model.Trace `json:"trace"`
|
|
302
|
+
City *model.CityMap `json:"city"`
|
|
303
|
+
}{Trace: trace, City: city})
|
|
304
|
+
case "trace":
|
|
305
|
+
trace, _, err := s.traceAndMap(selector)
|
|
306
|
+
if err != nil {
|
|
307
|
+
http.Error(w, err.Error(), http.StatusNotFound)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
writeJSON(w, trace)
|
|
311
|
+
case "citymap":
|
|
312
|
+
_, city, err := s.traceAndMap(selector)
|
|
313
|
+
if err != nil {
|
|
314
|
+
http.Error(w, err.Error(), http.StatusNotFound)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
writeJSON(w, city)
|
|
318
|
+
case "report":
|
|
319
|
+
s.handleSessionReport(w, r, selector)
|
|
320
|
+
case "analyze":
|
|
321
|
+
s.handleSessionAnalyze(w, r, selector)
|
|
322
|
+
default:
|
|
323
|
+
http.NotFound(w, r)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
func (s *Server) handleSessionAgents(w http.ResponseWriter, r *http.Request, selector string) {
|
|
328
|
+
if r.Method != http.MethodGet {
|
|
329
|
+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
root, err := s.findSession(selector)
|
|
333
|
+
if err != nil {
|
|
334
|
+
http.Error(w, "session not found", http.StatusNotFound)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
graph, err := s.agentGraph(root)
|
|
338
|
+
if err != nil {
|
|
339
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
writeJSON(w, graph)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func (s *Server) handleSessionAgentTrace(w http.ResponseWriter, r *http.Request, selector, nodeID string) {
|
|
346
|
+
if r.Method != http.MethodGet {
|
|
347
|
+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
root, err := s.findSession(selector)
|
|
351
|
+
if err != nil {
|
|
352
|
+
http.Error(w, "session not found", http.StatusNotFound)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
graph, err := s.agentGraph(root)
|
|
356
|
+
if err != nil {
|
|
357
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
var node *model.AgentNode
|
|
361
|
+
for i := range graph.Agents {
|
|
362
|
+
if graph.Agents[i].ID == nodeID {
|
|
363
|
+
node = &graph.Agents[i]
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if node == nil {
|
|
368
|
+
http.Error(w, "agent not found", http.StatusNotFound)
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if node.TraceAvailability != model.TraceAvailabilityAvailable {
|
|
372
|
+
http.Error(w, "agent trace unavailable: "+node.TraceAvailability, http.StatusConflict)
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if node.Kind == model.AgentKindMain {
|
|
377
|
+
trace, _, err := s.traceAndMapMeta(root)
|
|
378
|
+
if err != nil {
|
|
379
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
writeJSON(w, trace)
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
child, err := s.findCatalogSession(node.TraceSessionKey)
|
|
386
|
+
if err != nil {
|
|
387
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
_, rootCity, err := s.traceAndMapMeta(root)
|
|
391
|
+
if err != nil {
|
|
392
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
// The raw layer caches the unprojected parse; projecting onto the root
|
|
396
|
+
// city clones, so the cached trace is never mutated.
|
|
397
|
+
trace, err := s.traceStore.LoadRaw(child)
|
|
398
|
+
if err != nil {
|
|
399
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
writeJSON(w, traceAgainstCity(trace, rootCity))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// handleRepoMap serves the citymap for a repo with no session / trace attached.
|
|
406
|
+
// It backs the static full-repo map view (mindwalk map <repo> and the ?map=1 UI
|
|
407
|
+
// mode). The repo path comes from the ?repo= query param, falling back to the
|
|
408
|
+
// server's configured RepoRoot. Maps are cached per path with a short TTL so a
|
|
409
|
+
// long-running serve picks up tree changes, and the cache is size-bounded.
|
|
410
|
+
//
|
|
411
|
+
// The path is trusted: the server is localhost-only and already builds citymaps
|
|
412
|
+
// for arbitrary session repos, so accepting a repo path here does not widen the
|
|
413
|
+
// read surface. The builder only reads the tree (git ls-files / walk).
|
|
414
|
+
func (s *Server) handleRepoMap(w http.ResponseWriter, r *http.Request) {
|
|
415
|
+
if r.Method != http.MethodGet {
|
|
416
|
+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
repo := r.URL.Query().Get("repo")
|
|
420
|
+
if repo == "" {
|
|
421
|
+
repo = s.cfg.RepoRoot
|
|
422
|
+
}
|
|
423
|
+
if repo == "" {
|
|
424
|
+
http.Error(w, "no repo configured", http.StatusNotFound)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
city, err := s.repoCityMap(repo)
|
|
428
|
+
if err != nil {
|
|
429
|
+
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
writeJSON(w, city)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
func (s *Server) repoCityMap(repo string) (*model.CityMap, error) {
|
|
436
|
+
if abs, err := filepath.Abs(repo); err == nil {
|
|
437
|
+
repo = abs
|
|
438
|
+
}
|
|
439
|
+
s.repoMapMu.Lock()
|
|
440
|
+
defer s.repoMapMu.Unlock()
|
|
441
|
+
if entry, ok := s.repoMaps[repo]; ok && time.Since(entry.builtAt) < repoMapTTL {
|
|
442
|
+
return entry.city, nil
|
|
443
|
+
}
|
|
444
|
+
city, err := s.buildCityMap(repo, nil)
|
|
445
|
+
if err != nil {
|
|
446
|
+
return nil, err
|
|
447
|
+
}
|
|
448
|
+
s.repoMaps[repo] = repoMapEntry{city: city, builtAt: time.Now()}
|
|
449
|
+
s.evictRepoMapsLocked()
|
|
450
|
+
return city, nil
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// evictRepoMapsLocked bounds the repo-map cache by dropping the oldest entries
|
|
454
|
+
// once it grows past repoMapMaxEntries. Caller must hold repoMapMu.
|
|
455
|
+
func (s *Server) evictRepoMapsLocked() {
|
|
456
|
+
for len(s.repoMaps) > repoMapMaxEntries {
|
|
457
|
+
var oldestKey string
|
|
458
|
+
var oldest time.Time
|
|
459
|
+
for key, entry := range s.repoMaps {
|
|
460
|
+
if oldestKey == "" || entry.builtAt.Before(oldest) {
|
|
461
|
+
oldestKey = key
|
|
462
|
+
oldest = entry.builtAt
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if oldestKey == "" {
|
|
466
|
+
return
|
|
467
|
+
}
|
|
468
|
+
delete(s.repoMaps, oldestKey)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
func (s *Server) listSessions() ([]model.SessionMeta, error) {
|
|
473
|
+
return s.listSessionsFresh(false)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func (s *Server) listSessionsFresh(fresh bool) ([]model.SessionMeta, error) {
|
|
477
|
+
s.mu.Lock()
|
|
478
|
+
observedFreshGen := s.freshGen
|
|
479
|
+
s.mu.Unlock()
|
|
480
|
+
return s.listSessionsObserved(fresh, observedFreshGen)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func (s *Server) listSessionsObserved(fresh bool, observedFreshGen uint64) ([]model.SessionMeta, error) {
|
|
484
|
+
// scanMu serializes scans so callers arriving mid-scan wait for the
|
|
485
|
+
// in-flight result instead of duplicating the walk
|
|
486
|
+
s.scanMu.Lock()
|
|
487
|
+
defer s.scanMu.Unlock()
|
|
488
|
+
s.mu.Lock()
|
|
489
|
+
if s.sessions != nil && ((!fresh && time.Since(s.sessionAt) < sessionListTTL) || (fresh && s.freshGen != observedFreshGen)) {
|
|
490
|
+
sessions := append([]model.SessionMeta(nil), s.sessions...)
|
|
491
|
+
s.mu.Unlock()
|
|
492
|
+
return sessions, nil
|
|
493
|
+
}
|
|
494
|
+
s.mu.Unlock()
|
|
495
|
+
|
|
496
|
+
sessions, err := s.scanSessions()
|
|
497
|
+
if err != nil {
|
|
498
|
+
return nil, err
|
|
499
|
+
}
|
|
500
|
+
if s.cfg.OpenSession != "" {
|
|
501
|
+
meta, err := s.summarizeAnyCached(s.cfg.OpenSession, nil)
|
|
502
|
+
if err == nil {
|
|
503
|
+
s.mu.Lock()
|
|
504
|
+
s.sessionCatalog[meta.Key] = meta
|
|
505
|
+
s.mu.Unlock()
|
|
506
|
+
if !meta.Auxiliary {
|
|
507
|
+
found := false
|
|
508
|
+
for i := range sessions {
|
|
509
|
+
if sessions[i].Key == meta.Key {
|
|
510
|
+
sessions[i] = meta
|
|
511
|
+
found = true
|
|
512
|
+
break
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if !found {
|
|
516
|
+
sessions = append([]model.SessionMeta{meta}, sessions...)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
sort.SliceStable(sessions, func(i, j int) bool {
|
|
522
|
+
return sessions[i].EndedAt > sessions[j].EndedAt
|
|
523
|
+
})
|
|
524
|
+
s.mu.Lock()
|
|
525
|
+
s.sessions = sessions
|
|
526
|
+
s.sessionAt = time.Now()
|
|
527
|
+
if fresh {
|
|
528
|
+
s.freshGen++
|
|
529
|
+
clear(s.agentGraphs)
|
|
530
|
+
}
|
|
531
|
+
s.mu.Unlock()
|
|
532
|
+
return sessions, nil
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
func (s *Server) scanSessions() ([]model.SessionMeta, error) {
|
|
536
|
+
type sessionFile struct {
|
|
537
|
+
source adapter.Source
|
|
538
|
+
path string
|
|
539
|
+
info fs.FileInfo
|
|
540
|
+
}
|
|
541
|
+
seen := map[string]bool{}
|
|
542
|
+
var files []sessionFile
|
|
543
|
+
for _, source := range s.adapters {
|
|
544
|
+
dir := source.SessionDir()
|
|
545
|
+
if dir == "" {
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
|
549
|
+
continue
|
|
550
|
+
}
|
|
551
|
+
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
|
|
552
|
+
if walkErr != nil {
|
|
553
|
+
return nil
|
|
554
|
+
}
|
|
555
|
+
if entry.IsDir() {
|
|
556
|
+
return nil
|
|
557
|
+
}
|
|
558
|
+
if filepath.Ext(path) != ".jsonl" {
|
|
559
|
+
return nil
|
|
560
|
+
}
|
|
561
|
+
info, err := entry.Info()
|
|
562
|
+
if err != nil {
|
|
563
|
+
return nil
|
|
564
|
+
}
|
|
565
|
+
seen[summaryKey(source, path)] = true
|
|
566
|
+
files = append(files, sessionFile{source: source, path: path, info: info})
|
|
567
|
+
return nil
|
|
568
|
+
})
|
|
569
|
+
if err != nil {
|
|
570
|
+
return nil, err
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// summarizing reads every uncached session file; spread the parsing
|
|
575
|
+
// across cores so a cold scan doesn't serialize gigabytes of JSONL
|
|
576
|
+
results := make([]*model.SessionMeta, len(files))
|
|
577
|
+
workers := runtime.NumCPU()
|
|
578
|
+
if workers > len(files) {
|
|
579
|
+
workers = len(files)
|
|
580
|
+
}
|
|
581
|
+
if workers > 1 {
|
|
582
|
+
jobs := make(chan int)
|
|
583
|
+
var wg sync.WaitGroup
|
|
584
|
+
for w := 0; w < workers; w++ {
|
|
585
|
+
wg.Add(1)
|
|
586
|
+
go func() {
|
|
587
|
+
defer wg.Done()
|
|
588
|
+
for i := range jobs {
|
|
589
|
+
if meta, err := s.summarizeCached(files[i].source, files[i].path, files[i].info); err == nil {
|
|
590
|
+
results[i] = &meta
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}()
|
|
594
|
+
}
|
|
595
|
+
for i := range files {
|
|
596
|
+
jobs <- i
|
|
597
|
+
}
|
|
598
|
+
close(jobs)
|
|
599
|
+
wg.Wait()
|
|
600
|
+
} else {
|
|
601
|
+
for i := range files {
|
|
602
|
+
if meta, err := s.summarizeCached(files[i].source, files[i].path, files[i].info); err == nil {
|
|
603
|
+
results[i] = &meta
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
catalog := make(map[string]model.SessionMeta, len(files))
|
|
609
|
+
sessions := make([]model.SessionMeta, 0, len(files))
|
|
610
|
+
for _, meta := range results {
|
|
611
|
+
if meta == nil {
|
|
612
|
+
continue
|
|
613
|
+
}
|
|
614
|
+
catalog[meta.Key] = *meta
|
|
615
|
+
if !meta.Auxiliary {
|
|
616
|
+
sessions = append(sessions, *meta)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
s.mu.Lock()
|
|
620
|
+
s.sessionCatalog = catalog
|
|
621
|
+
s.mu.Unlock()
|
|
622
|
+
s.pruneSummaryCache(seen)
|
|
623
|
+
return sessions, nil
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
func (s *Server) summarizeAnyCached(path string, info fs.FileInfo) (model.SessionMeta, error) {
|
|
627
|
+
var lastErr error
|
|
628
|
+
for _, source := range s.adapters {
|
|
629
|
+
meta, err := s.summarizeCached(source, path, info)
|
|
630
|
+
if err == nil {
|
|
631
|
+
return meta, nil
|
|
632
|
+
}
|
|
633
|
+
lastErr = err
|
|
634
|
+
}
|
|
635
|
+
if lastErr != nil {
|
|
636
|
+
return model.SessionMeta{}, lastErr
|
|
637
|
+
}
|
|
638
|
+
return model.SessionMeta{}, errors.New("no session adapters configured")
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
func (s *Server) summarizeCached(source adapter.Source, path string, info fs.FileInfo) (model.SessionMeta, error) {
|
|
642
|
+
if info == nil {
|
|
643
|
+
var err error
|
|
644
|
+
info, err = os.Stat(path)
|
|
645
|
+
if err != nil {
|
|
646
|
+
return model.SessionMeta{}, err
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
key := summaryKey(source, path)
|
|
650
|
+
sidecar := summarySidecarDigest(source, path)
|
|
651
|
+
s.mu.Lock()
|
|
652
|
+
if cached, ok := s.summaries[key]; ok && cached.size == info.Size() && cached.modTime.Equal(info.ModTime()) &&
|
|
653
|
+
cached.sidecar == sidecar {
|
|
654
|
+
meta := cached.meta
|
|
655
|
+
s.mu.Unlock()
|
|
656
|
+
return meta, nil
|
|
657
|
+
}
|
|
658
|
+
s.mu.Unlock()
|
|
659
|
+
|
|
660
|
+
meta, err := source.Summarize(path)
|
|
661
|
+
if err != nil {
|
|
662
|
+
return model.SessionMeta{}, err
|
|
663
|
+
}
|
|
664
|
+
if meta.Key == "" {
|
|
665
|
+
meta.Key = adapter.SessionKey(source.Harness(), path)
|
|
666
|
+
}
|
|
667
|
+
s.mu.Lock()
|
|
668
|
+
s.summaries[key] = summaryCacheEntry{
|
|
669
|
+
size: info.Size(),
|
|
670
|
+
modTime: info.ModTime(),
|
|
671
|
+
sidecar: sidecar,
|
|
672
|
+
meta: meta,
|
|
673
|
+
}
|
|
674
|
+
s.mu.Unlock()
|
|
675
|
+
return meta, nil
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// summarySidecarDigest fingerprints the companion files the source declares
|
|
679
|
+
// for path. The server holds no harness-specific sidecar knowledge — sources
|
|
680
|
+
// that keep files beside their session logs implement SummarySidecarSource.
|
|
681
|
+
func summarySidecarDigest(source adapter.Source, path string) string {
|
|
682
|
+
sidecars, ok := source.(adapter.SummarySidecarSource)
|
|
683
|
+
if !ok {
|
|
684
|
+
return ""
|
|
685
|
+
}
|
|
686
|
+
inputs := sidecars.SummaryInputs(path)
|
|
687
|
+
if len(inputs) == 0 {
|
|
688
|
+
return ""
|
|
689
|
+
}
|
|
690
|
+
var material strings.Builder
|
|
691
|
+
for _, input := range inputs {
|
|
692
|
+
if fingerprint, err := fingerprintFile(input); err == nil {
|
|
693
|
+
fmt.Fprintf(&material, "%s\x00%d\x00%d\n", input, fingerprint.size, fingerprint.modTime.UnixNano())
|
|
694
|
+
} else {
|
|
695
|
+
fmt.Fprintf(&material, "%s\x00missing\n", input)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return material.String()
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
func (s *Server) pruneSummaryCache(seen map[string]bool) {
|
|
702
|
+
s.mu.Lock()
|
|
703
|
+
defer s.mu.Unlock()
|
|
704
|
+
for key := range s.summaries {
|
|
705
|
+
if !seen[key] && summaryPath(key) != s.cfg.OpenSession {
|
|
706
|
+
delete(s.summaries, key)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
func (s *Server) traceAndMap(selector string) (*model.Trace, *model.CityMap, error) {
|
|
712
|
+
meta, err := s.findSession(selector)
|
|
713
|
+
if err != nil {
|
|
714
|
+
return nil, nil, err
|
|
715
|
+
}
|
|
716
|
+
return s.traceAndMapMeta(meta)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
func (s *Server) traceAndMapMeta(meta model.SessionMeta) (*model.Trace, *model.CityMap, error) {
|
|
720
|
+
return s.traceStore.LoadSnapshot(meta)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
func (s *Server) agentGraph(root model.SessionMeta) (*model.AgentGraph, error) {
|
|
724
|
+
source := s.adapterForHarness(root.Harness)
|
|
725
|
+
graphSource, ok := source.(adapter.AgentGraphSource)
|
|
726
|
+
if !ok {
|
|
727
|
+
// A source without subagent support is not a server failure: answer
|
|
728
|
+
// with the same single-root graph a graph-capable source returns for
|
|
729
|
+
// a session that launched no subagents.
|
|
730
|
+
return &model.AgentGraph{
|
|
731
|
+
Version: model.AgentGraphVersion,
|
|
732
|
+
RootSessionKey: root.Key,
|
|
733
|
+
Agents: []model.AgentNode{{
|
|
734
|
+
ID: adapter.AgentNodeID(root.Harness, root.Key, "root:"+root.Key),
|
|
735
|
+
Depth: 0,
|
|
736
|
+
Kind: model.AgentKindMain,
|
|
737
|
+
Label: "Main",
|
|
738
|
+
Status: model.AgentStatusMain,
|
|
739
|
+
TraceAvailability: model.TraceAvailabilityAvailable,
|
|
740
|
+
TraceSessionKey: root.Key,
|
|
741
|
+
TraceEventCount: root.EventCount,
|
|
742
|
+
LinkQuality: model.AgentLinkQualityExact,
|
|
743
|
+
LinkMethod: model.AgentLinkMethodRoot,
|
|
744
|
+
}},
|
|
745
|
+
}, nil
|
|
746
|
+
}
|
|
747
|
+
for {
|
|
748
|
+
s.mu.Lock()
|
|
749
|
+
catalog := make([]model.SessionMeta, 0, len(s.sessionCatalog))
|
|
750
|
+
for _, session := range s.sessionCatalog {
|
|
751
|
+
catalog = append(catalog, session)
|
|
752
|
+
}
|
|
753
|
+
freshGen := s.freshGen
|
|
754
|
+
s.mu.Unlock()
|
|
755
|
+
sort.Slice(catalog, func(i, j int) bool { return catalog[i].Key < catalog[j].Key })
|
|
756
|
+
|
|
757
|
+
inputs, err := graphSource.AgentGraphInputs(root, catalog)
|
|
758
|
+
if err != nil {
|
|
759
|
+
return nil, err
|
|
760
|
+
}
|
|
761
|
+
fingerprint, err := fingerprintAgentGraphInputs(inputs, freshGen)
|
|
762
|
+
if err != nil {
|
|
763
|
+
return nil, err
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
s.mu.Lock()
|
|
767
|
+
if cached, ok := s.agentGraphs[root.Key]; ok && cached.fingerprint == fingerprint {
|
|
768
|
+
cached.used = time.Now()
|
|
769
|
+
s.agentGraphs[root.Key] = cached
|
|
770
|
+
s.mu.Unlock()
|
|
771
|
+
return cached.graph, nil
|
|
772
|
+
}
|
|
773
|
+
if load := s.agentGraphLoads[root.Key]; load != nil {
|
|
774
|
+
done := load.done
|
|
775
|
+
shareSnapshot := load.fingerprint == fingerprint
|
|
776
|
+
s.mu.Unlock()
|
|
777
|
+
<-done
|
|
778
|
+
if shareSnapshot {
|
|
779
|
+
return load.graph, load.err
|
|
780
|
+
}
|
|
781
|
+
continue
|
|
782
|
+
}
|
|
783
|
+
load := &inflightAgentGraph{done: make(chan struct{}), fingerprint: fingerprint}
|
|
784
|
+
s.agentGraphLoads[root.Key] = load
|
|
785
|
+
s.mu.Unlock()
|
|
786
|
+
|
|
787
|
+
s.runAgentGraphInflight(root.Key, load, graphSource, root, catalog)
|
|
788
|
+
return load.graph, load.err
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
func fingerprintAgentGraphInputs(paths []string, freshGen uint64) (agentGraphFingerprint, error) {
|
|
793
|
+
paths = append([]string(nil), paths...)
|
|
794
|
+
sort.Strings(paths)
|
|
795
|
+
var material strings.Builder
|
|
796
|
+
fmt.Fprintf(&material, "fresh:%d\n", freshGen)
|
|
797
|
+
previous := ""
|
|
798
|
+
for _, path := range paths {
|
|
799
|
+
path = filepath.Clean(path)
|
|
800
|
+
if path == previous {
|
|
801
|
+
continue
|
|
802
|
+
}
|
|
803
|
+
previous = path
|
|
804
|
+
info, err := os.Stat(path)
|
|
805
|
+
if os.IsNotExist(err) {
|
|
806
|
+
fmt.Fprintf(&material, "%s\x00missing\n", path)
|
|
807
|
+
continue
|
|
808
|
+
}
|
|
809
|
+
if err != nil {
|
|
810
|
+
return agentGraphFingerprint{}, err
|
|
811
|
+
}
|
|
812
|
+
fmt.Fprintf(&material, "%s\x00%d\x00%d\n", path, info.Size(), info.ModTime().UnixNano())
|
|
813
|
+
}
|
|
814
|
+
return agentGraphFingerprint{digest: sha256.Sum256([]byte(material.String())), freshGen: freshGen}, nil
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
func (s *Server) runAgentGraphInflight(key string, load *inflightAgentGraph, source adapter.AgentGraphSource, root model.SessionMeta, catalog []model.SessionMeta) {
|
|
818
|
+
defer func() {
|
|
819
|
+
if r := recover(); r != nil {
|
|
820
|
+
load.graph = nil
|
|
821
|
+
load.err = fmt.Errorf("build agent graph %s: %v", key, r)
|
|
822
|
+
log.Printf("mindwalk: panic building agent graph %s: %v\n%s", key, r, debug.Stack())
|
|
823
|
+
}
|
|
824
|
+
s.mu.Lock()
|
|
825
|
+
if load.err == nil {
|
|
826
|
+
s.agentGraphs[key] = agentGraphCacheEntry{fingerprint: load.fingerprint, graph: load.graph, used: time.Now()}
|
|
827
|
+
s.evictAgentGraphsLocked()
|
|
828
|
+
}
|
|
829
|
+
if s.agentGraphLoads[key] == load {
|
|
830
|
+
delete(s.agentGraphLoads, key)
|
|
831
|
+
}
|
|
832
|
+
close(load.done)
|
|
833
|
+
s.mu.Unlock()
|
|
834
|
+
}()
|
|
835
|
+
load.graph, load.err = source.BuildAgentGraph(root, catalog)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// evictAgentGraphsLocked bounds the graph cache by dropping the least
|
|
839
|
+
// recently used entries. Caller must hold mu.
|
|
840
|
+
func (s *Server) evictAgentGraphsLocked() {
|
|
841
|
+
for len(s.agentGraphs) > agentGraphMaxEntries {
|
|
842
|
+
var oldestKey string
|
|
843
|
+
var oldest time.Time
|
|
844
|
+
for key, entry := range s.agentGraphs {
|
|
845
|
+
if oldestKey == "" || entry.used.Before(oldest) {
|
|
846
|
+
oldestKey = key
|
|
847
|
+
oldest = entry.used
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if oldestKey == "" {
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
delete(s.agentGraphs, oldestKey)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
func (s *Server) findCatalogSession(key string) (model.SessionMeta, error) {
|
|
858
|
+
s.mu.Lock()
|
|
859
|
+
defer s.mu.Unlock()
|
|
860
|
+
meta, ok := s.sessionCatalog[key]
|
|
861
|
+
if !ok {
|
|
862
|
+
return model.SessionMeta{}, errors.New("session not found")
|
|
863
|
+
}
|
|
864
|
+
return meta, nil
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
func (s *Server) loadTraceAndMap(meta model.SessionMeta) (*model.Trace, *model.CityMap, error) {
|
|
868
|
+
trace, err := s.parseSessionTrace(meta)
|
|
869
|
+
if err != nil {
|
|
870
|
+
return nil, nil, err
|
|
871
|
+
}
|
|
872
|
+
repoRoot := trace.Session.Cwd
|
|
873
|
+
if repoRoot == "" {
|
|
874
|
+
repoRoot = meta.Cwd
|
|
875
|
+
}
|
|
876
|
+
if repoRoot == "" {
|
|
877
|
+
repoRoot = s.cfg.RepoRoot
|
|
878
|
+
}
|
|
879
|
+
if repoRoot == "" {
|
|
880
|
+
repoRoot = filepath.Dir(meta.Path)
|
|
881
|
+
}
|
|
882
|
+
city, err := s.buildCityMap(repoRoot, trace)
|
|
883
|
+
if err != nil {
|
|
884
|
+
log.Printf("mindwalk: citymap build failed for %s: %v; serving empty map", repoRoot, err)
|
|
885
|
+
city = emptyCityMap(repoRoot)
|
|
886
|
+
} else {
|
|
887
|
+
assignFileIDs(trace, city)
|
|
888
|
+
}
|
|
889
|
+
// Recompute with the citymap's file count, carrying over the adapter's
|
|
890
|
+
// grade for its error signal — the recount cannot re-derive it.
|
|
891
|
+
trace.Stats = model.ComputeStats(trace, repoFileCount(city), trace.Stats.Observability.Errors)
|
|
892
|
+
return trace, city, nil
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
func (s *Server) parseSessionTrace(meta model.SessionMeta) (*model.Trace, error) {
|
|
896
|
+
source := s.adapterForHarness(meta.Harness)
|
|
897
|
+
if source == nil {
|
|
898
|
+
return nil, fmt.Errorf("no adapter for harness %q", meta.Harness)
|
|
899
|
+
}
|
|
900
|
+
trace, parseErr := source.Parse(meta.Path)
|
|
901
|
+
if trace == nil {
|
|
902
|
+
if parseErr != nil {
|
|
903
|
+
return nil, parseErr
|
|
904
|
+
}
|
|
905
|
+
return nil, errors.New("trace unavailable")
|
|
906
|
+
}
|
|
907
|
+
return trace, nil
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
func emptyCityMap(repoRoot string) *model.CityMap {
|
|
911
|
+
root, err := filepath.Abs(repoRoot)
|
|
912
|
+
if err != nil {
|
|
913
|
+
root = repoRoot
|
|
914
|
+
}
|
|
915
|
+
return &model.CityMap{
|
|
916
|
+
Version: 1,
|
|
917
|
+
Repo: model.RepoMeta{
|
|
918
|
+
Root: root,
|
|
919
|
+
Dirty: false,
|
|
920
|
+
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
921
|
+
},
|
|
922
|
+
Files: []model.CityFile{},
|
|
923
|
+
Dirs: []model.CityDir{},
|
|
924
|
+
Layout: model.LayoutMeta{
|
|
925
|
+
Algorithm: "unavailable",
|
|
926
|
+
Weight: "none",
|
|
927
|
+
},
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
func repoFileCount(city *model.CityMap) int {
|
|
932
|
+
count := 0
|
|
933
|
+
for _, file := range city.Files {
|
|
934
|
+
if !file.Ghost {
|
|
935
|
+
count++
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return count
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
func (s *Server) findSession(selector string) (model.SessionMeta, error) {
|
|
942
|
+
sessions, err := s.listSessions()
|
|
943
|
+
if err != nil {
|
|
944
|
+
return model.SessionMeta{}, err
|
|
945
|
+
}
|
|
946
|
+
for _, session := range sessions {
|
|
947
|
+
if session.Key == selector {
|
|
948
|
+
return session, nil
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
var matches []model.SessionMeta
|
|
952
|
+
for _, session := range sessions {
|
|
953
|
+
basename := strings.TrimSuffix(filepath.Base(session.Path), filepath.Ext(session.Path))
|
|
954
|
+
if session.ID == selector || basename == selector {
|
|
955
|
+
matches = append(matches, session)
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if len(matches) == 1 {
|
|
959
|
+
return matches[0], nil
|
|
960
|
+
}
|
|
961
|
+
if len(matches) > 1 {
|
|
962
|
+
return model.SessionMeta{}, fmt.Errorf("session selector %q is ambiguous; use the session key", selector)
|
|
963
|
+
}
|
|
964
|
+
return model.SessionMeta{}, errors.New("session not found")
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
func fingerprintFile(path string) (fileFingerprint, error) {
|
|
968
|
+
info, err := os.Stat(path)
|
|
969
|
+
if err != nil {
|
|
970
|
+
return fileFingerprint{}, err
|
|
971
|
+
}
|
|
972
|
+
return fileFingerprint{size: info.Size(), modTime: info.ModTime()}, nil
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
func (f fileFingerprint) equal(other fileFingerprint) bool {
|
|
976
|
+
return f.size == other.size && f.modTime.Equal(other.modTime)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
func (s *Server) openSessionKey() string {
|
|
980
|
+
key := strings.TrimSuffix(filepath.Base(s.cfg.OpenSession), filepath.Ext(s.cfg.OpenSession))
|
|
981
|
+
if meta, err := s.summarizeAnyCached(s.cfg.OpenSession, nil); err == nil && meta.Key != "" {
|
|
982
|
+
key = meta.Key
|
|
983
|
+
}
|
|
984
|
+
return key
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
func (s *Server) adapterForHarness(harness string) adapter.Source {
|
|
988
|
+
for _, source := range s.adapters {
|
|
989
|
+
if source.Harness() == harness {
|
|
990
|
+
return source
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return nil
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
func summaryKey(source adapter.Source, path string) string {
|
|
997
|
+
return source.Harness() + "\x00" + path
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
func summaryPath(key string) string {
|
|
1001
|
+
if idx := strings.IndexByte(key, 0); idx >= 0 {
|
|
1002
|
+
return key[idx+1:]
|
|
1003
|
+
}
|
|
1004
|
+
return key
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
func assignFileIDs(trace *model.Trace, city *model.CityMap) {
|
|
1008
|
+
ids := map[string]int{}
|
|
1009
|
+
for _, file := range city.Files {
|
|
1010
|
+
ids[file.Path] = file.ID
|
|
1011
|
+
}
|
|
1012
|
+
for ei := range trace.Events {
|
|
1013
|
+
for ti := range trace.Events[ei].Targets {
|
|
1014
|
+
trace.Events[ei].Targets[ti].FileID = nil
|
|
1015
|
+
if id, ok := ids[trace.Events[ei].Targets[ti].Path]; ok {
|
|
1016
|
+
v := id
|
|
1017
|
+
trace.Events[ei].Targets[ti].FileID = &v
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
func traceAgainstCity(trace *model.Trace, city *model.CityMap) *model.Trace {
|
|
1024
|
+
clone := *trace
|
|
1025
|
+
clone.Events = append([]model.Event{}, trace.Events...)
|
|
1026
|
+
for i := range clone.Events {
|
|
1027
|
+
clone.Events[i].Targets = append([]model.Target{}, trace.Events[i].Targets...)
|
|
1028
|
+
for j := range clone.Events[i].Targets {
|
|
1029
|
+
clone.Events[i].Targets[j].Lines = append([][2]int{}, trace.Events[i].Targets[j].Lines...)
|
|
1030
|
+
}
|
|
1031
|
+
clone.Events[i].Outside = append([]model.OutsideTouch{}, trace.Events[i].Outside...)
|
|
1032
|
+
}
|
|
1033
|
+
clone.Marks = append([]model.Mark{}, trace.Marks...)
|
|
1034
|
+
assignFileIDs(&clone, city)
|
|
1035
|
+
clone.Stats = model.ComputeStats(&clone, repoFileCount(city), trace.Stats.Observability.Errors)
|
|
1036
|
+
return &clone
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
|
1040
|
+
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
1041
|
+
http.NotFound(w, r)
|
|
1042
|
+
return
|
|
1043
|
+
}
|
|
1044
|
+
if s.cfg.Dev && s.serveDist(w, r) {
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
static, _ := fs.Sub(embeddedStatic, "static")
|
|
1048
|
+
http.FileServer(http.FS(static)).ServeHTTP(w, r)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
func (s *Server) serveDist(w http.ResponseWriter, r *http.Request) bool {
|
|
1052
|
+
candidates := []string{
|
|
1053
|
+
filepath.Join("web", "dist"),
|
|
1054
|
+
filepath.Join("..", "web", "dist"),
|
|
1055
|
+
}
|
|
1056
|
+
for _, root := range candidates {
|
|
1057
|
+
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
|
1058
|
+
continue
|
|
1059
|
+
}
|
|
1060
|
+
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
1061
|
+
if path == "" {
|
|
1062
|
+
path = "index.html"
|
|
1063
|
+
}
|
|
1064
|
+
full := filepath.Join(root, filepath.Clean(path))
|
|
1065
|
+
if !strings.HasPrefix(full, filepath.Clean(root)) {
|
|
1066
|
+
http.Error(w, "bad path", http.StatusBadRequest)
|
|
1067
|
+
return true
|
|
1068
|
+
}
|
|
1069
|
+
if info, err := os.Stat(full); err != nil || info.IsDir() {
|
|
1070
|
+
full = filepath.Join(root, "index.html")
|
|
1071
|
+
}
|
|
1072
|
+
if ext := filepath.Ext(full); ext != "" {
|
|
1073
|
+
if typ := mime.TypeByExtension(ext); typ != "" {
|
|
1074
|
+
w.Header().Set("Content-Type", typ)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
http.ServeFile(w, r, full)
|
|
1078
|
+
return true
|
|
1079
|
+
}
|
|
1080
|
+
return false
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
func writeJSON(w http.ResponseWriter, v any) {
|
|
1084
|
+
w.Header().Set("Content-Type", "application/json")
|
|
1085
|
+
enc := json.NewEncoder(w)
|
|
1086
|
+
enc.SetIndent("", " ")
|
|
1087
|
+
_ = enc.Encode(v)
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
func openURL(url string) error {
|
|
1091
|
+
switch runtime.GOOS {
|
|
1092
|
+
case "darwin":
|
|
1093
|
+
return exec.Command("open", url).Start()
|
|
1094
|
+
case "windows":
|
|
1095
|
+
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
|
1096
|
+
default:
|
|
1097
|
+
return exec.Command("xdg-open", url).Start()
|
|
1098
|
+
}
|
|
1099
|
+
}
|