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