返回 DeepSeek-Reasonix
copy.go
根目录 / internal / session / copy.go
1 package session
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "reasonix/internal/fileutil"
14 )
15
16 const (
17 copyReceiptName = "copy-receipt.json"
18 copyReceiptSchemaVersion = 1
19 )
20
21 // CopyRequest identifies one full-history copy. OperationID is durable
22 // idempotency authority: retrying it addresses the same child even after the
23 // source has accepted more events.
24 type CopyRequest struct {
25 Source SessionRef
26 ChildID string
27 OperationID string
28 CWD string
29 }
30
31 type CopyResult struct {
32 Child SessionRef
33 }
34
35 type copyReceipt struct {
36 SchemaVersion int `json:"schemaVersion"`
37 OperationID string `json:"operationId"`
38 SourceSessionID string `json:"sourceSessionId"`
39 CreatedAt time.Time `json:"createdAt"`
40 }
41
42 // CopySession publishes a complete immutable snapshot under a new identity.
43 // It does not attach a runtime and it never changes the source runtime.
44 func (s *Service) CopySession(ctx context.Context, request CopyRequest) (CopyResult, error) {
45 if s == nil {
46 return CopyResult{}, errors.New("session: nil service")
47 }
48 if err := request.Source.validate(s.hostID); err != nil {
49 return CopyResult{}, err
50 }
51 operationID := strings.TrimSpace(request.OperationID)
52 if operationID == "" {
53 return CopyResult{}, errors.New("session: copy operation id is required")
54 }
55 childID := strings.TrimSpace(request.ChildID)
56 if childID == "" {
57 childID = deterministicID("copy\x00" + request.Source.SessionID + "\x00" + operationID)
58 }
59 if err := validateSessionID(childID); err != nil {
60 return CopyResult{}, err
61 }
62 filesystem, ok := s.persistence.(*FilesystemPersistence)
63 if !ok {
64 return CopyResult{}, errors.New("session: persistence does not support filesystem copy")
65 }
66 childRef := SessionRef{HostID: s.hostID, SessionID: childID}
67 childDir := filepath.Join(filesystem.Root, childID)
68 if matched, err := copyChildMatches(childDir, request.Source.SessionID, operationID); err == nil {
69 if matched {
70 return CopyResult{Child: childRef}, nil
71 }
72 return CopyResult{}, fmt.Errorf("%w: %s", ErrSessionExists, childID)
73 } else if !os.IsNotExist(err) {
74 return CopyResult{}, err
75 }
76
77 container, err := os.MkdirTemp(filesystem.Root, ".copy-export-")
78 if err != nil {
79 return CopyResult{}, err
80 }
81 defer os.RemoveAll(container)
82 bundle := filepath.Join(container, "bundle")
83 if err := s.Export(ctx, request.Source, bundle); err != nil {
84 return CopyResult{}, err
85 }
86 createdAt := time.Now().UTC()
87 manifest, err := readManifest(filepath.Join(bundle, "manifest.json"))
88 if err != nil {
89 return CopyResult{}, err
90 }
91 manifest.CreatedAt = createdAt
92 if err := writeManifestFile(filepath.Join(bundle, "manifest.json"), manifest); err != nil {
93 return CopyResult{}, err
94 }
95 receipt := copyReceipt{
96 SchemaVersion: copyReceiptSchemaVersion, OperationID: operationID,
97 SourceSessionID: request.Source.SessionID, CreatedAt: createdAt,
98 }
99 body, err := json.Marshal(receipt)
100 if err != nil {
101 return CopyResult{}, err
102 }
103 if err := fileutil.AtomicWriteFileStrict(filepath.Join(bundle, copyReceiptName), append(body, '\n'), 0o600); err != nil {
104 return CopyResult{}, err
105 }
106 _, err = s.ImportWithHeader(ctx, bundle, CreateOptions{
107 SessionID: childID, CWD: request.CWD, ParentSessionID: request.Source.SessionID,
108 Origin: SessionOriginCanonicalImport,
109 })
110 if err == nil {
111 return CopyResult{Child: childRef}, nil
112 }
113 // Two retries can race through the preflight. The published receipt is the
114 // durable proof that the winner belongs to this exact operation.
115 if matched, readErr := copyChildMatches(childDir, request.Source.SessionID, operationID); readErr == nil && matched {
116 return CopyResult{Child: childRef}, nil
117 }
118 return CopyResult{}, err
119 }
120
121 func copyChildMatches(childDir, sourceSessionID, operationID string) (bool, error) {
122 body, err := os.ReadFile(filepath.Join(childDir, copyReceiptName))
123 if err != nil {
124 return false, err
125 }
126 var receipt copyReceipt
127 if err := json.Unmarshal(body, &receipt); err != nil {
128 return false, err
129 }
130 return receipt.SchemaVersion == copyReceiptSchemaVersion &&
131 receipt.SourceSessionID == strings.TrimSpace(sourceSessionID) &&
132 receipt.OperationID == strings.TrimSpace(operationID), nil
133 }
134
135 // CopyOperationMatches proves that an already-published child belongs to the
136 // exact full-copy operation. Hosts use it to resume workspace attachment after
137 // a process stopped between storage publication and registry commit.
138 func (s *Service) CopyOperationMatches(ctx context.Context, child SessionRef, sourceSessionID, operationID string) (bool, error) {
139 if err := ctx.Err(); err != nil {
140 return false, err
141 }
142 if err := child.validate(s.hostID); err != nil {
143 return false, err
144 }
145 filesystem, ok := s.persistence.(*FilesystemPersistence)
146 if !ok {
147 return false, errors.New("session: persistence does not support filesystem copy")
148 }
149 return copyChildMatches(filepath.Join(filesystem.Root, child.SessionID), sourceSessionID, operationID)
150 }
151
151 lines GO