返回 DeepSeek-Reasonix
jsonstore.go
根目录 / internal / taskmonitor / jsonstore.go
1 package taskmonitor
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13 "time"
14
15 "reasonix/internal/fileutil"
16 )
17
18 // FileStore is a Store backed by a JSON file tree under a project-local
19 // directory. Tasks are stored as <dir>/<task-id>/snapshot.json and
20 // <dir>/<task-id>/events.jsonl. It is read-only in TM-02; write support
21 // is added in TM-04.
22 type FileStore struct {
23 baseDir string // projectDir → task data root (e.g. ".reasonix/tasks")
24 sink ProjectionSink
25 }
26
27 // NewFileStore returns a FileStore rooted at baseDir. baseDir is typically
28 // ".reasonix/tasks" relative to the project root.
29 func NewFileStore(baseDir string) *FileStore {
30 return &FileStore{baseDir: baseDir}
31 }
32
33 func NewObservedFileStore(baseDir string, sink ProjectionSink) *FileStore {
34 return &FileStore{baseDir: baseDir, sink: sink}
35 }
36
37 // safeID validates a user-supplied identifier for use as a filesystem path
38 // component. It rejects empty strings, ".", "..", and values containing a
39 // path separator. Used for both taskID and idempotency keys.
40 func safeID(name string) (string, error) {
41 if name == "" {
42 return "", errors.New("identifier must not be empty")
43 }
44 cleaned := filepath.Base(name)
45 if cleaned == "." || cleaned == ".." {
46 return "", fmt.Errorf("invalid identifier %q", name)
47 }
48 // Windows accepts both slash styles as path separators. Check both so
49 // validation has the same traversal behavior on every platform.
50 if strings.ContainsAny(name, `/\\`) {
51 return "", fmt.Errorf("identifier %q contains path separator", name)
52 }
53 return cleaned, nil
54 }
55
56 // taskRoot returns the cleaned directory holding task data for projectDir.
57 // projectDir is the caller-selected project scope, not a path relative to a
58 // separate containment root. Parent-relative paths such as ../project and
59 // directory names containing ".." are therefore valid inputs.
60 func (s *FileStore) taskRoot(projectDir string) (string, error) {
61 if projectDir == "" {
62 projectDir = "."
63 }
64 cleaned := filepath.Clean(projectDir)
65 root := filepath.Join(cleaned, s.baseDir)
66 if err := rejectStoreParents(cleaned, root); err != nil {
67 return "", err
68 }
69 return root, nil
70 }
71
72 func rejectSymlink(path string) error {
73 info, err := os.Lstat(path)
74 if err != nil {
75 if os.IsNotExist(err) {
76 return nil
77 }
78 return err
79 }
80 if info.Mode()&os.ModeSymlink != 0 {
81 return fmt.Errorf("task store path %q is a symlink", path)
82 }
83 return nil
84 }
85
86 // rejectSymlinkChain rejects symlinks in the store path itself and all of its
87 // descendants up to target. This keeps a project-local task id from redirecting
88 // reads or writes outside the project through an intermediate directory.
89 func rejectSymlinkChain(root, target string) error {
90 rel, err := filepath.Rel(root, target)
91 if err != nil {
92 return err
93 }
94 cur := root
95 if err := rejectSymlink(cur); err != nil {
96 return err
97 }
98 if rel == "." {
99 return nil
100 }
101 for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
102 cur = filepath.Join(cur, part)
103 if err := rejectSymlink(cur); err != nil {
104 return err
105 }
106 }
107 return nil
108 }
109
110 func rejectStoreParents(projectDir, root string) error {
111 rel, err := filepath.Rel(projectDir, root)
112 if err != nil {
113 return err
114 }
115 cur := projectDir
116 for part := range strings.SplitSeq(rel, string(filepath.Separator)) {
117 if part == "." || part == "" {
118 continue
119 }
120 cur = filepath.Join(cur, part)
121 if err := rejectSymlink(cur); err != nil {
122 return err
123 }
124 }
125 return nil
126 }
127
128 func prepareTaskDir(root, id string) (string, error) {
129 taskDir := filepath.Join(root, id)
130 if err := rejectSymlinkChain(root, taskDir); err != nil {
131 return "", err
132 }
133 if err := os.MkdirAll(root, 0o700); err != nil {
134 return "", err
135 }
136 if err := os.Chmod(root, 0o700); err != nil {
137 return "", err
138 }
139 if err := os.MkdirAll(taskDir, 0o700); err != nil {
140 return "", err
141 }
142 if err := os.Chmod(taskDir, 0o700); err != nil {
143 return "", err
144 }
145 return taskDir, nil
146 }
147
148 // ListTasks implements Store.
149 func (s *FileStore) ListTasks(ctx context.Context, projectDir string) ([]TaskSnapshot, error) {
150 if err := ctx.Err(); err != nil {
151 return nil, err
152 }
153 root, err := s.taskRoot(projectDir)
154 if err != nil {
155 return nil, err
156 }
157 if err := rejectSymlink(root); err != nil {
158 return nil, err
159 }
160 entries, err := os.ReadDir(root)
161 if err != nil {
162 if os.IsNotExist(err) {
163 return []TaskSnapshot{}, nil
164 }
165 return nil, fmt.Errorf("read task dir %s: %w", root, err)
166 }
167 result := make([]TaskSnapshot, 0)
168 for _, e := range entries {
169 if !e.IsDir() {
170 continue
171 }
172 taskDir := filepath.Join(root, e.Name())
173 if err := rejectSymlinkChain(root, taskDir); err != nil {
174 continue
175 }
176 snap, err := s.readSnapshot(taskDir)
177 if err != nil {
178 continue // skip corrupt entries
179 }
180 reconcileRuntime(&snap, timeNow())
181 result = append(result, snap)
182 }
183 sort.Slice(result, func(i, j int) bool {
184 return result[i].UpdatedAt.After(result[j].UpdatedAt)
185 })
186 return result, nil
187 }
188
189 // GetTask implements Store.
190 func (s *FileStore) GetTask(ctx context.Context, projectDir string, taskID string) (*TaskSnapshot, error) {
191 snap, err := s.getTaskRaw(ctx, projectDir, taskID)
192 if snap != nil {
193 reconcileRuntime(snap, timeNow())
194 }
195 return snap, err
196 }
197
198 // getTaskRaw returns the persisted snapshot without applying observer-side
199 // runtime lease reconciliation. Runtime owners use this path when renewing a
200 // lease after process suspension or system sleep.
201 func (s *FileStore) getTaskRaw(ctx context.Context, projectDir string, taskID string) (*TaskSnapshot, error) {
202 if err := ctx.Err(); err != nil {
203 return nil, err
204 }
205 id, err := safeID(taskID)
206 if err != nil {
207 return nil, err
208 }
209 root, err := s.taskRoot(projectDir)
210 if err != nil {
211 return nil, err
212 }
213 if err := rejectSymlinkChain(root, filepath.Join(root, id)); err != nil {
214 return nil, err
215 }
216 snap, err := s.readSnapshot(filepath.Join(root, id))
217 if err != nil {
218 if os.IsNotExist(err) {
219 return nil, nil
220 }
221 return nil, err
222 }
223 return &snap, nil
224 }
225
226 // RenewRuntimeLease implements WriteStore. The raw read plus SaveTask CAS
227 // ensures a delayed owner cannot overwrite a concurrent control/completion
228 // update or renew a newer recorder generation.
229 func (s *FileStore) RenewRuntimeLease(ctx context.Context, projectDir, taskID, ownerID string, leaseUntil time.Time) (bool, error) {
230 if ownerID == "" || leaseUntil.IsZero() {
231 return false, nil
232 }
233 const maxAttempts = 4
234 for range maxAttempts {
235 snap, err := s.getTaskRaw(ctx, projectDir, taskID)
236 if err != nil || snap == nil {
237 return false, err
238 }
239 if snap.RuntimeOwnerID != ownerID || snap.State.Terminal() || snap.RuntimeState.Effective() != RuntimeStateAlive {
240 return false, nil
241 }
242 snap.Version++
243 snap.RuntimeLeaseUntil = leaseUntil
244 if err := s.SaveTask(ctx, projectDir, *snap); err == nil {
245 return true, nil
246 } else if !errors.Is(err, ErrStoreVersionConflict) {
247 return false, err
248 }
249 }
250 return false, ErrStoreVersionConflict
251 }
252
253 // ListEvents implements Store.
254 func (s *FileStore) ListEvents(ctx context.Context, projectDir string, taskID string, afterSequence int) ([]TaskEvent, error) {
255 if err := ctx.Err(); err != nil {
256 return nil, err
257 }
258 id, err := safeID(taskID)
259 if err != nil {
260 return nil, err
261 }
262 root, err := s.taskRoot(projectDir)
263 if err != nil {
264 return nil, err
265 }
266 if err := rejectSymlinkChain(root, filepath.Join(root, id)); err != nil {
267 return nil, err
268 }
269 events, err := s.readEvents(filepath.Join(root, id))
270 if err != nil {
271 if os.IsNotExist(err) {
272 return []TaskEvent{}, nil
273 }
274 return nil, err
275 }
276 result := make([]TaskEvent, 0)
277 for _, e := range events {
278 if e.Sequence > afterSequence {
279 result = append(result, e)
280 }
281 }
282 sort.Slice(result, func(i, j int) bool {
283 return result[i].Sequence < result[j].Sequence
284 })
285 return result, nil
286 }
287
288 func (s *FileStore) readSnapshot(taskDir string) (TaskSnapshot, error) {
289 if err := rejectSymlink(filepath.Join(taskDir, "snapshot.json")); err != nil {
290 return TaskSnapshot{}, err
291 }
292 data, err := os.ReadFile(filepath.Join(taskDir, "snapshot.json"))
293 if err != nil {
294 return TaskSnapshot{}, err
295 }
296 var snap TaskSnapshot
297 if err := json.Unmarshal(data, &snap); err != nil {
298 return TaskSnapshot{}, fmt.Errorf("parse snapshot: %w", err)
299 }
300 return snap, nil
301 }
302
303 func (s *FileStore) readEvents(taskDir string) ([]TaskEvent, error) {
304 if err := rejectSymlink(filepath.Join(taskDir, "events.jsonl")); err != nil {
305 return nil, err
306 }
307 data, err := os.ReadFile(filepath.Join(taskDir, "events.jsonl"))
308 if err != nil {
309 return nil, err
310 }
311 // JSONL: one JSON object per line
312 var events []TaskEvent
313 raw := string(data)
314 for raw != "" {
315 idx := 0
316 // find newline
317 for idx < len(raw) && raw[idx] != '\n' {
318 idx++
319 }
320 line := raw[:idx]
321 raw = raw[idx:]
322 if len(raw) > 0 {
323 raw = raw[1:] // skip newline
324 }
325 if line == "" {
326 continue
327 }
328 var ev TaskEvent
329 if err := json.Unmarshal([]byte(line), &ev); err != nil {
330 continue // skip corrupt lines
331 }
332 events = append(events, ev)
333 }
334 return events, nil
335 }
336
337 // SaveTask implements WriteStore. It atomically writes the snapshot,
338 // failing if a concurrent write has changed the version.
339 func (s *FileStore) SaveTask(ctx context.Context, projectDir string, snap TaskSnapshot) (retErr error) {
340 committed := false
341 defer func() {
342 if committed && s.sink != nil {
343 s.sink.SnapshotChanged(projectDir, snap.TaskID)
344 }
345 }()
346 if err := ctx.Err(); err != nil {
347 return err
348 }
349 id, err := safeID(snap.TaskID)
350 if err != nil {
351 return err
352 }
353 root, err := s.taskRoot(projectDir)
354 if err != nil {
355 return err
356 }
357 taskDir, err := prepareTaskDir(root, id)
358 if err != nil {
359 return fmt.Errorf("save task: %w", err)
360 }
361
362 // Cross-process CAS: hold the per-task lock while reading the current
363 // version and replacing snapshot.json, so two writers (CLI + Desktop,
364 // or two control operations) cannot both pass the version check and
365 // clobber each other. A dedicated lock file is used — never snapshot.json
366 // itself, since rename swaps the inode and would orphan the lock.
367 lockPath := filepath.Join(taskDir, "task.lock")
368 if err := rejectSymlink(lockPath); err != nil {
369 return fmt.Errorf("save task: %w", err)
370 }
371 lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
372 if err != nil {
373 return fmt.Errorf("save task: open lock: %w", err)
374 }
375 defer lf.Close()
376 if err := lockTaskFile(lf); err != nil {
377 return fmt.Errorf("save task: lock: %w", err)
378 }
379 _ = lf.Chmod(0o600)
380 defer func() {
381 if unlockErr := unlockTaskFile(lf); unlockErr != nil && retErr == nil {
382 retErr = fmt.Errorf("save task: unlock: %w", unlockErr)
383 }
384 }()
385
386 target := filepath.Join(taskDir, "snapshot.json")
387 // Read current version for CAS check (inside the lock).
388 current, err := s.readSnapshot(taskDir)
389 switch {
390 case err == nil && snap.Version <= current.Version:
391 return fmt.Errorf("save task: %w: stored=%d, given=%d", ErrStoreVersionConflict, current.Version, snap.Version)
392 case err != nil && !os.IsNotExist(err):
393 // A corrupt snapshot must fail loudly, never bypass the CAS check.
394 return fmt.Errorf("save task: read current snapshot: %w", err)
395 }
396
397 data, err := json.Marshal(snap)
398 if err != nil {
399 return fmt.Errorf("save task: marshal: %w", err)
400 }
401
402 // Atomic write via temp file + rename
403 tmp, err := os.CreateTemp(taskDir, ".snapshot-*.tmp")
404 if err != nil {
405 return fmt.Errorf("save task: %w", err)
406 }
407 tmpName := tmp.Name()
408 if _, err := tmp.Write(data); err != nil {
409 tmp.Close()
410 os.Remove(tmpName)
411 return fmt.Errorf("save task: %w", err)
412 }
413 if err := tmp.Sync(); err != nil {
414 tmp.Close()
415 os.Remove(tmpName)
416 return fmt.Errorf("save task: %w", err)
417 }
418 if err := tmp.Close(); err != nil {
419 os.Remove(tmpName)
420 return fmt.Errorf("save task: %w", err)
421 }
422 // Keep the CAS lock across bounded retries for Windows readers or filter
423 // drivers. Publication must remain an atomic rename, never a copy fallback.
424 if err := fileutil.ClaimRename(tmpName, target); err != nil {
425 os.Remove(tmpName)
426 return fmt.Errorf("save task: %w", err)
427 }
428 _ = os.Chmod(target, 0o600)
429 committed = true
430 return nil
431 }
432
433 // SaveEvent implements WriteStore.
434 // AppendAuditEvent implements WriteStore. It atomically assigns the next
435 // monotonic sequence number and appends the event to the JSONL file.
436 func (s *FileStore) AppendAuditEvent(ctx context.Context, projectDir string, ev TaskEvent) (retErr error) {
437 committed := false
438 defer func() {
439 if committed && s.sink != nil {
440 s.sink.EventsChanged(projectDir, ev.TaskID)
441 }
442 }()
443 if err := ctx.Err(); err != nil {
444 return err
445 }
446 id, err := safeID(ev.TaskID)
447 if err != nil {
448 return err
449 }
450 root, err := s.taskRoot(projectDir)
451 if err != nil {
452 return err
453 }
454 taskDir, err := prepareTaskDir(root, id)
455 if err != nil {
456 return fmt.Errorf("append audit event: %w", err)
457 }
458
459 // Cross-process atomicity: take the per-task lock (shared with SaveTask)
460 // so sequence assignment and snapshot writes never interleave. The
461 // events file itself is never renamed, so a dedicated task.lock is
462 // sufficient and keeps exactly one lock per task directory.
463 lockPath := filepath.Join(taskDir, "task.lock")
464 if err := rejectSymlink(lockPath); err != nil {
465 return fmt.Errorf("append audit event: %w", err)
466 }
467 lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
468 if err != nil {
469 return fmt.Errorf("append audit event: open lock: %w", err)
470 }
471 defer lf.Close()
472 if err := lockTaskFile(lf); err != nil {
473 return fmt.Errorf("append audit event: lock: %w", err)
474 }
475 defer func() {
476 if unlockErr := unlockTaskFile(lf); unlockErr != nil && retErr == nil {
477 retErr = fmt.Errorf("append audit event: unlock: %w", unlockErr)
478 }
479 }()
480
481 eventsPath := filepath.Join(taskDir, "events.jsonl")
482 if err := rejectSymlink(eventsPath); err != nil {
483 return fmt.Errorf("append audit event: %w", err)
484 }
485 f, err := os.OpenFile(eventsPath, os.O_RDWR|os.O_CREATE, 0o600)
486 if err != nil {
487 return err
488 }
489 defer f.Close()
490 _ = f.Chmod(0o600)
491
492 // Read current events to compute next sequence (safe under lock)
493 if _, err := f.Seek(0, 0); err != nil {
494 return err
495 }
496 raw, err := io.ReadAll(f)
497 if err != nil {
498 return err
499 }
500 max := 0
501 for line := range strings.SplitSeq(string(raw), "\n") {
502 line = strings.TrimSpace(line)
503 if line == "" {
504 continue
505 }
506 var existing TaskEvent
507 if err := json.Unmarshal([]byte(line), &existing); err != nil {
508 continue
509 }
510 if existing.Sequence > max {
511 max = existing.Sequence
512 }
513 }
514 ev.Sequence = max + 1
515 if err := ev.Validate(); err != nil {
516 return fmt.Errorf("append audit event: %w", err)
517 }
518 data, err := json.Marshal(ev)
519 if err != nil {
520 return err
521 }
522 // Append at end of locked file
523 if _, err := f.Seek(0, 2); err != nil {
524 return err
525 }
526 if _, err := f.WriteString(string(data) + "\n"); err != nil {
527 return err
528 }
529 committed = true
530 return nil
531 }
532
533 // ── deprecated: removed NextSequence, SaveEvent — use AppendAuditEvent ──
534
535 // CheckIdempotency implements WriteStore.
536 func (s *FileStore) CheckIdempotency(ctx context.Context, projectDir string, key string) (*IdempotencyRecord, error) {
537 root, err := s.taskRoot(projectDir)
538 if err != nil {
539 return nil, err
540 }
541 id, err := safeID(key)
542 if err != nil {
543 return nil, err
544 }
545 idemDir := filepath.Join(root, ".idempotency")
546 if err := rejectSymlink(idemDir); err != nil {
547 return nil, err
548 }
549 if err := rejectSymlink(filepath.Join(idemDir, id+".json")); err != nil {
550 return nil, err
551 }
552 data, err := os.ReadFile(filepath.Join(idemDir, id+".json"))
553 if err != nil {
554 if os.IsNotExist(err) {
555 return nil, nil
556 }
557 return nil, err
558 }
559 var rec IdempotencyRecord
560 if err := json.Unmarshal(data, &rec); err != nil {
561 return nil, nil
562 }
563 return &rec, nil
564 }
565
566 func (s *FileStore) idempotencyPaths(projectDir, key string) (string, string, string, error) {
567 root, err := s.taskRoot(projectDir)
568 if err != nil {
569 return "", "", "", err
570 }
571 id, err := safeID(key)
572 if err != nil {
573 return "", "", "", err
574 }
575 dir := filepath.Join(root, ".idempotency")
576 if err := rejectSymlink(dir); err != nil {
577 return "", "", "", err
578 }
579 if err := os.MkdirAll(dir, 0o700); err != nil {
580 return "", "", "", err
581 }
582 _ = os.Chmod(dir, 0o700)
583 target := filepath.Join(dir, id+".json")
584 lock := filepath.Join(dir, id+".lock")
585 if err := rejectSymlink(target); err != nil {
586 return "", "", "", err
587 }
588 if err := rejectSymlink(lock); err != nil {
589 return "", "", "", err
590 }
591 return dir, target, lock, nil
592 }
593
594 // quarantineCorruptIdempotency moves an unreadable record out of the active
595 // key path without deleting it. A corrupt record cannot safely describe either
596 // a pending or finalized operation; keeping it as evidence prevents it from
597 // permanently blocking future claims while preserving forensic data.
598 func quarantineCorruptIdempotency(target string) error {
599 backup := fmt.Sprintf("%s.corrupt-%d", target, timeNow().UnixNano())
600 if err := os.Rename(target, backup); err != nil {
601 if os.IsNotExist(err) {
602 return nil
603 }
604 return err
605 }
606 return nil
607 }
608
609 func (s *FileStore) ClaimIdempotency(ctx context.Context, projectDir string, r IdempotencyRecord) (*IdempotencyRecord, error) {
610 if err := ctx.Err(); err != nil {
611 return nil, err
612 }
613 _, target, lockPath, err := s.idempotencyPaths(projectDir, r.Key)
614 if err != nil {
615 return nil, err
616 }
617 lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
618 if err != nil {
619 return nil, err
620 }
621 defer lf.Close()
622 _ = lf.Chmod(0o600)
623 if err := lockTaskFile(lf); err != nil {
624 return nil, err
625 }
626 defer func() { _ = unlockTaskFile(lf) }()
627 data, err := os.ReadFile(target)
628 if err == nil {
629 var existing IdempotencyRecord
630 if jsonErr := json.Unmarshal(data, &existing); jsonErr != nil {
631 if quarantineErr := quarantineCorruptIdempotency(target); quarantineErr != nil {
632 return nil, fmt.Errorf("idempotency claim: parse existing record: %w (quarantine: %w)", jsonErr, quarantineErr)
633 }
634 // Continue with a fresh claim after preserving the corrupt record.
635 } else if existing.Pending && timeNow().Sub(existing.ClaimedAt) > 5*time.Minute {
636 _ = os.Remove(target)
637 } else {
638 return &existing, nil
639 }
640 } else if !os.IsNotExist(err) {
641 return nil, err
642 }
643 if r.ClaimedAt.IsZero() {
644 r.ClaimedAt = timeNow()
645 }
646 r.Pending = true
647 data, err = json.Marshal(r)
648 if err != nil {
649 return nil, err
650 }
651 if err := fileutil.AtomicWriteFile(target, data, 0o600); err != nil {
652 return nil, err
653 }
654 _ = os.Chmod(target, 0o600)
655 return nil, nil
656 }
657
658 func (s *FileStore) FinalizeIdempotency(ctx context.Context, projectDir string, r IdempotencyRecord) error {
659 if err := ctx.Err(); err != nil {
660 return err
661 }
662 _, target, lockPath, err := s.idempotencyPaths(projectDir, r.Key)
663 if err != nil {
664 return err
665 }
666 lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
667 if err != nil {
668 return err
669 }
670 defer lf.Close()
671 if err := lockTaskFile(lf); err != nil {
672 return err
673 }
674 defer func() { _ = unlockTaskFile(lf) }()
675 data, err := os.ReadFile(target)
676 if err != nil {
677 return err
678 }
679 var existing IdempotencyRecord
680 if err := json.Unmarshal(data, &existing); err != nil {
681 return err
682 }
683 if existing.Op != r.Op || existing.TaskID != r.TaskID || existing.Version != r.Version {
684 return fmt.Errorf("idempotency key conflict: different params")
685 }
686 existing.Pending = false
687 data, err = json.Marshal(existing)
688 if err != nil {
689 return err
690 }
691 if err := fileutil.AtomicWriteFile(target, data, 0o600); err != nil {
692 return err
693 }
694 _ = os.Chmod(target, 0o600)
695 return nil
696 }
697
698 func (s *FileStore) ReleaseIdempotency(ctx context.Context, projectDir, key string) error {
699 if err := ctx.Err(); err != nil {
700 return err
701 }
702 _, target, lockPath, err := s.idempotencyPaths(projectDir, key)
703 if err != nil {
704 return err
705 }
706 lf, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600)
707 if err != nil {
708 return err
709 }
710 defer lf.Close()
711 if err := lockTaskFile(lf); err != nil {
712 return err
713 }
714 defer func() { _ = unlockTaskFile(lf) }()
715 data, err := os.ReadFile(target)
716 if os.IsNotExist(err) {
717 return nil
718 }
719 if err != nil {
720 return err
721 }
722 var existing IdempotencyRecord
723 if err := json.Unmarshal(data, &existing); err != nil {
724 return err
725 }
726 if existing.Pending {
727 return os.Remove(target)
728 }
729 return nil
730 }
731
732 // RecordIdempotency implements WriteStore.
733 func (s *FileStore) RecordIdempotency(ctx context.Context, projectDir string, r IdempotencyRecord) error {
734 root, err := s.taskRoot(projectDir)
735 if err != nil {
736 return err
737 }
738 id, err := safeID(r.Key)
739 if err != nil {
740 return err
741 }
742 idemDir := filepath.Join(root, ".idempotency")
743 if err := rejectSymlink(idemDir); err != nil {
744 return err
745 }
746 if err := os.MkdirAll(idemDir, 0o700); err != nil {
747 return err
748 }
749 if err := os.Chmod(idemDir, 0o700); err != nil {
750 return err
751 }
752 data, err := json.Marshal(r)
753 if err != nil {
754 return err
755 }
756 target := filepath.Join(idemDir, id+".json")
757 if err := rejectSymlink(target); err != nil {
758 return err
759 }
760 // Publish the complete record only when the key is still absent. A crash
761 // during the old direct write could leave a permanently unparsable record.
762 if err := fileutil.AtomicCreateFile(target, data, 0o600); err == nil {
763 return nil
764 } else if !os.IsExist(err) {
765 return err
766 }
767 existing, rdErr := os.ReadFile(target)
768 if rdErr != nil {
769 return fmt.Errorf("idempotency conflict: cannot read existing record: %w", rdErr)
770 }
771 var prev IdempotencyRecord
772 if err := json.Unmarshal(existing, &prev); err != nil {
773 return fmt.Errorf("idempotency conflict: cannot parse existing record: %w", err)
774 }
775 if prev.Op != r.Op || prev.TaskID != r.TaskID || prev.Version != r.Version {
776 return fmt.Errorf("idempotency key conflict: different params")
777 }
778 return nil // idempotent
779 }
780
780 lines GO