@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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/synapse +0 -0
  4. package/cmd/rubriceval/main.go +308 -0
  5. package/cmd/synapse/main.go +280 -0
  6. package/cmd/synapse/main_test.go +16 -0
  7. package/go.mod +7 -0
  8. package/go.sum +6 -0
  9. package/internal/adapter/adapter.go +1117 -0
  10. package/internal/adapter/adapter_test.go +518 -0
  11. package/internal/adapter/agy/adapter.go +193 -0
  12. package/internal/adapter/claudecode/adapter.go +415 -0
  13. package/internal/adapter/claudecode/adapter_test.go +260 -0
  14. package/internal/adapter/claudecode/agents.go +387 -0
  15. package/internal/adapter/claudecode/agents_test.go +480 -0
  16. package/internal/adapter/claudecode/summary_inputs_test.go +19 -0
  17. package/internal/adapter/codex/adapter.go +919 -0
  18. package/internal/adapter/codex/adapter_test.go +920 -0
  19. package/internal/adapter/codex/agents.go +401 -0
  20. package/internal/adapter/codex/agents_test.go +610 -0
  21. package/internal/adapter/codex/summary_inputs_test.go +20 -0
  22. package/internal/adapter/pi/adapter.go +483 -0
  23. package/internal/adapter/pi/adapter_test.go +517 -0
  24. package/internal/citymap/builder.go +1124 -0
  25. package/internal/citymap/builder_test.go +818 -0
  26. package/internal/judge/cache.go +180 -0
  27. package/internal/judge/cli.go +240 -0
  28. package/internal/judge/cli_test.go +68 -0
  29. package/internal/judge/fresh_summary_test.go +37 -0
  30. package/internal/judge/input.go +233 -0
  31. package/internal/judge/judge.go +416 -0
  32. package/internal/judge/judge_test.go +288 -0
  33. package/internal/judge/prompt.go +129 -0
  34. package/internal/judge/rubric.go +275 -0
  35. package/internal/judge/rubric_test.go +642 -0
  36. package/internal/model/agent.go +63 -0
  37. package/internal/model/agent_schema_test.go +86 -0
  38. package/internal/model/agent_test.go +64 -0
  39. package/internal/model/model.go +175 -0
  40. package/internal/model/report.go +166 -0
  41. package/internal/model/stats.go +151 -0
  42. package/internal/model/stats_test.go +89 -0
  43. package/internal/model/trace_schema_test.go +67 -0
  44. package/internal/server/analyze.go +286 -0
  45. package/internal/server/analyze_test.go +297 -0
  46. package/internal/server/codex_index_test.go +40 -0
  47. package/internal/server/hardening_test.go +147 -0
  48. package/internal/server/reportindex.go +91 -0
  49. package/internal/server/reportindex_test.go +116 -0
  50. package/internal/server/server.go +1099 -0
  51. package/internal/server/server_test.go +1389 -0
  52. package/internal/server/static/assets/fraunces-latin-ext-standard-italic-CGbN9UgK.woff2 +0 -0
  53. package/internal/server/static/assets/fraunces-latin-ext-standard-normal-CJcjJNj7.woff2 +0 -0
  54. package/internal/server/static/assets/fraunces-latin-standard-italic-lSdLDfvT.woff2 +0 -0
  55. package/internal/server/static/assets/fraunces-latin-standard-normal-DihXLNYH.woff2 +0 -0
  56. package/internal/server/static/assets/fraunces-vietnamese-standard-italic-DxWqP7Ku.woff2 +0 -0
  57. package/internal/server/static/assets/fraunces-vietnamese-standard-normal-Czevyj-6.woff2 +0 -0
  58. package/internal/server/static/assets/index-BNoY_BiB.css +1 -0
  59. package/internal/server/static/assets/index-C_adLrJr.js +3 -0
  60. package/internal/server/static/assets/react-gcHzaSmV.js +10 -0
  61. package/internal/server/static/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  62. package/internal/server/static/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  63. package/internal/server/static/assets/three-DnGjZfD1.js +4012 -0
  64. package/internal/server/static/index.html +26 -0
  65. package/internal/server/tracestore.go +173 -0
  66. package/internal/server/tracestore_test.go +51 -0
  67. package/internal/textutil/truncate.go +30 -0
  68. package/internal/textutil/truncate_test.go +39 -0
  69. package/package.json +35 -0
  70. package/web/e2e/agent-lens.spec.ts +688 -0
  71. package/web/index.html +23 -0
  72. package/web/package-lock.json +1933 -0
  73. package/web/package.json +33 -0
  74. package/web/playwright.config.ts +24 -0
  75. package/web/src/App.tsx +876 -0
  76. package/web/src/api/client.ts +74 -0
  77. package/web/src/main.tsx +12 -0
  78. package/web/src/playback/recorder.ts +160 -0
  79. package/web/src/playback/reducer.ts +91 -0
  80. package/web/src/scene/CityScene.tsx +638 -0
  81. package/web/src/scene/TreeScene.tsx +656 -0
  82. package/web/src/scene/dirLabels.ts +145 -0
  83. package/web/src/scene/sceneUtils.ts +144 -0
  84. package/web/src/scene/textures.ts +60 -0
  85. package/web/src/scene/trail.ts +79 -0
  86. package/web/src/scene/treeLayout.ts +169 -0
  87. package/web/src/state/filters.ts +40 -0
  88. package/web/src/state/store.ts +83 -0
  89. package/web/src/styles.css +2565 -0
  90. package/web/src/types.ts +315 -0
  91. package/web/src/ui/AgentsPanel.tsx +376 -0
  92. package/web/src/ui/Dock.tsx +104 -0
  93. package/web/src/ui/Hud.tsx +335 -0
  94. package/web/src/ui/Inspector.tsx +107 -0
  95. package/web/src/ui/LogoMark.tsx +38 -0
  96. package/web/src/ui/ReportPanel.tsx +491 -0
  97. package/web/src/ui/SessionRail.tsx +316 -0
  98. package/web/src/ui/Timeline.tsx +458 -0
  99. package/web/src/ui/ViewPanel.tsx +45 -0
  100. package/web/src/ui/shortcuts.ts +4 -0
  101. package/web/tsconfig.json +21 -0
  102. 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
+ }