@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,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
|
+
}
|