返回 DeepSeek-Reasonix
migrate.go
根目录 / internal / session / migrate.go
1 package session
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "io/fs"
12 "os"
13 "path/filepath"
14 "sort"
15 "strings"
16 "sync"
17 "time"
18
19 "reasonix/internal/agent"
20 "reasonix/internal/fileutil"
21 filelock "reasonix/internal/identitylock"
22 "reasonix/internal/provider"
23 "reasonix/internal/store"
24 )
25
26 type MigrationMapping struct {
27 SchemaVersion int `json:"schemaVersion"`
28 Entries []MigrationEntry `json:"entries"`
29 }
30
31 type MigrationEntry struct {
32 SourcePath string `json:"sourcePath"`
33 SourceSize int64 `json:"sourceSize"`
34 SourceSHA256 string `json:"sourceSha256"`
35 LegacyHeadID string `json:"legacyHeadId,omitempty"`
36 TargetCodec string `json:"targetCodec"`
37 TargetID string `json:"targetId"`
38 CreatedAt time.Time `json:"createdAt"`
39 }
40
41 type MigrationResult struct {
42 TargetID string
43 TargetDir string
44 Source Source
45 Reused bool
46 MessageNum int
47 }
48
49 var migrationMu sync.Mutex
50
51 // MigrateLegacy freezes one legacy session under its write lease, constructs a
52 // complete canonical directory in a sibling temporary directory, then publishes it by
53 // rename. Original artifacts are copied byte-for-byte under legacy/ and are
54 // never rewritten or removed.
55 func MigrateLegacy(ctx context.Context, sourcePath, targetRoot string) (MigrationResult, error) {
56 return MigrateLegacyHead(ctx, sourcePath, targetRoot, "")
57 }
58
59 // MigrateLegacyHead turns one reachable legacy DAG head into its own canonical
60 // session. An empty head ID imports the legacy file's selected/default view.
61 // Head identity participates in both the deterministic target ID and migration
62 // map key, so continuing two old heads can never merge their future writes.
63 func MigrateLegacyHead(ctx context.Context, sourcePath, targetRoot, legacyHeadID string) (MigrationResult, error) {
64 return migrateLegacyHead(ctx, sourcePath, targetRoot, legacyHeadID, false)
65 }
66
67 // migrateLegacyHeadForHost permits an existing host transition to freeze a
68 // legacy source already leased by this process. Cross-process ownership is
69 // still enforced by the OS lease. Callers must serialize the transition with
70 // their host/session gate and stop the old producer before invoking it.
71 func migrateLegacyHeadForHost(ctx context.Context, sourcePath, targetRoot, legacyHeadID string) (MigrationResult, error) {
72 return migrateLegacyHead(ctx, sourcePath, targetRoot, legacyHeadID, true)
73 }
74
75 func migrateLegacyHead(ctx context.Context, sourcePath, targetRoot, legacyHeadID string, allowCurrentOwner bool) (MigrationResult, error) {
76 targetRoot = filepath.Clean(strings.TrimSpace(targetRoot))
77 if targetRoot == "." {
78 return MigrationResult{}, fmt.Errorf("session: source and target root are required")
79 }
80 frozen, err := freezeLegacyHead(ctx, sourcePath, legacyHeadID, allowCurrentOwner)
81 if err != nil {
82 return MigrationResult{}, err
83 }
84 return frozen.publish(ctx, targetRoot, CreateOptions{})
85 }
86
87 // frozenLegacyHead is one legacy head reduced to an immutable, already-parsed
88 // migration input. Nothing is published while it is being built, so a caller
89 // that must compare it against a paired event sidecar can still refuse the
90 // import without leaving a partially-adopted target behind.
91 type frozenLegacyHead struct {
92 sourcePath string
93 headID string
94 source Source
95 artifacts []frozenArtifact
96 targetID string
97 messageSpool string
98 messageCount int
99 modelRef string
100 modelIdentity string
101 modelMessages []provider.Message
102 projectionDiagnostic string
103 goal map[string]any
104 freezeDir string
105 }
106
107 // LegacyMigrationHeads lists all heads from an immutable copy, without changing
108 // source selection, caches, or logs. Retired heads are returned for the caller
109 // to distinguish deliberate deletion from a missing branch.
110 func LegacyMigrationHeads(ctx context.Context, sourcePath string) ([]agent.SessionHead, error) {
111 sourcePath = agent.CanonicalSessionPath(sourcePath)
112 lease, err := agent.TryAcquireSessionLease(sourcePath)
113 if err != nil {
114 return nil, err
115 }
116 artifacts, _, dir, err := freezeLegacyArtifacts(ctx, sourcePath)
117 lease.Release()
118 if err != nil {
119 return nil, err
120 }
121 defer os.RemoveAll(dir)
122 for _, artifact := range artifacts {
123 if artifact.path == sourcePath {
124 return agent.ListSessionHeadsForMigration(ctx, artifact.frozenPath)
125 }
126 }
127 return nil, os.ErrNotExist
128 }
129
130 // freezeLegacyHead acquires the source lease, copies every durable artifact
131 // byte-for-byte, then parses only the frozen copy. The lease is released before
132 // parsing, which is safe precisely because the parse never reads the original.
133 func freezeLegacyHead(ctx context.Context, sourcePath, legacyHeadID string, allowCurrentOwner bool) (*frozenLegacyHead, error) {
134 if err := ctx.Err(); err != nil {
135 return nil, err
136 }
137 sourcePath = agent.CanonicalSessionPath(sourcePath)
138 legacyHeadID = strings.TrimSpace(legacyHeadID)
139 if sourcePath == "" {
140 return nil, fmt.Errorf("session: source is required")
141 }
142 var lease *agent.SessionLease
143 if !allowCurrentOwner || !agent.SessionLeaseHeldByCurrentRuntime(sourcePath) {
144 acquired, acquireErr := agent.TryAcquireSessionLease(sourcePath)
145 if acquireErr != nil {
146 return nil, fmt.Errorf("freeze legacy session: %w", acquireErr)
147 }
148 lease = acquired
149 }
150 artifacts, source, freezeDir, err := freezeLegacyArtifacts(ctx, sourcePath)
151 if lease != nil {
152 lease.Release()
153 }
154 if err != nil {
155 return nil, err
156 }
157 source.LegacyHeadID = legacyHeadID
158 parsed, err := parseFrozenLegacy(ctx, artifacts, sourcePath, legacyHeadID)
159 if err != nil {
160 _ = os.RemoveAll(freezeDir)
161 return nil, err
162 }
163 return &frozenLegacyHead{
164 sourcePath: sourcePath, headID: legacyHeadID, source: source, artifacts: artifacts,
165 targetID: migrationTargetID(sourcePath, source.SHA256, legacyHeadID),
166 messageSpool: parsed.messageSpool, messageCount: parsed.messageCount,
167 modelRef: parsed.modelRef, modelIdentity: parsed.modelIdentity,
168 modelMessages: parsed.modelMessages, projectionDiagnostic: parsed.projectionDiagnostic,
169 goal: parsed.goal,
170 freezeDir: freezeDir,
171 }, nil
172 }
173
174 type frozenLegacyParse struct {
175 messageSpool string
176 messageCount int
177 modelRef string
178 modelIdentity string
179 modelMessages []provider.Message
180 projectionDiagnostic string
181 goal map[string]any
182 }
183
184 // parseFrozenLegacy reads the frozen artifacts from a private directory so the
185 // published target can never depend on bytes outside the frozen input.
186 func parseFrozenLegacy(ctx context.Context, artifacts []frozenArtifact, sourcePath, legacyHeadID string) (frozenLegacyParse, error) {
187 if len(artifacts) == 0 {
188 return frozenLegacyParse{}, os.ErrNotExist
189 }
190 if err := ctx.Err(); err != nil {
191 return frozenLegacyParse{}, err
192 }
193 frozenSourcePath := ""
194 for _, artifact := range artifacts {
195 if artifact.path == sourcePath {
196 frozenSourcePath = artifact.frozenPath
197 break
198 }
199 }
200 if frozenSourcePath == "" {
201 return frozenLegacyParse{}, os.ErrNotExist
202 }
203 messageSpool := filepath.Join(filepath.Dir(frozenSourcePath), ".migration-messages.jsons")
204 spool, err := os.OpenFile(messageSpool, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0o600)
205 if err != nil {
206 return frozenLegacyParse{}, err
207 }
208 encoder := json.NewEncoder(spool)
209 reset := func() error {
210 if err := spool.Truncate(0); err != nil {
211 return err
212 }
213 _, err := spool.Seek(0, io.SeekStart)
214 return err
215 }
216 emit := func(message provider.Message) error { return encoder.Encode(message) }
217 stream, streamErr := agent.StreamSessionMessagesForMigration(ctx, frozenSourcePath, legacyHeadID, reset, emit)
218 if streamErr == nil {
219 streamErr = spool.Sync()
220 }
221 closeErr := spool.Close()
222 if streamErr != nil {
223 return frozenLegacyParse{}, fmt.Errorf("read legacy transcript: %w", streamErr)
224 }
225 if closeErr != nil {
226 return frozenLegacyParse{}, closeErr
227 }
228 parsed := frozenLegacyParse{messageSpool: messageSpool, messageCount: stream.Messages}
229 if _, err := os.Stat(agent.ContextStatePath(frozenSourcePath)); err == nil {
230 canonical, loadErr := readMigrationMessageSpool(ctx, messageSpool)
231 if loadErr != nil {
232 return frozenLegacyParse{}, loadErr
233 }
234 modelMessages, valid, projectionErr := agent.LoadValidContextProjectionForMigration(frozenSourcePath, canonical)
235 switch {
236 case projectionErr != nil:
237 parsed.projectionDiagnostic = "legacy context projection ignored: " + projectionErr.Error()
238 case valid:
239 parsed.modelMessages = provider.ModelMessages(modelMessages)
240 default:
241 parsed.projectionDiagnostic = "legacy context projection ignored: sidecar does not match canonical history"
242 }
243 } else if !os.IsNotExist(err) {
244 return frozenLegacyParse{}, err
245 }
246 if modelRef, modelIdentity, ok := agent.LoadSessionModelSelection(frozenSourcePath); ok && strings.TrimSpace(modelRef) != "" {
247 parsed.modelRef, parsed.modelIdentity = strings.TrimSpace(modelRef), strings.TrimSpace(modelIdentity)
248 }
249 parsed.goal = sanitizedLegacyGoal(frozenSourcePath)
250 return parsed, nil
251 }
252
253 func readMigrationMessageSpool(ctx context.Context, path string) ([]provider.Message, error) {
254 file, err := os.Open(path)
255 if err != nil {
256 return nil, err
257 }
258 defer file.Close()
259 decoder := json.NewDecoder(&contextReader{ctx: ctx, reader: file})
260 var messages []provider.Message
261 for {
262 var message provider.Message
263 if err := decoder.Decode(&message); errors.Is(err, io.EOF) {
264 return messages, nil
265 } else if err != nil {
266 return nil, err
267 }
268 messages = append(messages, message)
269 }
270 }
271
272 // publish materializes the frozen input as the deterministic final target. The
273 // directory is built in a sibling temporary path and atomically renamed, so a
274 // reader never observes a partial session.
275 func (f *frozenLegacyHead) publish(ctx context.Context, targetRoot string, options CreateOptions) (MigrationResult, error) {
276 if f == nil {
277 return MigrationResult{}, fmt.Errorf("session: nil frozen legacy head")
278 }
279 defer os.RemoveAll(f.freezeDir)
280 targetDir := filepath.Join(targetRoot, f.targetID)
281 result := MigrationResult{TargetID: f.targetID, TargetDir: targetDir, Source: f.source, MessageNum: f.messageCount}
282
283 migrationMu.Lock()
284 defer migrationMu.Unlock()
285 if err := ctx.Err(); err != nil {
286 return MigrationResult{}, err
287 }
288 reused, err := f.reusePublished(ctx, targetRoot, targetDir, options)
289 if err != nil {
290 return MigrationResult{}, err
291 }
292 if reused {
293 result.Reused = true
294 return result, nil
295 }
296 if err := os.MkdirAll(targetRoot, 0o700); err != nil {
297 return MigrationResult{}, err
298 }
299 tmp, err := os.MkdirTemp(targetRoot, "."+f.targetID+".tmp-")
300 if err != nil {
301 return MigrationResult{}, err
302 }
303 published := false
304 defer func() {
305 if !published {
306 _ = os.RemoveAll(tmp)
307 }
308 }()
309
310 manifest := Manifest{SchemaVersion: SchemaVersion, Codec: Codec, StorageRevision: StorageRevision, ContentRoot: sharedContentRoot, SessionID: f.targetID, CreatedAt: time.Now().UTC(), Source: &f.source}
311 if err := writeImportedManifest(tmp, manifest, options); err != nil {
312 return MigrationResult{}, err
313 }
314 legacyDir := filepath.Join(tmp, "legacy")
315 for _, artifact := range f.artifacts {
316 if err := ctx.Err(); err != nil {
317 return MigrationResult{}, err
318 }
319 if err := copyFrozenArtifact(ctx, artifact.frozenPath, filepath.Join(legacyDir, filepath.Base(artifact.path)), artifact.mode); err != nil {
320 return MigrationResult{}, err
321 }
322 }
323
324 target, err := OpenWithOptions(tmp, f.targetID, OpenOptions{ExternalHistory: true})
325 if err != nil {
326 return MigrationResult{}, err
327 }
328 appendErr := f.appendMessages(ctx, target)
329 if appendErr == nil {
330 appendErr = f.appendMetadata(ctx, target)
331 }
332 if appendErr == nil {
333 _, appendErr = target.Flush(ctx)
334 }
335 closeErr := target.Close(ctx)
336 if appendErr != nil {
337 return MigrationResult{}, appendErr
338 }
339 if closeErr != nil {
340 return MigrationResult{}, closeErr
341 }
342 if err := os.Rename(tmp, targetDir); err != nil {
343 return MigrationResult{}, fmt.Errorf("publish canonical session: %w", err)
344 }
345 published = true
346 if err := appendMigrationMapping(ctx, targetRoot, MigrationEntry{SourcePath: f.sourcePath, SourceSize: f.source.Size, SourceSHA256: f.source.SHA256, LegacyHeadID: f.headID, TargetCodec: Codec, TargetID: f.targetID, CreatedAt: time.Now().UTC()}); err != nil {
347 // The target is already complete and deterministically discoverable. A
348 // later retry repairs the mapping without rebuilding or resending work.
349 return result, fmt.Errorf("publish migration mapping: %w", err)
350 }
351 return result, nil
352 }
353
354 func (f *frozenLegacyHead) appendMessages(ctx context.Context, target *Session) (err error) {
355 const messageBatchSize = 128
356 spool, err := os.Open(f.messageSpool)
357 if err != nil {
358 return err
359 }
360 defer func() { err = errors.Join(err, spool.Close()) }()
361
362 decoder := json.NewDecoder(&contextReader{ctx: ctx, reader: spool})
363 events := make([]Event, 0, messageBatchSize)
364 batchNumber := 0
365 flush := func() error {
366 if len(events) == 0 {
367 return nil
368 }
369 _, err := target.Append(ctx, Batch{
370 OperationID: fmt.Sprintf("legacy-import:%s:messages:%d", f.source.SHA256, batchNumber),
371 Events: events,
372 })
373 batchNumber++
374 events = make([]Event, 0, messageBatchSize)
375 return err
376 }
377 for {
378 var message provider.Message
379 decodeErr := decoder.Decode(&message)
380 if errors.Is(decodeErr, io.EOF) {
381 return flush()
382 }
383 if decodeErr != nil {
384 return decodeErr
385 }
386 raw, marshalErr := json.Marshal(struct {
387 Message provider.Message `json:"message"`
388 }{Message: message})
389 if marshalErr != nil {
390 return marshalErr
391 }
392 events = append(events, Event{Kind: "message/complete", Payload: raw})
393 if len(events) == messageBatchSize {
394 if err := flush(); err != nil {
395 return err
396 }
397 }
398 }
399 }
400
401 func (f *frozenLegacyHead) appendMetadata(ctx context.Context, target *Session) error {
402 prefix := "legacy-import:" + f.source.SHA256 + ":"
403 if len(f.modelMessages) > 0 {
404 event, err := legacyModelContextEvent(f.modelMessages)
405 if err != nil {
406 return err
407 }
408 if _, err := target.Append(ctx, Batch{OperationID: prefix + "model-context", Events: []Event{event}}); err != nil {
409 return err
410 }
411 }
412 if f.projectionDiagnostic != "" {
413 raw, err := json.Marshal(map[string]string{
414 "code": "legacy_context_projection_ignored",
415 "detail": f.projectionDiagnostic,
416 })
417 if err != nil {
418 return err
419 }
420 if _, err := target.Append(ctx, Batch{OperationID: prefix + "projection-diagnostic", Events: []Event{{Kind: "diagnostic", Optional: true, Payload: raw}}}); err != nil {
421 return err
422 }
423 }
424 if f.modelRef != "" {
425 raw, err := json.Marshal(map[string]string{"modelRef": f.modelRef, "modelIdentity": f.modelIdentity})
426 if err != nil {
427 return err
428 }
429 if _, err := target.Append(ctx, Batch{OperationID: prefix + "model", Events: []Event{{Kind: "session/config", Payload: raw}}}); err != nil {
430 return err
431 }
432 }
433 if f.goal == nil {
434 return nil
435 }
436 raw, err := json.Marshal(f.goal)
437 if err != nil {
438 return err
439 }
440 _, err = target.Append(ctx, Batch{OperationID: prefix + "goal", Events: []Event{{Kind: "goal/state", Payload: raw}}})
441 return err
442 }
443
444 func (f *frozenLegacyHead) reusePublished(ctx context.Context, targetRoot, targetDir string, options CreateOptions) (bool, error) {
445 manifest, err := readManifest(filepath.Join(targetDir, "manifest.json"))
446 if os.IsNotExist(err) {
447 return false, nil
448 }
449 if err != nil {
450 return false, err
451 }
452 if manifest.Source == nil || manifest.Source.Path != f.sourcePath || manifest.Source.SHA256 != f.source.SHA256 || manifest.Source.LegacyHeadID != f.headID {
453 return false, fmt.Errorf("session: target %s already exists for different input", f.targetID)
454 }
455 if err := validateSessionHeaderForCreate(targetDir, f.targetID, options); err != nil {
456 return false, err
457 }
458 if err := f.repairPublishedProjection(ctx, targetDir); err != nil {
459 return false, err
460 }
461 entry := MigrationEntry{SourcePath: f.sourcePath, SourceSize: f.source.Size, SourceSHA256: f.source.SHA256, LegacyHeadID: f.headID, TargetCodec: Codec, TargetID: f.targetID, CreatedAt: manifest.CreatedAt}
462 if err := appendMigrationMapping(ctx, targetRoot, entry); err != nil {
463 return false, fmt.Errorf("repair migration mapping: %w", err)
464 }
465 return true, nil
466 }
467
468 func legacyModelContextEvent(messages []provider.Message) (Event, error) {
469 raw, err := json.Marshal(map[string]any{
470 "messages": provider.ModelMessages(messages),
471 "reason": "legacy-import-projection",
472 })
473 if err != nil {
474 return Event{}, err
475 }
476 return Event{Kind: "model/context-replace", Payload: raw}, nil
477 }
478
479 // repairPublishedProjection upgrades a target created by an older importer
480 // only while every commit still belongs to the deterministic import. Any
481 // subsequent user or runtime commit makes the target ineligible for mutation.
482 func (f *frozenLegacyHead) repairPublishedProjection(ctx context.Context, targetDir string) error {
483 if f == nil || len(f.modelMessages) == 0 {
484 return nil
485 }
486 prefix := "legacy-import:" + f.source.SHA256 + ":"
487 pristine, hasProjection := true, false
488 if err := VisitCommits(ctx, targetDir, func(commit Commit) error {
489 if !strings.HasPrefix(commit.OperationID, prefix) {
490 pristine = false
491 }
492 for _, event := range commit.Events {
493 if event.Kind == "model/context-replace" || event.Kind == "compaction" {
494 hasProjection = true
495 }
496 }
497 return nil
498 }); err != nil {
499 return err
500 }
501 if !pristine || hasProjection {
502 return nil
503 }
504 target, err := OpenWithOptions(targetDir, f.targetID, OpenOptions{ExternalHistory: true})
505 if err != nil {
506 // A live canonical target owns its writer lease. Reuse remains safe, but
507 // an in-place repair must wait for a later inactive retry.
508 return nil
509 }
510 event, err := legacyModelContextEvent(f.modelMessages)
511 if err == nil {
512 _, err = target.Append(ctx, Batch{OperationID: prefix + "model-context", Events: []Event{event}})
513 }
514 if err == nil {
515 _, err = target.Flush(ctx)
516 }
517 return errors.Join(err, target.Close(ctx))
518 }
519
520 func writeImportedManifest(dir string, manifest Manifest, options CreateOptions) error {
521 if err := writeManifest(filepath.Join(dir, "manifest.json"), manifest); err != nil {
522 return err
523 }
524 return writeSessionHeaderForCreate(dir, manifest.SessionID, manifest.CreatedAt, options)
525 }
526
527 type frozenArtifact struct {
528 path string
529 frozenPath string
530 mode fs.FileMode
531 size int64
532 }
533
534 func freezeLegacyArtifacts(ctx context.Context, sourcePath string) ([]frozenArtifact, Source, string, error) {
535 freezeDir, err := os.MkdirTemp("", "reasonix-legacy-freeze-")
536 if err != nil {
537 return nil, Source{}, "", err
538 }
539 keep := false
540 defer func() {
541 if !keep {
542 _ = os.RemoveAll(freezeDir)
543 }
544 }()
545 paths := append([]string{sourcePath}, store.SessionSidecarFiles(sourcePath)...)
546 seen := map[string]bool{}
547 artifacts := []frozenArtifact{}
548 foundSource := false
549 hash := sha256.New()
550 var total int64
551 for _, path := range paths {
552 if err := ctx.Err(); err != nil {
553 return nil, Source{}, "", err
554 }
555 path = filepath.Clean(path)
556 if seen[path] {
557 continue
558 }
559 seen[path] = true
560 info, err := os.Stat(path)
561 if os.IsNotExist(err) {
562 continue
563 }
564 if err != nil {
565 return nil, Source{}, "", err
566 }
567 if !info.Mode().IsRegular() {
568 continue
569 }
570 frozenPath := filepath.Join(freezeDir, filepath.Base(path))
571 if err := copyFrozenArtifact(ctx, path, frozenPath, info.Mode().Perm()); err != nil {
572 return nil, Source{}, "", err
573 }
574 artifacts = append(artifacts, frozenArtifact{path: path, frozenPath: frozenPath, mode: info.Mode().Perm(), size: info.Size()})
575 if path == sourcePath {
576 foundSource = true
577 }
578 }
579 if !foundSource {
580 return nil, Source{}, "", os.ErrNotExist
581 }
582 sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].path < artifacts[j].path })
583 for _, artifact := range artifacts {
584 name := filepath.Base(artifact.path)
585 hash.Write([]byte(name))
586 hash.Write([]byte{0})
587 file, err := os.Open(artifact.frozenPath)
588 if err != nil {
589 return nil, Source{}, "", err
590 }
591 _, copyErr := copyStreamWithContext(ctx, hash, file)
592 closeErr := file.Close()
593 if copyErr != nil || closeErr != nil {
594 return nil, Source{}, "", errors.Join(copyErr, closeErr)
595 }
596 hash.Write([]byte{0})
597 total += artifact.size
598 }
599 keep = true
600 return artifacts, Source{Path: sourcePath, Size: total, SHA256: hex.EncodeToString(hash.Sum(nil)), Version: "legacy"}, freezeDir, nil
601 }
602
603 func copyFrozenArtifact(ctx context.Context, source, target string, mode fs.FileMode) error {
604 if err := ctx.Err(); err != nil {
605 return err
606 }
607 if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
608 return err
609 }
610 in, err := os.Open(source)
611 if err != nil {
612 return err
613 }
614 defer in.Close()
615 tmp, err := os.CreateTemp(filepath.Dir(target), ".frozen-*.tmp")
616 if err != nil {
617 return err
618 }
619 tmpPath := tmp.Name()
620 defer os.Remove(tmpPath)
621 if _, err := copyStreamWithContext(ctx, tmp, in); err != nil {
622 _ = tmp.Close()
623 return err
624 }
625 if err := tmp.Sync(); err != nil {
626 _ = tmp.Close()
627 return err
628 }
629 if err := tmp.Chmod(mode); err != nil {
630 _ = tmp.Close()
631 return err
632 }
633 if err := tmp.Close(); err != nil {
634 return err
635 }
636 return fileutil.ReplaceFile(tmpPath, target)
637 }
638
639 func copyStreamWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
640 buffer := make([]byte, 1<<20)
641 var total int64
642 for {
643 if err := ctx.Err(); err != nil {
644 return total, err
645 }
646 n, readErr := src.Read(buffer)
647 if n > 0 {
648 written, writeErr := dst.Write(buffer[:n])
649 total += int64(written)
650 if writeErr != nil {
651 return total, writeErr
652 }
653 if written != n {
654 return total, io.ErrShortWrite
655 }
656 }
657 if readErr != nil {
658 if errors.Is(readErr, io.EOF) {
659 return total, nil
660 }
661 return total, readErr
662 }
663 }
664 }
665
666 func sanitizedLegacyGoal(sourcePath string) map[string]any {
667 b, err := os.ReadFile(store.SessionGoalState(sourcePath))
668 if err != nil {
669 return nil
670 }
671 var goal map[string]any
672 if json.Unmarshal(b, &goal) != nil {
673 return nil
674 }
675 delete(goal, "todos")
676 delete(goal, "todo")
677 delete(goal, "auto_continue")
678 delete(goal, "autoContinue")
679 return goal
680 }
681
682 func migrationTargetID(path, digest, legacyHeadID string) string {
683 sum := sha256.Sum256([]byte(path + "\x00" + digest + "\x00" + legacyHeadID + "\x00" + Codec))
684 return hex.EncodeToString(sum[:12])
685 }
686
687 func readManifest(path string) (Manifest, error) {
688 b, err := os.ReadFile(path)
689 if err != nil {
690 return Manifest{}, err
691 }
692 var m Manifest
693 if err := json.Unmarshal(b, &m); err != nil {
694 return Manifest{}, err
695 }
696 if !currentStoredManifest(m) {
697 return Manifest{}, fmt.Errorf("%w: manifest schema or codec", ErrUnsupportedVersion)
698 }
699 return m, nil
700 }
701
702 func writeManifest(path string, m Manifest) error {
703 if m.Codec == "" {
704 m.Codec = Codec
705 }
706 if m.Codec == Codec && m.SchemaVersion == SchemaVersion && m.StorageRevision == 0 {
707 m.StorageRevision = StorageRevision
708 }
709 b, err := json.MarshalIndent(m, "", " ")
710 if err != nil {
711 return err
712 }
713 return fileutil.AtomicWriteFileStrict(path, append(b, '\n'), 0o600)
714 }
715
716 func appendMigrationMapping(ctx context.Context, root string, entry MigrationEntry) error {
717 path := filepath.Join(root, "migration-map.json")
718 release, err := acquireMigrationMapLease(ctx, path)
719 if err != nil {
720 return fmt.Errorf("lock migration map: %w", err)
721 }
722 defer release()
723 mapping := MigrationMapping{SchemaVersion: SchemaVersion, Entries: []MigrationEntry{}}
724 if b, err := os.ReadFile(path); err == nil {
725 if err := json.Unmarshal(b, &mapping); err != nil {
726 return err
727 }
728 if mapping.SchemaVersion != SchemaVersion {
729 return fmt.Errorf("%w: migration map schema %d", ErrUnsupportedVersion, mapping.SchemaVersion)
730 }
731 } else if !os.IsNotExist(err) {
732 return err
733 }
734 for _, existing := range mapping.Entries {
735 if existing.SourcePath == entry.SourcePath && existing.SourceSHA256 == entry.SourceSHA256 && existing.LegacyHeadID == entry.LegacyHeadID && existing.TargetCodec == entry.TargetCodec {
736 return nil
737 }
738 }
739 mapping.Entries = append(mapping.Entries, entry)
740 sort.Slice(mapping.Entries, func(i, j int) bool {
741 if mapping.Entries[i].SourcePath == mapping.Entries[j].SourcePath {
742 if mapping.Entries[i].SourceSHA256 == mapping.Entries[j].SourceSHA256 {
743 if mapping.Entries[i].LegacyHeadID == mapping.Entries[j].LegacyHeadID {
744 return mapping.Entries[i].TargetCodec < mapping.Entries[j].TargetCodec
745 }
746 return mapping.Entries[i].LegacyHeadID < mapping.Entries[j].LegacyHeadID
747 }
748 return mapping.Entries[i].SourceSHA256 < mapping.Entries[j].SourceSHA256
749 }
750 return mapping.Entries[i].SourcePath < mapping.Entries[j].SourcePath
751 })
752 b, err := json.MarshalIndent(mapping, "", " ")
753 if err != nil {
754 return err
755 }
756 return fileutil.AtomicWriteFileStrict(path, append(b, '\n'), 0o600)
757 }
758
759 func acquireMigrationMapLease(ctx context.Context, path string) (func(), error) {
760 return filelock.Acquire(ctx, path+".lock")
761 }
762
763 func IsUnsupported(err error) bool { return errors.Is(err, ErrUnsupportedVersion) }
764
764 lines GO