@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,297 @@
1
+ package server
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/hybridlabor-api/bdb-synapse/internal/judge"
14
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
15
+ )
16
+
17
+ type stubJudge struct {
18
+ output string
19
+ err error
20
+ }
21
+
22
+ func (s stubJudge) Run(ctx context.Context, prompt, input string) (judge.RunResult, error) {
23
+ return judge.RunResult{Text: s.output, Model: "stub-model"}, s.err
24
+ }
25
+
26
+ func (s stubJudge) Name() string { return "stub" }
27
+
28
+ const stubJudgeOutput = `{"task_summary":"demo","dimensions":[
29
+ {"name":"exploration","findings":[{"claim":"读了再改","severity":"info","evidence_seqs":[0]}]},
30
+ {"name":"scope","findings":[]},
31
+ {"name":"wandering","findings":[]},
32
+ {"name":"verification","findings":[{"claim":"改后未验证","severity":"warning","evidence_seqs":[0]}]}],
33
+ "notable_moments":[],"narrative":"ok"}`
34
+
35
+ func TestAnalyzeEndpointRunsJudgeAndServesReport(t *testing.T) {
36
+ claudeDir := t.TempDir()
37
+ writeServerSession(t, filepath.Join(claudeDir, "eval.jsonl"),
38
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":"do the thing"}}`,
39
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","sessionId":"eval","cwd":"/tmp","message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`,
40
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
41
+ )
42
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex")})
43
+ s.analyze.runner = stubJudge{output: stubJudgeOutput}
44
+ s.reportCache.Dir = t.TempDir()
45
+
46
+ // before any run: state none, judge reported available via the stub
47
+ getResp := httptest.NewRecorder()
48
+ s.handleSessionResource(getResp, httptest.NewRequest(http.MethodGet, "/api/sessions/eval/report", nil))
49
+ if getResp.Code != http.StatusOK {
50
+ t.Fatalf("report status = %d body=%s", getResp.Code, getResp.Body.String())
51
+ }
52
+ var status reportStatus
53
+ if err := json.Unmarshal(getResp.Body.Bytes(), &status); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ if status.State != "none" || !status.JudgeAvailable || status.JudgeCLI != "stub" {
57
+ t.Fatalf("status = %+v", status)
58
+ }
59
+
60
+ postResp := httptest.NewRecorder()
61
+ s.handleSessionResource(postResp, httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", nil))
62
+ if postResp.Code != http.StatusAccepted {
63
+ t.Fatalf("analyze status = %d body=%s", postResp.Code, postResp.Body.String())
64
+ }
65
+
66
+ deadline := time.Now().Add(5 * time.Second)
67
+ for {
68
+ resp := httptest.NewRecorder()
69
+ s.handleSessionResource(resp, httptest.NewRequest(http.MethodGet, "/api/sessions/eval/report", nil))
70
+ if err := json.Unmarshal(resp.Body.Bytes(), &status); err != nil {
71
+ t.Fatal(err)
72
+ }
73
+ if status.State == "done" || status.State == "failed" {
74
+ break
75
+ }
76
+ if time.Now().After(deadline) {
77
+ t.Fatalf("job never finished: %+v", status)
78
+ }
79
+ time.Sleep(10 * time.Millisecond)
80
+ }
81
+ if status.State != "done" || status.Stale || status.Report == nil {
82
+ t.Fatalf("status = %+v", status)
83
+ }
84
+ verdicts := map[string]string{}
85
+ for _, dim := range status.Report.Dimensions {
86
+ verdicts[dim.Name] = dim.Verdict
87
+ }
88
+ if verdicts["verification"] != model.VerdictWarning || verdicts["exploration"] != model.VerdictGood {
89
+ t.Fatalf("verdicts = %#v", verdicts)
90
+ }
91
+
92
+ // the report must survive a server restart via the disk cache
93
+ restarted := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex")})
94
+ restarted.analyze.runner = stubJudge{output: stubJudgeOutput}
95
+ restarted.reportCache.Dir = s.reportCache.Dir
96
+ resp := httptest.NewRecorder()
97
+ restarted.handleSessionResource(resp, httptest.NewRequest(http.MethodGet, "/api/sessions/eval/report", nil))
98
+ if err := json.Unmarshal(resp.Body.Bytes(), &status); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ if status.State != "done" || status.Report == nil {
102
+ t.Fatalf("restarted status = %+v", status)
103
+ }
104
+ }
105
+
106
+ func TestAnalyzeEndpointReportsJudgeFailure(t *testing.T) {
107
+ claudeDir := t.TempDir()
108
+ writeServerSession(t, filepath.Join(claudeDir, "fail.jsonl"),
109
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"fail","cwd":"/tmp","message":{"role":"user","content":"x"}}`,
110
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","sessionId":"fail","cwd":"/tmp","message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`,
111
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"fail","cwd":"/tmp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
112
+ )
113
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex")})
114
+ s.analyze.runner = stubJudge{output: "garbage, not json"}
115
+ s.reportCache.Dir = t.TempDir()
116
+
117
+ postResp := httptest.NewRecorder()
118
+ s.handleSessionResource(postResp, httptest.NewRequest(http.MethodPost, "/api/sessions/fail/analyze", nil))
119
+ if postResp.Code != http.StatusAccepted {
120
+ t.Fatalf("analyze status = %d", postResp.Code)
121
+ }
122
+ deadline := time.Now().Add(5 * time.Second)
123
+ var status reportStatus
124
+ for {
125
+ resp := httptest.NewRecorder()
126
+ s.handleSessionResource(resp, httptest.NewRequest(http.MethodGet, "/api/sessions/fail/report", nil))
127
+ if err := json.Unmarshal(resp.Body.Bytes(), &status); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if status.State == "failed" || status.State == "done" {
131
+ break
132
+ }
133
+ if time.Now().After(deadline) {
134
+ t.Fatalf("job never finished: %+v", status)
135
+ }
136
+ time.Sleep(10 * time.Millisecond)
137
+ }
138
+ if status.State != "failed" || status.Error == "" {
139
+ t.Fatalf("status = %+v", status)
140
+ }
141
+ }
142
+
143
+ // gateJudge blocks every call until released, so tests can observe the
144
+ // running state deterministically.
145
+ type gateJudge struct {
146
+ release chan struct{}
147
+ output string
148
+ }
149
+
150
+ func (g gateJudge) Run(ctx context.Context, prompt, input string) (judge.RunResult, error) {
151
+ select {
152
+ case <-g.release:
153
+ case <-ctx.Done():
154
+ return judge.RunResult{}, ctx.Err()
155
+ }
156
+ return judge.RunResult{Text: g.output, Model: "stub-model"}, nil
157
+ }
158
+
159
+ func (g gateJudge) Name() string { return "stub" }
160
+
161
+ func TestAnalyzeRubricFalseBypassesReportCache(t *testing.T) {
162
+ claudeDir := t.TempDir()
163
+ writeServerSession(t, filepath.Join(claudeDir, "eval.jsonl"),
164
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":"do the thing"}}`,
165
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","sessionId":"eval","cwd":"/tmp","message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`,
166
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
167
+ )
168
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex")})
169
+ s.analyze.runner = stubJudge{output: stubJudgeOutput}
170
+ s.reportCache.Dir = t.TempDir()
171
+
172
+ meta, err := s.findSession("eval")
173
+ if err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ // A richer, rubric-ful report already sits in the cache; a rubric:false
177
+ // run must not downgrade it.
178
+ rich := &model.Report{
179
+ Version: 1,
180
+ Session: model.ReportSession{ID: "eval", EventCount: 1},
181
+ Judge: model.ReportJudge{CLI: "stub", PromptVersion: judge.PromptVersion, RubricPromptVersion: judge.RubricPromptVersion},
182
+ Dimensions: []model.ReportDimension{{Name: "exploration", Verdict: model.VerdictGood, Findings: []model.ReportFinding{}}},
183
+ Rubric: &model.Rubric{Status: model.RubricStatusScored},
184
+ }
185
+ if err := s.reportCache.Store(meta.Key, rich); err != nil {
186
+ t.Fatal(err)
187
+ }
188
+
189
+ postResp := httptest.NewRecorder()
190
+ s.handleSessionResource(postResp, httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", strings.NewReader(`{"rubric":false}`)))
191
+ if postResp.Code != http.StatusAccepted {
192
+ t.Fatalf("analyze status = %d body=%s", postResp.Code, postResp.Body.String())
193
+ }
194
+ deadline := time.Now().Add(5 * time.Second)
195
+ var status reportStatus
196
+ for {
197
+ resp := httptest.NewRecorder()
198
+ s.handleSessionResource(resp, httptest.NewRequest(http.MethodGet, "/api/sessions/eval/report", nil))
199
+ if err := json.Unmarshal(resp.Body.Bytes(), &status); err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ if status.State == "done" || status.State == "failed" {
203
+ break
204
+ }
205
+ if time.Now().After(deadline) {
206
+ t.Fatalf("job never finished: %+v", status)
207
+ }
208
+ time.Sleep(10 * time.Millisecond)
209
+ }
210
+ // The requester gets their dimensions-only result…
211
+ if status.State != "done" || status.Report == nil || status.Report.Rubric != nil {
212
+ t.Fatalf("status = %+v", status)
213
+ }
214
+ // …and the richer cached report survives untouched on disk.
215
+ if cached := s.reportCache.Load(meta.Key); cached == nil || cached.Rubric == nil {
216
+ t.Fatalf("rubric:false overwrote the richer cache: %#v", cached)
217
+ }
218
+ }
219
+
220
+ func TestAnalyzeConflictingConfigRejected(t *testing.T) {
221
+ claudeDir := t.TempDir()
222
+ writeServerSession(t, filepath.Join(claudeDir, "eval.jsonl"),
223
+ `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":"do the thing"}}`,
224
+ `{"type":"assistant","timestamp":"2026-07-09T00:00:01Z","sessionId":"eval","cwd":"/tmp","message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`,
225
+ `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
226
+ )
227
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex")})
228
+ gate := gateJudge{release: make(chan struct{}), output: stubJudgeOutput}
229
+ s.analyze.runner = gate
230
+ s.reportCache.Dir = t.TempDir()
231
+
232
+ first := httptest.NewRecorder()
233
+ s.handleSessionResource(first, httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", nil))
234
+ if first.Code != http.StatusAccepted {
235
+ t.Fatalf("first analyze = %d", first.Code)
236
+ }
237
+ // Same session, different configuration: the API must refuse rather than
238
+ // pretend the in-flight run covers it.
239
+ conflict := httptest.NewRecorder()
240
+ s.handleSessionResource(conflict, httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", strings.NewReader(`{"rubric":false}`)))
241
+ if conflict.Code != http.StatusConflict {
242
+ t.Fatalf("conflicting config = %d, want 409", conflict.Code)
243
+ }
244
+ // Same configuration deduplicates as before.
245
+ same := httptest.NewRecorder()
246
+ s.handleSessionResource(same, httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", nil))
247
+ if same.Code != http.StatusAccepted {
248
+ t.Fatalf("same config = %d, want 202", same.Code)
249
+ }
250
+ close(gate.release)
251
+ deadline := time.Now().Add(5 * time.Second)
252
+ for {
253
+ resp := httptest.NewRecorder()
254
+ var status reportStatus
255
+ s.handleSessionResource(resp, httptest.NewRequest(http.MethodGet, "/api/sessions/eval/report", nil))
256
+ if err := json.Unmarshal(resp.Body.Bytes(), &status); err != nil {
257
+ t.Fatal(err)
258
+ }
259
+ if status.State == "done" || status.State == "failed" {
260
+ break
261
+ }
262
+ if time.Now().After(deadline) {
263
+ t.Fatal("job never finished after release")
264
+ }
265
+ time.Sleep(10 * time.Millisecond)
266
+ }
267
+ }
268
+
269
+ func TestAnalyzeEndpointRejectsBadBodies(t *testing.T) {
270
+ claudeDir := t.TempDir()
271
+ writeServerSession(t, filepath.Join(claudeDir, "eval.jsonl"),
272
+ `{"type":"user","timestamp":"2026-07-14T00:00:00Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":"do the thing"}}`,
273
+ `{"type":"assistant","timestamp":"2026-07-14T00:00:01Z","sessionId":"eval","cwd":"/tmp","message":{"role":"assistant","content":[{"type":"tool_use","id":"r1","name":"Read","input":{"file_path":"/tmp/a.go"}}]}}`,
274
+ `{"type":"user","timestamp":"2026-07-14T00:00:02Z","sessionId":"eval","cwd":"/tmp","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
275
+ )
276
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex")})
277
+ s.analyze.runner = stubJudge{output: stubJudgeOutput}
278
+ s.reportCache.Dir = t.TempDir()
279
+
280
+ // This POST starts an expensive run: garbled JSON and trailing garbage
281
+ // must both be rejected, not silently run with defaults.
282
+ for _, body := range []string{`{bad`, `{"cli":"stub"} trailing`} {
283
+ resp := httptest.NewRecorder()
284
+ req := httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", strings.NewReader(body))
285
+ s.handleSessionResource(resp, req)
286
+ if resp.Code != http.StatusBadRequest {
287
+ t.Fatalf("body %q -> %d, want 400", body, resp.Code)
288
+ }
289
+ }
290
+
291
+ resp := httptest.NewRecorder()
292
+ req := httptest.NewRequest(http.MethodPost, "/api/sessions/eval/analyze", strings.NewReader(`{"cli":"gemini"}`))
293
+ s.handleSessionResource(resp, req)
294
+ if resp.Code != http.StatusBadRequest {
295
+ t.Fatalf("unknown cli -> %d, want 400", resp.Code)
296
+ }
297
+ }
@@ -0,0 +1,40 @@
1
+ package server
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ // A Codex thread rename only touches session_index.jsonl, never the session
10
+ // file. The summary cache must treat the index as a summary input, or the
11
+ // rename stays invisible for the life of the serve process.
12
+ func TestFreshScanPicksUpCodexTitleIndexChange(t *testing.T) {
13
+ base := t.TempDir()
14
+ codexDir := filepath.Join(base, "sessions")
15
+ if err := os.MkdirAll(codexDir, 0o755); err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ writeServerJSONL(t, filepath.Join(codexDir, "s1.jsonl"), map[string]any{
19
+ "timestamp": "2026-07-09T00:00:00Z",
20
+ "type": "session_meta",
21
+ "payload": map[string]any{"id": "codex-title", "cwd": "/tmp"},
22
+ })
23
+ writeIndex := func(title string) {
24
+ writeServerJSONL(t, filepath.Join(base, "session_index.jsonl"),
25
+ map[string]any{"id": "codex-title", "thread_name": title})
26
+ }
27
+ writeIndex("Title One")
28
+
29
+ s := New(Config{ClaudeDir: filepath.Join(t.TempDir(), "claude"), CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
30
+ initial := requestSessions(t, s, "/api/sessions")
31
+ if len(initial) != 1 || initial[0].Title != "Title One" {
32
+ t.Fatalf("initial sessions = %#v", initial)
33
+ }
34
+
35
+ writeIndex("Title Two Updated")
36
+ fresh := requestSessions(t, s, "/api/sessions?fresh=1")
37
+ if len(fresh) != 1 || fresh[0].Title != "Title Two Updated" {
38
+ t.Fatalf("index change not picked up: %#v", fresh)
39
+ }
40
+ }
@@ -0,0 +1,147 @@
1
+ package server
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
12
+ )
13
+
14
+ func TestHandlerRejectsNonLocalHost(t *testing.T) {
15
+ s := New(Config{})
16
+ handler := s.handler()
17
+
18
+ // A stray bracket must not normalize into an allowed host.
19
+ for _, host := range []string{"evil.example:8765", "localhost]", "localhost]:8765", "[localhost:8765"} {
20
+ req := httptest.NewRequest(http.MethodGet, "/api/repomap", nil)
21
+ req.Host = host
22
+ resp := httptest.NewRecorder()
23
+ handler.ServeHTTP(resp, req)
24
+ if resp.Code != http.StatusForbidden {
25
+ t.Fatalf("Host %q status = %d, want 403", host, resp.Code)
26
+ }
27
+ }
28
+
29
+ // Host names are case-insensitive.
30
+ for _, host := range []string{"127.0.0.1:8765", "localhost:8765", "LOCALHOST:8765", "[::1]:8765"} {
31
+ req := httptest.NewRequest(http.MethodGet, "/api/repomap", nil)
32
+ req.Host = host
33
+ resp := httptest.NewRecorder()
34
+ handler.ServeHTTP(resp, req)
35
+ if resp.Code == http.StatusForbidden {
36
+ t.Fatalf("local Host %q rejected", host)
37
+ }
38
+ }
39
+ }
40
+
41
+ func TestAnalyzeRejectsCrossSiteRequests(t *testing.T) {
42
+ s := New(Config{})
43
+ handler := s.handler()
44
+ post := func(origin, fetchSite string) int {
45
+ req := httptest.NewRequest(http.MethodPost, "/api/sessions/nope/analyze", nil)
46
+ req.Host = "127.0.0.1:8765"
47
+ if origin != "" {
48
+ req.Header.Set("Origin", origin)
49
+ }
50
+ if fetchSite != "" {
51
+ req.Header.Set("Sec-Fetch-Site", fetchSite)
52
+ }
53
+ resp := httptest.NewRecorder()
54
+ handler.ServeHTTP(resp, req)
55
+ return resp.Code
56
+ }
57
+
58
+ rejected := []struct{ origin, fetchSite string }{
59
+ {"https://evil.example", ""},
60
+ {"null", ""},
61
+ {"", "cross-site"},
62
+ {"", "same-site"},
63
+ {"http://127.0.0.1:8765", "cross-site"},
64
+ // Loopback is not same-origin: another local server's page — a
65
+ // different host name, port, or scheme — must not pass.
66
+ {"http://localhost:9999", ""},
67
+ {"http://127.0.0.1:9999", ""},
68
+ {"http://localhost:8765", ""},
69
+ {"https://127.0.0.1:8765", ""},
70
+ }
71
+ for _, c := range rejected {
72
+ if code := post(c.origin, c.fetchSite); code != http.StatusForbidden {
73
+ t.Fatalf("origin=%q fetchSite=%q status = %d, want 403", c.origin, c.fetchSite, code)
74
+ }
75
+ }
76
+
77
+ // Same-origin browser requests and non-browser clients (no Origin) must
78
+ // pass the middleware; 404 here means the handler ran and found no session.
79
+ allowed := []struct{ origin, fetchSite string }{
80
+ {"", ""},
81
+ {"http://127.0.0.1:8765", ""},
82
+ {"http://127.0.0.1:8765", "same-origin"},
83
+ {"", "none"},
84
+ }
85
+ for _, c := range allowed {
86
+ if code := post(c.origin, c.fetchSite); code == http.StatusForbidden {
87
+ t.Fatalf("origin=%q fetchSite=%q rejected by middleware", c.origin, c.fetchSite)
88
+ }
89
+ }
90
+ }
91
+
92
+ // Cross-site GETs stay readable-by-middleware (CORS already blocks the
93
+ // response); only non-GET methods carry the Origin gate.
94
+ func TestCrossSiteGetPassesMiddleware(t *testing.T) {
95
+ s := New(Config{})
96
+ req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
97
+ req.Host = "127.0.0.1:8765"
98
+ req.Header.Set("Origin", "https://evil.example")
99
+ req.Header.Set("Sec-Fetch-Site", "cross-site")
100
+ resp := httptest.NewRecorder()
101
+ s.handler().ServeHTTP(resp, req)
102
+ if resp.Code == http.StatusForbidden {
103
+ t.Fatal("cross-site GET rejected; reads are CORS-protected, not middleware-gated")
104
+ }
105
+ }
106
+
107
+ func TestRepoMapUsesInjectedBuilder(t *testing.T) {
108
+ repoRoot := t.TempDir()
109
+ if err := os.WriteFile(filepath.Join(repoRoot, "a.go"), []byte("package demo\n"), 0o644); err != nil {
110
+ t.Fatal(err)
111
+ }
112
+ s := New(Config{})
113
+ calls := 0
114
+ s.buildCityMap = func(repo string, trace *model.Trace) (*model.CityMap, error) {
115
+ calls++
116
+ return emptyCityMap(repo), nil
117
+ }
118
+ if _, err := s.repoCityMap(repoRoot); err != nil {
119
+ t.Fatal(err)
120
+ }
121
+ if calls != 1 {
122
+ t.Fatalf("injected builder calls = %d, want 1", calls)
123
+ }
124
+ }
125
+
126
+ func TestAgentGraphCacheIsBounded(t *testing.T) {
127
+ s := New(Config{})
128
+ s.mu.Lock()
129
+ base := time.Now()
130
+ for i := 0; i < agentGraphMaxEntries+5; i++ {
131
+ s.agentGraphs[string(rune('a'+i))] = agentGraphCacheEntry{
132
+ graph: &model.AgentGraph{},
133
+ used: base.Add(time.Duration(i) * time.Second),
134
+ }
135
+ }
136
+ s.evictAgentGraphsLocked()
137
+ n := len(s.agentGraphs)
138
+ _, newestKept := s.agentGraphs[string(rune('a'+agentGraphMaxEntries+4))]
139
+ _, oldestKept := s.agentGraphs["a"]
140
+ s.mu.Unlock()
141
+ if n != agentGraphMaxEntries {
142
+ t.Fatalf("agent graph cache size = %d, want %d", n, agentGraphMaxEntries)
143
+ }
144
+ if !newestKept || oldestKept {
145
+ t.Fatalf("eviction order wrong: newestKept=%v oldestKept=%v", newestKept, oldestKept)
146
+ }
147
+ }
@@ -0,0 +1,91 @@
1
+ package server
2
+
3
+ import (
4
+ "os"
5
+ "strings"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/hybridlabor-api/bdb-synapse/internal/judge"
10
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
11
+ )
12
+
13
+ // reportIndexTTL bounds how stale the directory listing may get. The reports
14
+ // directory is also written by the CLI, so the listing cannot be trusted for
15
+ // long; 5s matches the session-list polling cadence.
16
+ const reportIndexTTL = 5 * time.Second
17
+
18
+ // reportIndex keeps the session list's report lookups off the disk hot path.
19
+ // Most sessions have no report: one ReadDir per TTL answers "which sessions
20
+ // have a report at all" instead of a stat per session per request, and
21
+ // per-key loads are memoized against the report file's fingerprint so a
22
+ // report is re-read only when it changed. Same-process writers must call
23
+ // markPresent after storing a report — the TTL only covers writers this
24
+ // server cannot see.
25
+ type reportIndex struct {
26
+ mu sync.Mutex
27
+ scanned time.Time
28
+ present map[string]bool
29
+ loaded map[string]reportMemo
30
+ }
31
+
32
+ type reportMemo struct {
33
+ fingerprint fileFingerprint
34
+ report *model.Report
35
+ }
36
+
37
+ // load returns the cached report for key, or nil when none exists. The cache
38
+ // is passed per call so a Dir override after construction is observed.
39
+ func (ri *reportIndex) load(cache judge.Cache, key string) *model.Report {
40
+ if cache.Dir == "" || key == "" {
41
+ return nil
42
+ }
43
+ ri.mu.Lock()
44
+ defer ri.mu.Unlock()
45
+ if time.Since(ri.scanned) >= reportIndexTTL {
46
+ ri.present = map[string]bool{}
47
+ if entries, err := os.ReadDir(cache.Dir); err == nil {
48
+ for _, entry := range entries {
49
+ if name, ok := strings.CutSuffix(entry.Name(), ".json"); ok {
50
+ ri.present[name] = true
51
+ }
52
+ }
53
+ }
54
+ for key := range ri.loaded {
55
+ if !ri.present[key] {
56
+ delete(ri.loaded, key)
57
+ }
58
+ }
59
+ ri.scanned = time.Now()
60
+ }
61
+ if !ri.present[key] {
62
+ return nil
63
+ }
64
+ fingerprint, err := fingerprintFile(cache.Path(key))
65
+ if err != nil {
66
+ delete(ri.loaded, key)
67
+ return nil
68
+ }
69
+ if memo, ok := ri.loaded[key]; ok && memo.fingerprint.equal(fingerprint) {
70
+ return memo.report
71
+ }
72
+ report := cache.Load(key)
73
+ if ri.loaded == nil {
74
+ ri.loaded = map[string]reportMemo{}
75
+ }
76
+ ri.loaded[key] = reportMemo{fingerprint: fingerprint, report: report}
77
+ return report
78
+ }
79
+
80
+ // markPresent records that a report for key now exists on disk, so a load
81
+ // within the listing TTL finds it. Without this, a poll landing right after
82
+ // the server persisted a report — and after the job entry was dropped —
83
+ // would read "no report" until the next directory scan.
84
+ func (ri *reportIndex) markPresent(key string) {
85
+ ri.mu.Lock()
86
+ defer ri.mu.Unlock()
87
+ if ri.present == nil {
88
+ ri.present = map[string]bool{}
89
+ }
90
+ ri.present[key] = true
91
+ }