返回 DeepSeek-Reasonix
store.go
1 // Package autoresearch is a read-only compatibility reader for historical
2 // `.reasonix/autoresearch/<task-id>/` archives. New Goal runs never create or
3 // mutate these directories.
4 package autoresearch
5
6 import (
7 "bufio"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "path/filepath"
14 "regexp"
15 "sort"
16 "strings"
17 "unicode"
18
19 fileencoding "reasonix/internal/fileutil/encoding"
20 )
21
22 var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
23
24 const explicitTaskPathPrefix = ".reasonix/autoresearch/"
25
26 // Store is a fail-closed reader over a workspace's legacy AutoResearch root.
27 type Store struct {
28 workspaceRoot string
29 root string
30 }
31
32 func NewStore(workspaceRoot string) *Store {
33 if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil {
34 workspaceRoot = resolved
35 }
36 return &Store{
37 workspaceRoot: workspaceRoot,
38 root: filepath.Join(workspaceRoot, ".reasonix", "autoresearch"),
39 }
40 }
41
42 // Root returns the absolute archive root under the workspace.
43 func (s *Store) Root() string {
44 return s.root
45 }
46
47 func (s *Store) ListSummaries() ([]Summary, error) {
48 storeRoot, err := s.openArchiveRoot()
49 if err != nil {
50 if os.IsNotExist(err) {
51 return []Summary{}, nil
52 }
53 return nil, fmt.Errorf("autoresearch: list tasks: %w", err)
54 }
55 defer storeRoot.Close()
56 dir, err := storeRoot.Open(".")
57 if err != nil {
58 return nil, fmt.Errorf("autoresearch: open task list: %w", err)
59 }
60 entries, err := dir.ReadDir(-1)
61 closeErr := dir.Close()
62 if err != nil {
63 return nil, fmt.Errorf("autoresearch: read task list: %w", err)
64 }
65 if closeErr != nil {
66 return nil, fmt.Errorf("autoresearch: close task list: %w", closeErr)
67 }
68 ids := make([]string, 0, len(entries))
69 for _, entry := range entries {
70 if !entry.IsDir() {
71 continue
72 }
73 id := entry.Name()
74 if validateTaskID(id) != nil {
75 continue
76 }
77 ids = append(ids, id)
78 }
79 sort.Sort(sort.Reverse(sort.StringSlice(ids)))
80 out := make([]Summary, 0, len(ids))
81 for _, id := range ids {
82 summary, err := s.Summary(id)
83 if err != nil {
84 return nil, err
85 }
86 out = append(out, *summary)
87 }
88 return out, nil
89 }
90
91 func (s *Store) LoadTask(taskID string) (*Task, error) {
92 storeRoot, taskRel, err := s.openTaskRoot(taskID)
93 if err != nil {
94 return nil, err
95 }
96 defer storeRoot.Close()
97 spec, report := validateTaskRoot(storeRoot, taskRel, taskID)
98 if !report.Valid {
99 return nil, fmt.Errorf("autoresearch: task %s is invalid: %v", taskID, report.Errors)
100 }
101 return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil
102 }
103
104 // ResumeFromGoalText loads an archive only when goal text names an explicit
105 // `.reasonix/autoresearch/<task-id>/` path. ok is true when a path was found;
106 // err is non-nil when that path is missing, corrupt, a symlink, or invalid.
107 func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) {
108 taskID, found, err := ExplicitTaskID(goal)
109 if !found || err != nil {
110 return nil, found, err
111 }
112 task, err := s.LoadTask(taskID)
113 if err != nil {
114 return nil, true, err
115 }
116 return task, true, nil
117 }
118
119 // ExplicitTaskID extracts one complete legacy archive path token from goal
120 // text. Once the prefix is present, malformed IDs and additional path
121 // components are errors rather than ordinary goal text.
122 func ExplicitTaskID(goal string) (string, bool, error) {
123 _, tail, found := strings.Cut(goal, explicitTaskPathPrefix)
124 if !found {
125 return "", false, nil
126 }
127 if end := strings.IndexFunc(tail, unicode.IsSpace); end >= 0 {
128 tail = tail[:end]
129 }
130 taskID := strings.TrimSuffix(tail, "/")
131 if taskID == "" {
132 return "", true, errors.New("autoresearch: explicit task path is missing a task id")
133 }
134 if strings.ContainsAny(taskID, `/\`) {
135 return "", true, fmt.Errorf("autoresearch: explicit task path has extra components: %q", tail)
136 }
137 if err := validateTaskID(taskID); err != nil {
138 return "", true, err
139 }
140 return taskID, true, nil
141 }
142
143 func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
144 storeRoot, taskRel, err := s.openTaskRoot(taskID)
145 if err != nil {
146 return nil, err
147 }
148 defer storeRoot.Close()
149 path := filepath.Join(taskRel, "state", "findings.jsonl")
150 // Bounded requests (the newest-N views) read only the file tail; limit 0
151 // keeps the full scan because accepted-evidence lookups need every entry.
152 lines, err := tailJSONLLines(storeRoot, path, limit)
153 if err != nil {
154 return nil, err
155 }
156 var findings []Finding
157 for _, line := range lines {
158 var f Finding
159 if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &f); err != nil {
160 return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
161 }
162 // Kind is fully opaque: unknown historical values pass through.
163 findings = append(findings, f)
164 }
165 for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 {
166 findings[i], findings[j] = findings[j], findings[i]
167 }
168 if limit > 0 && len(findings) > limit {
169 findings = findings[:limit]
170 }
171 return findings, nil
172 }
173
174 func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) {
175 storeRoot, taskRel, err := s.openTaskRoot(taskID)
176 if err != nil {
177 return nil, err
178 }
179 defer storeRoot.Close()
180 path := filepath.Join(taskRel, "logs", "heartbeat.jsonl")
181 lines, err := tailJSONLLines(storeRoot, path, limit)
182 if err != nil {
183 return nil, err
184 }
185 var heartbeats []Heartbeat
186 for _, line := range lines {
187 var h Heartbeat
188 if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &h); err != nil {
189 return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err)
190 }
191 heartbeats = append(heartbeats, h)
192 }
193 if limit > 0 && len(heartbeats) > limit {
194 heartbeats = heartbeats[len(heartbeats)-limit:]
195 }
196 return heartbeats, nil
197 }
198
199 func (s *Store) LastHeartbeat(taskID string) (Heartbeat, bool, error) {
200 heartbeats, err := s.Heartbeats(taskID, 1)
201 if err != nil {
202 return Heartbeat{}, false, err
203 }
204 if len(heartbeats) == 0 {
205 return Heartbeat{}, false, nil
206 }
207 return heartbeats[0], true, nil
208 }
209
210 func (s *Store) Progress(taskID string) (*Progress, error) {
211 storeRoot, taskRel, err := s.openTaskRoot(taskID)
212 if err != nil {
213 return nil, err
214 }
215 defer storeRoot.Close()
216 var progress Progress
217 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
218 return nil, err
219 }
220 return &progress, nil
221 }
222
223 func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
224 storeRoot, taskRel, err := s.openTaskRoot(taskID)
225 if err != nil {
226 return nil, err
227 }
228 defer storeRoot.Close()
229 _, report := validateTaskRoot(storeRoot, taskRel, taskID)
230 return report, nil
231 }
232
233 // validateTaskRoot reads and validates a task through one already-open root.
234 // The task directory cannot be swapped between validation and goal extraction.
235 func validateTaskRoot(storeRoot *os.Root, taskRel, taskID string) (TaskSpec, *ValidationReport) {
236 report := &ValidationReport{Valid: true}
237 info, err := storeRoot.Lstat(taskRel)
238 if err != nil {
239 report.add("task", "", err.Error())
240 report.Valid = false
241 return TaskSpec{}, report
242 }
243 if info.Mode()&os.ModeSymlink != 0 {
244 report.add("task", "", "task directory must not be a symlink")
245 report.Valid = false
246 return TaskSpec{}, report
247 }
248 if !info.IsDir() {
249 report.add("task", "", "task path is not a directory")
250 report.Valid = false
251 return TaskSpec{}, report
252 }
253 var spec TaskSpec
254 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
255 report.add("task_spec.json", "", err.Error())
256 } else {
257 validateTaskSpec(report, taskID, spec)
258 }
259 var progress Progress
260 if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
261 report.add("progress.json", "", err.Error())
262 } else {
263 validateProgress(report, progress)
264 }
265 validateDirections := func() error {
266 path := filepath.Join(taskRel, "state", "directions_tried.json")
267 data, err := readArchiveFile(storeRoot, path)
268 if err != nil {
269 return err
270 }
271 data = fileencoding.DecodeToUTF8(data)
272 if strings.TrimSpace(string(data)) == "" {
273 return nil
274 }
275 var directions []DirectionTried
276 if err := json.Unmarshal(data, &directions); err != nil {
277 return fmt.Errorf("parse %s: %w", path, err)
278 }
279 return nil
280 }
281 if err := validateDirections(); err != nil {
282 report.add("directions_tried.json", "", err.Error())
283 }
284 validateJSONL := func(rel string, each func([]byte) error) {
285 path := filepath.Join(taskRel, rel)
286 if err := readJSONL(storeRoot, path, each); err != nil {
287 report.add(filepath.Base(rel), "", err.Error())
288 }
289 }
290 validateJSONL("state/findings.jsonl", func(data []byte) error {
291 var finding Finding
292 if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &finding); err != nil {
293 return err
294 }
295 return validateFinding(finding)
296 })
297 validateJSONL("state/iteration_log.jsonl", func(data []byte) error {
298 var entry json.RawMessage
299 if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &entry); err != nil {
300 return err
301 }
302 return nil
303 })
304 validateJSONL("logs/heartbeat.jsonl", func(data []byte) error {
305 var heartbeat Heartbeat
306 if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &heartbeat); err != nil {
307 return err
308 }
309 if strings.TrimSpace(heartbeat.Status) == "" {
310 return errors.New("heartbeat status is required")
311 }
312 if heartbeat.Iteration < 0 {
313 return errors.New("heartbeat iteration must not be negative")
314 }
315 if heartbeat.CreatedAt.IsZero() {
316 return errors.New("heartbeat created_at is required")
317 }
318 return nil
319 })
320 report.Valid = len(report.Errors) == 0
321 return spec, report
322 }
323
324 func (s *Store) taskRoot(taskID string) string {
325 return filepath.Join(s.root, taskID)
326 }
327
328 func (s *Store) taskRel(taskID string, parts ...string) (string, error) {
329 if err := validateTaskID(taskID); err != nil {
330 return "", err
331 }
332 all := append([]string{taskID}, parts...)
333 rel := filepath.Join(all...)
334 if !filepath.IsLocal(rel) {
335 return "", fmt.Errorf("autoresearch: unsafe task-relative path %q", rel)
336 }
337 return rel, nil
338 }
339
340 func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) {
341 taskRel, err := s.taskRel(taskID)
342 if err != nil {
343 return nil, "", err
344 }
345 storeRoot, err := s.openArchiveRoot()
346 if err != nil {
347 if os.IsNotExist(err) {
348 return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
349 }
350 return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err)
351 }
352 info, err := storeRoot.Lstat(taskRel)
353 if err != nil {
354 storeRoot.Close()
355 if os.IsNotExist(err) {
356 return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
357 }
358 return nil, "", fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
359 }
360 if info.Mode()&os.ModeSymlink != 0 {
361 storeRoot.Close()
362 return nil, "", fmt.Errorf("autoresearch: task %s is a symlink", taskID)
363 }
364 if !info.IsDir() {
365 storeRoot.Close()
366 return nil, "", fmt.Errorf("autoresearch: task %s is not a directory", taskID)
367 }
368 taskRoot, err := storeRoot.OpenRoot(taskRel)
369 if err != nil {
370 storeRoot.Close()
371 return nil, "", fmt.Errorf("autoresearch: open task %s: %w", taskID, err)
372 }
373 opened, err := taskRoot.Stat(".")
374 if err != nil || !os.SameFile(info, opened) {
375 taskRoot.Close()
376 storeRoot.Close()
377 if err != nil {
378 return nil, "", fmt.Errorf("autoresearch: verify task %s: %w", taskID, err)
379 }
380 return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID)
381 }
382 current, err := storeRoot.Lstat(taskRel)
383 if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, current) {
384 taskRoot.Close()
385 storeRoot.Close()
386 if err != nil {
387 return nil, "", fmt.Errorf("autoresearch: recheck task %s: %w", taskID, err)
388 }
389 return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID)
390 }
391 if err := storeRoot.Close(); err != nil {
392 taskRoot.Close()
393 return nil, "", fmt.Errorf("autoresearch: close archive root: %w", err)
394 }
395 return taskRoot, ".", nil
396 }
397
398 // openArchiveRoot anchors every archive read to the resolved workspace root.
399 // os.Root prevents a concurrent symlink swap from escaping the workspace; the
400 // explicit Lstat/SameFile checks additionally reject symlinked archive roots.
401 func (s *Store) openArchiveRoot() (*os.Root, error) {
402 workspace, err := os.OpenRoot(s.workspaceRoot)
403 if err != nil {
404 return nil, fmt.Errorf("autoresearch: open workspace root: %w", err)
405 }
406 defer workspace.Close()
407
408 archiveRel := filepath.Join(".reasonix", "autoresearch")
409 rels := []string{".reasonix", archiveRel}
410 infos := make([]os.FileInfo, len(rels))
411 for i, rel := range rels {
412 info, err := workspace.Lstat(rel)
413 if err != nil {
414 return nil, fmt.Errorf("autoresearch: stat archive path %s: %w", rel, err)
415 }
416 if info.Mode()&os.ModeSymlink != 0 {
417 return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", rel)
418 }
419 if !info.IsDir() {
420 return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", rel)
421 }
422 infos[i] = info
423 }
424
425 archive, err := workspace.OpenRoot(archiveRel)
426 if err != nil {
427 return nil, fmt.Errorf("autoresearch: open archive root: %w", err)
428 }
429 opened, err := archive.Stat(".")
430 if err != nil || !os.SameFile(infos[len(infos)-1], opened) {
431 archive.Close()
432 if err != nil {
433 return nil, fmt.Errorf("autoresearch: verify archive root: %w", err)
434 }
435 return nil, errors.New("autoresearch: archive root changed while opening")
436 }
437 for i, rel := range rels {
438 current, err := workspace.Lstat(rel)
439 if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], current) {
440 archive.Close()
441 if err != nil {
442 return nil, fmt.Errorf("autoresearch: recheck archive path %s: %w", rel, err)
443 }
444 return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", rel)
445 }
446 }
447 return archive, nil
448 }
449
450 func validateTaskID(id string) error {
451 id = strings.TrimSpace(id)
452 if id == "" {
453 return errors.New("autoresearch: task id is required")
454 }
455 if !safeTaskID.MatchString(id) || strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
456 return fmt.Errorf("autoresearch: unsafe task id %q", id)
457 }
458 return nil
459 }
460
461 // validateFinding checks the base schema fields of a historical finding.
462 // Kind is intentionally unconstrained so unknown historical values remain
463 // readable. This helper exists for archive integrity checks and tests only;
464 // the reader never writes findings.
465 func validateFinding(f Finding) error {
466 if strings.TrimSpace(f.ID) == "" {
467 return errors.New("autoresearch: finding id is required")
468 }
469 if strings.TrimSpace(f.Summary) == "" {
470 return errors.New("autoresearch: finding summary is required")
471 }
472 if f.CreatedAt.IsZero() {
473 return errors.New("autoresearch: finding created_at is required")
474 }
475 return nil
476 }
477
478 func readJSONFile(root *os.Root, path string, out any) error {
479 data, err := readArchiveFile(root, path)
480 if err != nil {
481 return err
482 }
483 data = fileencoding.DecodeToUTF8(data)
484 if err := json.Unmarshal(data, out); err != nil {
485 return fmt.Errorf("parse %s: %w", path, err)
486 }
487 return nil
488 }
489
490 func readJSONL(root *os.Root, path string, each func([]byte) error) error {
491 f, err := openArchiveFile(root, path)
492 if err != nil {
493 return fmt.Errorf("autoresearch: open %s: %w", path, err)
494 }
495 defer f.Close()
496 scanner := bufio.NewScanner(f)
497 // Historical findings can be long; raise the scanner buffer for safety.
498 scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
499 for scanner.Scan() {
500 line := strings.TrimSpace(scanner.Text())
501 if line == "" {
502 continue
503 }
504 if err := each([]byte(line)); err != nil {
505 return fmt.Errorf("autoresearch: parse %s: %w", path, err)
506 }
507 }
508 if err := scanner.Err(); err != nil {
509 return fmt.Errorf("autoresearch: scan %s: %w", path, err)
510 }
511 return nil
512 }
513
514 // tailJSONLLines returns the last limit non-empty lines of a JSONL file in
515 // file order, reading backward in fixed-size chunks so per-turn readers do not
516 // rescan an append-only log that grows for the life of a task. limit <= 0
517 // reads the whole file (legacy unbounded behavior).
518 func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
519 if limit <= 0 {
520 var lines [][]byte
521 if err := readJSONL(root, path, func(data []byte) error {
522 line := make([]byte, len(data))
523 copy(line, data)
524 lines = append(lines, line)
525 return nil
526 }); err != nil {
527 return nil, err
528 }
529 return lines, nil
530 }
531 f, err := openArchiveFile(root, path)
532 if err != nil {
533 return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
534 }
535 defer f.Close()
536 info, err := f.Stat()
537 if err != nil {
538 return nil, fmt.Errorf("autoresearch: stat %s: %w", path, err)
539 }
540 const chunkSize = 64 * 1024
541 var (
542 buf []byte
543 off = info.Size()
544 )
545 for off > 0 {
546 readLen := min(off, int64(chunkSize))
547 off -= readLen
548 chunk := make([]byte, readLen)
549 if _, err := f.ReadAt(chunk, off); err != nil {
550 return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
551 }
552 buf = append(chunk, buf...)
553 if countCompleteTailLines(buf, off == 0) > limit {
554 break
555 }
556 }
557 segments := strings.Split(string(buf), "\n")
558 if off > 0 && len(segments) > 0 {
559 segments = segments[1:] // drop the leading partial line
560 }
561 var lines [][]byte
562 for _, seg := range segments {
563 seg = strings.TrimSpace(seg)
564 if seg == "" {
565 continue
566 }
567 lines = append(lines, []byte(seg))
568 }
569 if len(lines) > limit {
570 lines = lines[len(lines)-limit:]
571 }
572 return lines, nil
573 }
574
575 func readArchiveFile(root *os.Root, path string) ([]byte, error) {
576 f, err := openArchiveFile(root, path)
577 if err != nil {
578 return nil, err
579 }
580 defer f.Close()
581 data, err := io.ReadAll(f)
582 if err != nil {
583 return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
584 }
585 return data, nil
586 }
587
588 // openArchiveFile rejects symlinks and non-regular files at every path
589 // component, then binds parsing to the verified file descriptor. The second
590 // identity check closes the Lstat/open replacement window without holding a
591 // process-global directory or changing the archive.
592 func openArchiveFile(root *os.Root, path string) (*os.File, error) {
593 path = filepath.Clean(path)
594 if !filepath.IsLocal(path) || path == "." {
595 return nil, fmt.Errorf("autoresearch: unsafe archive file path %q", path)
596 }
597 parts := strings.Split(path, string(filepath.Separator))
598 infos := make([]os.FileInfo, len(parts))
599 current := ""
600 for i, part := range parts {
601 current = filepath.Join(current, part)
602 info, err := root.Lstat(current)
603 if err != nil {
604 return nil, fmt.Errorf("autoresearch: stat %s: %w", current, err)
605 }
606 if info.Mode()&os.ModeSymlink != 0 {
607 return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", current)
608 }
609 if i < len(parts)-1 {
610 if !info.IsDir() {
611 return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", current)
612 }
613 } else if !info.Mode().IsRegular() {
614 return nil, fmt.Errorf("autoresearch: archive path %s is not a regular file", current)
615 }
616 infos[i] = info
617 }
618
619 f, err := root.Open(path)
620 if err != nil {
621 return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
622 }
623 opened, err := f.Stat()
624 if err != nil || !opened.Mode().IsRegular() || !os.SameFile(infos[len(infos)-1], opened) {
625 f.Close()
626 if err != nil {
627 return nil, fmt.Errorf("autoresearch: verify %s: %w", path, err)
628 }
629 return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", path)
630 }
631
632 current = ""
633 for i, part := range parts {
634 current = filepath.Join(current, part)
635 info, err := root.Lstat(current)
636 if err != nil || info.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], info) {
637 f.Close()
638 if err != nil {
639 return nil, fmt.Errorf("autoresearch: recheck %s: %w", current, err)
640 }
641 return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", current)
642 }
643 }
644 return f, nil
645 }
646
647 func countCompleteTailLines(buf []byte, atStart bool) int {
648 segments := strings.Split(string(buf), "\n")
649 if !atStart && len(segments) > 0 {
650 segments = segments[1:]
651 }
652 count := 0
653 for _, seg := range segments {
654 if strings.TrimSpace(seg) != "" {
655 count++
656 }
657 }
658 return count
659 }
660
660 lines GO