返回 DeepSeek-Reasonix
store_test.go
根目录 / internal / autoresearch / store_test.go
1 package autoresearch
2
3 import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "testing"
8 "time"
9 )
10
11 func TestLoadTaskReadsHostOwnedLayout(t *testing.T) {
12 root := t.TempDir()
13 taskID := "20260630-100000-ui-lag"
14 taskRoot := writeArchiveFixture(t, root, taskID, "Find the root cause of UI lag", []SuccessCriterion{
15 {ID: "objective_evidence", Description: "Direct evidence", Required: true},
16 })
17 store := NewStore(root)
18 task, err := store.LoadTask(taskID)
19 if err != nil {
20 t.Fatalf("LoadTask: %v", err)
21 }
22 if task.ID != taskID {
23 t.Fatalf("task id = %q", task.ID)
24 }
25 if task.Root != taskRoot {
26 t.Fatalf("task root = %q, want %q", task.Root, taskRoot)
27 }
28 if task.Spec.Goal != "Find the root cause of UI lag" {
29 t.Fatalf("goal = %q", task.Spec.Goal)
30 }
31 report, err := store.ValidateTask(taskID)
32 if err != nil {
33 t.Fatalf("ValidateTask: %v", err)
34 }
35 if !report.Valid {
36 t.Fatalf("validation errors: %+v", report.Errors)
37 }
38 }
39
40 func TestLoadTaskRejectsSymlinkAndUnsafeIDs(t *testing.T) {
41 root := t.TempDir()
42 if resolved, err := filepath.EvalSymlinks(root); err == nil {
43 root = resolved
44 }
45 outside := t.TempDir()
46 if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch"), 0o755); err != nil {
47 t.Fatalf("create autoresearch root: %v", err)
48 }
49 taskID := "symlink-task"
50 if err := os.Symlink(outside, filepath.Join(root, ".reasonix", "autoresearch", taskID)); err != nil {
51 t.Fatalf("symlink: %v", err)
52 }
53 store := NewStore(root)
54 if _, err := store.LoadTask(taskID); err == nil {
55 t.Fatal("LoadTask accepted symlink task")
56 }
57 if _, err := store.LoadTask("../escape"); err == nil {
58 t.Fatal("LoadTask accepted path traversal id")
59 }
60 if _, err := store.LoadTask("has/slash"); err == nil {
61 t.Fatal("LoadTask accepted slash id")
62 }
63 }
64
65 func TestLoadTaskRejectsSymlinkedArchiveRoot(t *testing.T) {
66 root := t.TempDir()
67 if resolved, err := filepath.EvalSymlinks(root); err == nil {
68 root = resolved
69 }
70 outside := t.TempDir()
71 if resolved, err := filepath.EvalSymlinks(outside); err == nil {
72 outside = resolved
73 }
74 const taskID = "outside-task"
75 writeArchiveFixture(t, outside, taskID, "outside workspace goal", nil)
76 if err := os.MkdirAll(filepath.Join(root, ".reasonix"), 0o755); err != nil {
77 t.Fatal(err)
78 }
79 outsideRoot := filepath.Join(outside, ".reasonix", "autoresearch")
80 if err := os.Symlink(outsideRoot, filepath.Join(root, ".reasonix", "autoresearch")); err != nil {
81 t.Fatal(err)
82 }
83
84 store := NewStore(root)
85 if _, err := store.LoadTask(taskID); err == nil {
86 t.Fatal("LoadTask accepted a symlinked archive root outside the workspace")
87 }
88 if _, err := store.ListSummaries(); err == nil {
89 t.Fatal("ListSummaries accepted a symlinked archive root outside the workspace")
90 }
91 }
92
93 func TestArchiveReaderRejectsSymlinkedTaskContent(t *testing.T) {
94 t.Run("state directory", func(t *testing.T) {
95 root := t.TempDir()
96 writeArchiveFixture(t, root, "source-task", "source goal", nil)
97 victimRoot := writeArchiveFixture(t, root, "victim-task", "victim goal", nil)
98 if err := os.RemoveAll(filepath.Join(victimRoot, "state")); err != nil {
99 t.Fatal(err)
100 }
101 if err := os.Symlink(filepath.Join("..", "source-task", "state"), filepath.Join(victimRoot, "state")); err != nil {
102 t.Fatal(err)
103 }
104 if _, err := NewStore(root).LoadTask("victim-task"); err == nil {
105 t.Fatal("LoadTask followed a state-directory symlink into another task")
106 }
107 })
108
109 t.Run("task spec file", func(t *testing.T) {
110 root := t.TempDir()
111 taskRoot := writeArchiveFixture(t, root, "file-link-task", "linked goal", nil)
112 specPath := filepath.Join(taskRoot, "state", "task_spec.json")
113 if err := os.Rename(specPath, filepath.Join(taskRoot, "state", "task_spec.real.json")); err != nil {
114 t.Fatal(err)
115 }
116 if err := os.Symlink("task_spec.real.json", specPath); err != nil {
117 t.Fatal(err)
118 }
119 if _, err := NewStore(root).LoadTask("file-link-task"); err == nil {
120 t.Fatal("LoadTask followed a task_spec symlink")
121 }
122 })
123
124 t.Run("validation file", func(t *testing.T) {
125 root := t.TempDir()
126 taskRoot := writeArchiveFixture(t, root, "progress-link-task", "linked progress", nil)
127 progressPath := filepath.Join(taskRoot, "state", "progress.json")
128 if err := os.Rename(progressPath, filepath.Join(taskRoot, "state", "progress.real.json")); err != nil {
129 t.Fatal(err)
130 }
131 if err := os.Symlink("progress.real.json", progressPath); err != nil {
132 t.Fatal(err)
133 }
134 report, err := NewStore(root).ValidateTask("progress-link-task")
135 if err != nil {
136 t.Fatalf("ValidateTask: %v", err)
137 }
138 if report.Valid {
139 t.Fatal("ValidateTask accepted a symlinked progress file")
140 }
141 })
142 }
143
144 func TestLoadTaskRejectsUnreadableArchiveFile(t *testing.T) {
145 if os.Geteuid() == 0 {
146 t.Skip("root can bypass archive file permissions")
147 }
148 if runtime.GOOS == "windows" {
149 t.Skip("Windows has no Unix permission bits; chmod cannot make a file unreadable")
150 }
151 root := t.TempDir()
152 taskRoot := writeArchiveFixture(t, root, "permission-task", "permission goal", nil)
153 specPath := filepath.Join(taskRoot, "state", "task_spec.json")
154 if err := os.Chmod(specPath, 0); err != nil {
155 t.Fatal(err)
156 }
157 t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) })
158 if _, err := NewStore(root).LoadTask("permission-task"); err == nil {
159 t.Fatal("LoadTask accepted an unreadable task_spec.json")
160 }
161 }
162
163 func TestFindingsPreserveVerificationAndUnknownKinds(t *testing.T) {
164 root := t.TempDir()
165 taskID := "findings-kinds"
166 taskRoot := writeArchiveFixture(t, root, taskID, "Read evidence kinds", []SuccessCriterion{
167 {ID: "verified", Description: "Verified", Required: true, EvidenceIDs: []string{"f-verify"}},
168 })
169 appendFindingLine(t, taskRoot, Finding{
170 ID: "f-verify",
171 Kind: "verification",
172 Summary: "targeted test passed",
173 Source: FindingSourceCommand,
174 Command: "go test ./internal/autoresearch",
175 Accepted: true,
176 CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC),
177 })
178 appendFindingLine(t, taskRoot, Finding{
179 ID: "f-future",
180 Kind: "future_kind",
181 Summary: "preserve unknown evidence",
182 Source: FindingSourceManual,
183 Accepted: true,
184 CreatedAt: time.Date(2026, 6, 30, 10, 1, 0, 0, time.UTC),
185 })
186 store := NewStore(root)
187 findings, err := store.Findings(taskID, 0)
188 if err != nil {
189 t.Fatalf("Findings: %v", err)
190 }
191 if len(findings) != 2 {
192 t.Fatalf("findings = %+v", findings)
193 }
194 if findings[0].Kind != "future_kind" || findings[1].Kind != "verification" {
195 t.Fatalf("kinds = %+v, want newest-first future then verification", findings)
196 }
197 if err := validateFinding(findings[0]); err != nil {
198 t.Fatalf("validateFinding unknown kind: %v", err)
199 }
200 if err := validateFinding(Finding{ID: "", Kind: "anything", Summary: "x", CreatedAt: time.Now()}); err == nil {
201 t.Fatal("validateFinding accepted empty id")
202 }
203 summary, err := store.Summary(taskID)
204 if err != nil {
205 t.Fatalf("Summary: %v", err)
206 }
207 if len(summary.OpenCriteria) != 0 {
208 t.Fatalf("summary = %+v, want verification evidence to satisfy the legacy criterion", summary)
209 }
210 }
211
212 func TestValidateFindingDoesNotEnumerateKind(t *testing.T) {
213 now := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC)
214 if err := validateFinding(Finding{ID: "f1", Kind: "totally-unknown", Summary: "ok", CreatedAt: now}); err != nil {
215 t.Fatalf("unknown kind rejected: %v", err)
216 }
217 if err := validateFinding(Finding{ID: "f1", Kind: "verification", Summary: "ok", CreatedAt: now}); err != nil {
218 t.Fatalf("verification rejected: %v", err)
219 }
220 if err := validateFinding(Finding{ID: "f1", Kind: "", Summary: "ok", CreatedAt: now}); err != nil {
221 t.Fatalf("empty kind should still pass base validation: %v", err)
222 }
223 }
224
225 func TestSummaryReportsMissingCriteria(t *testing.T) {
226 root := t.TempDir()
227 taskID := "missing-criteria"
228 writeArchiveFixture(t, root, taskID, "Block incomplete completion", []SuccessCriterion{
229 {ID: "objective_evidence", Description: "Direct evidence", Required: true},
230 {ID: "verification", Description: "Verification", Required: true},
231 })
232 store := NewStore(root)
233 summary, err := store.Summary(taskID)
234 if err != nil {
235 t.Fatalf("Summary: %v", err)
236 }
237 if len(summary.OpenCriteria) != 2 {
238 t.Fatalf("summary = %+v, want missing both criteria", summary)
239 }
240 }
241
242 func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) {
243 root := t.TempDir()
244 taskID := "20260630-resume-path"
245 writeArchiveFixture(t, root, taskID, "Resume explicit path", nil)
246 store := NewStore(root)
247 resumed, ok, err := store.ResumeFromGoalText("继续 .reasonix/autoresearch/" + taskID + "/ 这个任务")
248 if err != nil || !ok {
249 t.Fatalf("ResumeFromGoalText: ok=%v err=%v", ok, err)
250 }
251 if resumed.ID != taskID || resumed.Spec.Goal != "Resume explicit path" {
252 t.Fatalf("resumed = %+v", resumed)
253 }
254 if _, ok, err := store.ResumeFromGoalText("ordinary goal text"); err != nil || ok {
255 t.Fatalf("ordinary text matched archive: ok=%v err=%v", ok, err)
256 }
257 if _, ok, err := store.ResumeFromGoalText("resume .reasonix/autoresearch/missing-task/"); !ok || err == nil {
258 t.Fatalf("missing task should fail closed: ok=%v err=%v", ok, err)
259 }
260 for _, input := range []string{
261 "resume .reasonix/autoresearch/../escape",
262 "resume .reasonix/autoresearch/" + taskID + "/../../escape",
263 "resume .reasonix/autoresearch/" + taskID + "/extra",
264 "resume .reasonix/autoresearch/" + taskID + `\extra`,
265 "resume .reasonix/autoresearch/",
266 } {
267 if _, ok, err := store.ResumeFromGoalText(input); !ok || err == nil {
268 t.Errorf("unsafe explicit path %q did not fail closed: ok=%v err=%v", input, ok, err)
269 }
270 }
271 }
272
273 func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
274 root := t.TempDir()
275 firstID := "20260630-first"
276 secondID := "20260630-second"
277 firstRoot := writeArchiveFixture(t, root, firstID, "First research task", nil)
278 writeArchiveFixture(t, root, secondID, "Second research task", nil)
279 writeProgress(t, firstRoot, Progress{
280 Status: StatusRunning,
281 Iteration: 3,
282 CurrentDirection: "inspect logs",
283 StaleCount: 2,
284 PivotCount: 1,
285 UpdatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC),
286 })
287 appendHeartbeatLine(t, firstRoot, Heartbeat{
288 Status: HeartbeatTurnDone,
289 Iteration: 3,
290 CreatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC),
291 })
292 before := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch"))
293 beforeModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch"))
294 store := NewStore(root)
295 list, err := store.ListSummaries()
296 if err != nil {
297 t.Fatalf("ListSummaries: %v", err)
298 }
299 if len(list) != 2 {
300 t.Fatalf("list = %+v", list)
301 }
302 summary, err := store.Summary(firstID)
303 if err != nil {
304 t.Fatalf("Summary: %v", err)
305 }
306 if summary.Iteration != 3 || !summary.PivotRequired || summary.NextRequiredAction == "" {
307 t.Fatalf("summary = %+v", summary)
308 }
309 after := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch"))
310 if len(before) != len(after) {
311 t.Fatalf("archive file count changed: before=%d after=%d", len(before), len(after))
312 }
313 for path, content := range before {
314 if after[path] != content {
315 t.Fatalf("archive mutated at %s", path)
316 }
317 }
318 afterModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch"))
319 for path, modTime := range beforeModTimes {
320 if !afterModTimes[path].Equal(modTime) {
321 t.Fatalf("archive modification time changed at %s", path)
322 }
323 }
324 }
325
326 func TestValidateTaskRejectsCorruptJSON(t *testing.T) {
327 root := t.TempDir()
328 taskID := "corrupt-json"
329 taskRoot := writeArchiveFixture(t, root, taskID, "Validate schema errors", nil)
330 if err := os.WriteFile(filepath.Join(taskRoot, "state", "progress.json"), []byte("{not-json"), 0o644); err != nil {
331 t.Fatal(err)
332 }
333 store := NewStore(root)
334 report, err := store.ValidateTask(taskID)
335 if err != nil {
336 t.Fatalf("ValidateTask: %v", err)
337 }
338 if report.Valid {
339 t.Fatal("corrupt progress reported valid")
340 }
341 }
342
343 func TestValidateTaskRejectsCorruptArchiveLogsButAcceptsUnknownFindingKinds(t *testing.T) {
344 t.Run("corrupt finding JSON", func(t *testing.T) {
345 root := t.TempDir()
346 taskID := "corrupt-finding-json"
347 taskRoot := writeArchiveFixture(t, root, taskID, "Validate finding JSON", nil)
348 if err := os.WriteFile(filepath.Join(taskRoot, "state", "findings.jsonl"), []byte("{not-json\n"), 0o644); err != nil {
349 t.Fatal(err)
350 }
351 report, err := NewStore(root).ValidateTask(taskID)
352 if err != nil {
353 t.Fatal(err)
354 }
355 if report.Valid {
356 t.Fatal("corrupt finding JSON reported valid")
357 }
358 })
359
360 t.Run("unknown finding kind", func(t *testing.T) {
361 root := t.TempDir()
362 taskID := "unknown-finding-kind"
363 taskRoot := writeArchiveFixture(t, root, taskID, "Accept future finding kind", nil)
364 appendFindingLine(t, taskRoot, Finding{
365 ID: "future", Kind: "future-kind", Summary: "preserve me", Accepted: true,
366 CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC),
367 })
368 report, err := NewStore(root).ValidateTask(taskID)
369 if err != nil {
370 t.Fatal(err)
371 }
372 if !report.Valid {
373 t.Fatalf("unknown finding kind rejected: %+v", report.Errors)
374 }
375 })
376 }
377
378 func TestHeartbeatsTailRead(t *testing.T) {
379 root := t.TempDir()
380 taskID := "heartbeats"
381 taskRoot := writeArchiveFixture(t, root, taskID, "Record heartbeats", nil)
382 for i := 1; i <= 5; i++ {
383 appendHeartbeatLine(t, taskRoot, Heartbeat{
384 Status: HeartbeatTurnDone,
385 Iteration: i,
386 CreatedAt: time.Date(2026, 6, 30, 10, i, 0, 0, time.UTC),
387 })
388 }
389 store := NewStore(root)
390 heartbeats, err := store.Heartbeats(taskID, 2)
391 if err != nil {
392 t.Fatalf("Heartbeats: %v", err)
393 }
394 if len(heartbeats) != 2 || heartbeats[0].Iteration != 4 || heartbeats[1].Iteration != 5 {
395 t.Fatalf("heartbeats = %+v", heartbeats)
396 }
397 }
398
398 lines GO