| 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 |