| 1 | package session |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "io/fs" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | |
| 14 | "reasonix/internal/attachment" |
| 15 | filelock "reasonix/internal/identitylock" |
| 16 | "reasonix/internal/sessioncontent" |
| 17 | ) |
| 18 | |
| 19 | // SetTitle updates mutable session metadata through the canonical event log. |
| 20 | // A cold write acquires the ordinary writer lease, flushes the event, and then |
| 21 | // releases the exact Runtime; no title sidecar becomes a second source of truth. |
| 22 | func (s *Service) SetTitle(ctx context.Context, ref SessionRef, title string) error { |
| 23 | return s.setTitle(ctx, ref, nil, title) |
| 24 | } |
| 25 | |
| 26 | var ErrSessionTitleChanged = errors.New("session title changed") |
| 27 | |
| 28 | // SetTitleIfSequence checks and commits against the sequence of the latest |
| 29 | // session/title event. Same-value manual writes and A→B→A both advance this |
| 30 | // revision, so a delayed generated title cannot overwrite them. |
| 31 | func (s *Service) SetTitleIfSequence(ctx context.Context, ref SessionRef, expectedSequence uint64, title string) error { |
| 32 | return s.setTitle(ctx, ref, &expectedSequence, title) |
| 33 | } |
| 34 | |
| 35 | func (s *Service) setTitle(ctx context.Context, ref SessionRef, expectedSequence *uint64, title string) error { |
| 36 | if err := ctx.Err(); err != nil { |
| 37 | return err |
| 38 | } |
| 39 | if err := ref.validate(s.hostID); err != nil { |
| 40 | return err |
| 41 | } |
| 42 | runtime, alreadyOpen := s.Runtime(ref) |
| 43 | var session *Session |
| 44 | var err error |
| 45 | if alreadyOpen { |
| 46 | session = runtime.Session() |
| 47 | } else { |
| 48 | session, err = s.persistence.Open(ref.SessionID, ReadWrite) |
| 49 | if err != nil { |
| 50 | return err |
| 51 | } |
| 52 | defer session.Close(context.Background()) |
| 53 | } |
| 54 | payload, err := json.Marshal(map[string]string{"title": title}) |
| 55 | if err != nil { |
| 56 | return err |
| 57 | } |
| 58 | prepared, err := session.PrepareBatchContext(ctx, "session-title:"+randomID(), Batch{Events: []Event{{Kind: "session/title", Payload: payload}}}) |
| 59 | if err != nil { |
| 60 | return err |
| 61 | } |
| 62 | if _, err = session.commitPrepared(prepared, expectedSequence); err != nil { |
| 63 | return err |
| 64 | } |
| 65 | _, err = session.Flush(ctx) |
| 66 | return err |
| 67 | } |
| 68 | |
| 69 | // SetModel appends the same canonical session/config event used at creation. |
| 70 | // It is used when a host restores history under a safe fallback controller. |
| 71 | func (s *Service) SetModel(ctx context.Context, ref SessionRef, modelRef, modelIdentity string) error { |
| 72 | if err := ref.validate(s.hostID); err != nil { |
| 73 | return err |
| 74 | } |
| 75 | runtime, alreadyOpen := s.Runtime(ref) |
| 76 | var target *Session |
| 77 | var err error |
| 78 | if alreadyOpen { |
| 79 | target = runtime.Session() |
| 80 | } else { |
| 81 | target, err = s.persistence.Open(ref.SessionID, ReadWrite) |
| 82 | if err != nil { |
| 83 | return err |
| 84 | } |
| 85 | defer target.Close(context.Background()) |
| 86 | } |
| 87 | payload, err := json.Marshal(map[string]string{"modelRef": strings.TrimSpace(modelRef), "modelIdentity": strings.TrimSpace(modelIdentity)}) |
| 88 | if err != nil { |
| 89 | return err |
| 90 | } |
| 91 | if _, err := target.AppendBatch(ctx, "session-model:"+randomID(), []Event{{Kind: "session/config", Payload: payload}}); err != nil { |
| 92 | return err |
| 93 | } |
| 94 | _, err = target.Flush(ctx) |
| 95 | return err |
| 96 | } |
| 97 | |
| 98 | // Export writes a self-contained immutable copy of the session directory. It |
| 99 | // first establishes a durability checkpoint, then freezes the physical write |
| 100 | // boundary while copying, so the exported manifest and event prefix cannot |
| 101 | // describe different moments. |
| 102 | func (s *Session) Export(ctx context.Context, destination string) error { |
| 103 | if s == nil { |
| 104 | return os.ErrClosed |
| 105 | } |
| 106 | if err := ctx.Err(); err != nil { |
| 107 | return err |
| 108 | } |
| 109 | if _, err := s.Flush(ctx); err != nil { |
| 110 | return err |
| 111 | } |
| 112 | if s.binding == nil { |
| 113 | return ErrReadOnly |
| 114 | } |
| 115 | source := s.dir() |
| 116 | if source == "" { |
| 117 | return os.ErrClosed |
| 118 | } |
| 119 | // The drain chain is the physical write boundary: holding it freezes the |
| 120 | // bytes on disk. Accepted in-memory updates and Stop do not wait for the |
| 121 | // export's disk I/O. |
| 122 | return s.binding.freezePhysical(func() error { return exportDirectory(ctx, source, destination) }) |
| 123 | } |
| 124 | |
| 125 | func (p *FilesystemPersistence) exportCold(ctx context.Context, sessionID, destination string) error { |
| 126 | return p.exportColdMode(ctx, sessionID, destination, false) |
| 127 | } |
| 128 | |
| 129 | func (p *FilesystemPersistence) exportColdMode(ctx context.Context, sessionID, destination string, try bool) error { |
| 130 | if err := validateSessionID(sessionID); err != nil { |
| 131 | return err |
| 132 | } |
| 133 | source, err := p.sessionDir(sessionID, true) |
| 134 | if err != nil { |
| 135 | return err |
| 136 | } |
| 137 | acquire := func(path string) (func(), error) { |
| 138 | if err := ctx.Err(); err != nil { |
| 139 | return nil, err |
| 140 | } |
| 141 | if try { |
| 142 | return filelock.TryAcquireMode(path, filelock.ModeShared) |
| 143 | } |
| 144 | return filelock.AcquireMode(ctx, path, filelock.ModeShared) |
| 145 | } |
| 146 | releaseDirectory, err := acquire(directoryOwnershipPath(source)) |
| 147 | if err != nil { |
| 148 | return err |
| 149 | } |
| 150 | defer releaseDirectory() |
| 151 | if _, err := readManifest(filepath.Join(source, "manifest.json")); err != nil { |
| 152 | return err |
| 153 | } |
| 154 | release, err := acquire(filepath.Join(source, "writer.lock")) |
| 155 | if err != nil { |
| 156 | return fmt.Errorf("session: freeze cold export: %w", err) |
| 157 | } |
| 158 | defer release() |
| 159 | return exportDirectory(ctx, source, destination) |
| 160 | } |
| 161 | |
| 162 | func exportDirectory(ctx context.Context, source, destination string) error { |
| 163 | source = filepath.Clean(source) |
| 164 | destination = filepath.Clean(strings.TrimSpace(destination)) |
| 165 | if destination == "." || destination == source || strings.HasPrefix(destination+string(os.PathSeparator), source+string(os.PathSeparator)) { |
| 166 | return fmt.Errorf("session: invalid export destination %q", destination) |
| 167 | } |
| 168 | if _, err := os.Stat(destination); err == nil { |
| 169 | return fmt.Errorf("%w: export destination", ErrSessionExists) |
| 170 | } else if !os.IsNotExist(err) { |
| 171 | return err |
| 172 | } |
| 173 | parent := filepath.Dir(destination) |
| 174 | if err := os.MkdirAll(parent, 0o700); err != nil { |
| 175 | return err |
| 176 | } |
| 177 | tmp, err := os.MkdirTemp(parent, ".session-export-") |
| 178 | if err != nil { |
| 179 | return err |
| 180 | } |
| 181 | published := false |
| 182 | defer func() { |
| 183 | if !published { |
| 184 | _ = os.RemoveAll(tmp) |
| 185 | } |
| 186 | }() |
| 187 | err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { |
| 188 | if walkErr != nil { |
| 189 | return walkErr |
| 190 | } |
| 191 | if err := ctx.Err(); err != nil { |
| 192 | return err |
| 193 | } |
| 194 | relative, err := filepath.Rel(source, path) |
| 195 | if err != nil || relative == "." { |
| 196 | return err |
| 197 | } |
| 198 | if relative == "writer.lock" || relative == "events.offset-index.json" { |
| 199 | if entry.IsDir() { |
| 200 | return filepath.SkipDir |
| 201 | } |
| 202 | return nil |
| 203 | } |
| 204 | target := filepath.Join(tmp, relative) |
| 205 | info, err := entry.Info() |
| 206 | if err != nil { |
| 207 | return err |
| 208 | } |
| 209 | if info.Mode()&os.ModeSymlink != 0 { |
| 210 | return fmt.Errorf("session: export refuses symlink %s", relative) |
| 211 | } |
| 212 | if entry.IsDir() { |
| 213 | return os.MkdirAll(target, info.Mode().Perm()) |
| 214 | } |
| 215 | if !info.Mode().IsRegular() { |
| 216 | return fmt.Errorf("session: export refuses non-regular file %s", relative) |
| 217 | } |
| 218 | return copySessionFile(ctx, path, target, info.Mode().Perm()) |
| 219 | }) |
| 220 | if err != nil { |
| 221 | return err |
| 222 | } |
| 223 | manifest, err := readManifest(filepath.Join(tmp, "manifest.json")) |
| 224 | if err != nil { |
| 225 | return fmt.Errorf("validate export manifest: %w", err) |
| 226 | } |
| 227 | if err := copyExportContentClosure(ctx, source, tmp, manifest); err != nil { |
| 228 | return fmt.Errorf("export referenced content: %w", err) |
| 229 | } |
| 230 | manifest.ContentRoot = ".content-v1" |
| 231 | if err := writeManifestFile(filepath.Join(tmp, "manifest.json"), manifest); err != nil { |
| 232 | return err |
| 233 | } |
| 234 | if _, err := Replay(tmp, nil); err != nil { |
| 235 | return fmt.Errorf("validate export events: %w", err) |
| 236 | } |
| 237 | if manifest.SessionID == "" { |
| 238 | return fmt.Errorf("%w: export has empty session id", ErrDamagedStore) |
| 239 | } |
| 240 | if err := os.Rename(tmp, destination); err != nil { |
| 241 | return err |
| 242 | } |
| 243 | published = true |
| 244 | return nil |
| 245 | } |
| 246 | |
| 247 | func copyExportContentClosure(ctx context.Context, source, target string, manifest Manifest) error { |
| 248 | if manifest.Codec != Codec { |
| 249 | return nil |
| 250 | } |
| 251 | log, err := os.Open(logPathForManifest(target, manifest)) |
| 252 | if err != nil { |
| 253 | return err |
| 254 | } |
| 255 | defer log.Close() |
| 256 | sourceContent := contentStoreForSessionDir(source) |
| 257 | refs := map[string]sessioncontent.Ref{} |
| 258 | var payloadErr error |
| 259 | if err := scanV4CommitFileRefs(ctx, log, 0, 1, sourceContent, nil, func(_ int64, commit Commit) bool { |
| 260 | for _, event := range commit.Events { |
| 261 | if event.PayloadRef != nil { |
| 262 | key := fmt.Sprintf("%s:%d:%s", event.PayloadRef.Digest, event.PayloadRef.Bytes, event.PayloadRef.IndexDigest) |
| 263 | refs[key] = *event.PayloadRef |
| 264 | } |
| 265 | payload := event.Payload |
| 266 | if len(payload) == 0 && event.PayloadRef != nil { |
| 267 | payload, payloadErr = resolveContentPayload(ctx, sourceContent, *event.PayloadRef) |
| 268 | if payloadErr != nil { |
| 269 | return false |
| 270 | } |
| 271 | } |
| 272 | for _, extra := range attachment.CollectJSONRefs(payload) { |
| 273 | refs[contentRefKey(extra)] = extra |
| 274 | } |
| 275 | } |
| 276 | return true |
| 277 | }); err != nil { |
| 278 | return err |
| 279 | } |
| 280 | if payloadErr != nil { |
| 281 | return payloadErr |
| 282 | } |
| 283 | targetContent := sessioncontent.New(filepath.Join(target, ".content-v1")) |
| 284 | for _, ref := range refs { |
| 285 | reader, err := sourceContent.Open(ctx, ref) |
| 286 | if err != nil { |
| 287 | return fmt.Errorf("%w: missing exported content %s", ErrDamagedStore, ref.Digest) |
| 288 | } |
| 289 | published, putErr := targetContent.Put(ctx, reader, sessioncontent.Metadata{MediaType: ref.MediaType, Name: ref.Name}) |
| 290 | closeErr := reader.Close() |
| 291 | if putErr != nil || closeErr != nil { |
| 292 | return errors.Join(putErr, closeErr) |
| 293 | } |
| 294 | if published.Digest != ref.Digest || published.Bytes != ref.Bytes || published.IndexDigest != ref.IndexDigest { |
| 295 | return fmt.Errorf("%w: exported content identity changed", ErrDamagedStore) |
| 296 | } |
| 297 | } |
| 298 | return nil |
| 299 | } |
| 300 | |
| 301 | func copySessionFile(ctx context.Context, source, target string, mode fs.FileMode) error { |
| 302 | if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { |
| 303 | return err |
| 304 | } |
| 305 | in, err := os.Open(source) |
| 306 | if err != nil { |
| 307 | return err |
| 308 | } |
| 309 | defer in.Close() |
| 310 | out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) |
| 311 | if err != nil { |
| 312 | return err |
| 313 | } |
| 314 | _, copyErr := io.Copy(out, &contextReader{ctx: ctx, reader: in}) |
| 315 | syncErr := out.Sync() |
| 316 | closeErr := out.Close() |
| 317 | return errors.Join(copyErr, syncErr, closeErr) |
| 318 | } |
| 319 | |
| 320 | type contextReader struct { |
| 321 | ctx context.Context |
| 322 | reader io.Reader |
| 323 | } |
| 324 | |
| 325 | func (r *contextReader) Read(buffer []byte) (int, error) { |
| 326 | if err := r.ctx.Err(); err != nil { |
| 327 | return 0, err |
| 328 | } |
| 329 | return r.reader.Read(buffer) |
| 330 | } |
| 331 | |
| 332 | // Delete holds directory ownership across rename. The inner writer lock must |
| 333 | // be closed before moving its directory on Windows; the outer lock keeps a |
| 334 | // competing opener from entering that interval. |
| 335 | func (p *FilesystemPersistence) Delete(ctx context.Context, sessionID string) error { |
| 336 | if err := validateSessionID(sessionID); err != nil { |
| 337 | return err |
| 338 | } |
| 339 | source, err := p.sessionDir(sessionID, true) |
| 340 | if err != nil { |
| 341 | return err |
| 342 | } |
| 343 | releaseDirectory, err := filelock.Acquire(ctx, directoryOwnershipPath(source)) |
| 344 | if err != nil { |
| 345 | return err |
| 346 | } |
| 347 | defer releaseDirectory() |
| 348 | if _, err := readManifest(filepath.Join(source, "manifest.json")); err != nil { |
| 349 | if os.IsNotExist(err) { |
| 350 | return fmt.Errorf("%w: %s", ErrSessionNotFound, sessionID) |
| 351 | } |
| 352 | return err |
| 353 | } |
| 354 | release, err := filelock.Acquire(ctx, filepath.Join(source, "writer.lock")) |
| 355 | if err != nil { |
| 356 | return fmt.Errorf("session: delete ownership: %w", err) |
| 357 | } |
| 358 | release() |
| 359 | trashRoot := filepath.Join(p.Root, ".trash") |
| 360 | if err := os.MkdirAll(trashRoot, 0o700); err != nil { |
| 361 | return err |
| 362 | } |
| 363 | tombstone := filepath.Join(trashRoot, sessionID+"-"+randomID()) |
| 364 | if err := os.Rename(source, tombstone); err != nil { |
| 365 | return err |
| 366 | } |
| 367 | _ = os.RemoveAll(filepath.Join(p.Root, ".query-cache", sessionID)) |
| 368 | return os.RemoveAll(tombstone) |
| 369 | } |
| 370 | |
| 371 | func (s *Service) Export(ctx context.Context, ref SessionRef, destination string) error { |
| 372 | if err := ref.validate(s.hostID); err != nil { |
| 373 | return err |
| 374 | } |
| 375 | if runtime, ok := s.Runtime(ref); ok { |
| 376 | return runtime.session.Export(ctx, destination) |
| 377 | } |
| 378 | filesystem, ok := s.persistence.(*FilesystemPersistence) |
| 379 | if !ok { |
| 380 | return errors.New("session: persistence does not support export") |
| 381 | } |
| 382 | return filesystem.exportCold(ctx, ref.SessionID, destination) |
| 383 | } |
| 384 | |
| 385 | // TryExportCold takes a consistent snapshot without waiting for a writer. |
| 386 | // Import coordinators use this for historical sources owned by other processes. |
| 387 | // The locks remain held during copying; this is not a racy probe then export. |
| 388 | func (s *Service) TryExportCold(ctx context.Context, ref SessionRef, destination string) error { |
| 389 | if err := ref.validate(s.hostID); err != nil { |
| 390 | return err |
| 391 | } |
| 392 | if _, live := s.Runtime(ref); live { |
| 393 | return filelock.ErrHeld |
| 394 | } |
| 395 | filesystem, ok := s.persistence.(*FilesystemPersistence) |
| 396 | if !ok { |
| 397 | return errors.New("session: persistence does not support export") |
| 398 | } |
| 399 | return filesystem.exportColdMode(ctx, ref.SessionID, destination, true) |
| 400 | } |
| 401 | |
| 402 | // Import validates and atomically adopts a self-contained exported directory. |
| 403 | // The archive's immutable identity is retained; importing over an existing |
| 404 | // identity is refused rather than merging two histories. |
| 405 | func (s *Service) Import(ctx context.Context, source string) (SessionRef, error) { |
| 406 | return s.ImportWithHeader(ctx, source, CreateOptions{}) |
| 407 | } |
| 408 | |
| 409 | // ImportWithHeader atomically adopts a self-contained export and installs |
| 410 | // immutable Desktop ownership metadata before the target directory is |
| 411 | // published. Existing import callers remain headerless by passing zero options. |
| 412 | func (s *Service) ImportWithHeader(ctx context.Context, source string, options CreateOptions) (SessionRef, error) { |
| 413 | filesystem, ok := s.persistence.(*FilesystemPersistence) |
| 414 | if !ok { |
| 415 | return SessionRef{}, errors.New("session: persistence does not support import") |
| 416 | } |
| 417 | id, err := filesystem.importDirectory(ctx, source, options) |
| 418 | if err != nil { |
| 419 | return SessionRef{}, err |
| 420 | } |
| 421 | return SessionRef{HostID: s.hostID, SessionID: id}, nil |
| 422 | } |
| 423 | |
| 424 | func (p *FilesystemPersistence) importDirectory(ctx context.Context, source string, options CreateOptions) (string, error) { |
| 425 | if err := ctx.Err(); err != nil { |
| 426 | return "", err |
| 427 | } |
| 428 | source = filepath.Clean(strings.TrimSpace(source)) |
| 429 | if source == "." { |
| 430 | return "", errors.New("session: import source is required") |
| 431 | } |
| 432 | info, err := os.Lstat(source) |
| 433 | if err != nil { |
| 434 | return "", err |
| 435 | } |
| 436 | if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { |
| 437 | return "", errors.New("session: import source must be a real directory") |
| 438 | } |
| 439 | manifest, err := readManifest(filepath.Join(source, "manifest.json")) |
| 440 | if err != nil { |
| 441 | return "", fmt.Errorf("validate import manifest: %w", err) |
| 442 | } |
| 443 | if err := validateSessionID(manifest.SessionID); err != nil { |
| 444 | return "", err |
| 445 | } |
| 446 | if manifest.ContentRoot != ".content-v1" { |
| 447 | return "", errors.New("session: import is not a self-contained export") |
| 448 | } |
| 449 | targetID := manifest.SessionID |
| 450 | if strings.TrimSpace(options.SessionID) != "" { |
| 451 | targetID = strings.TrimSpace(options.SessionID) |
| 452 | } |
| 453 | if err := validateSessionID(targetID); err != nil { |
| 454 | return "", err |
| 455 | } |
| 456 | if err := os.MkdirAll(p.Root, 0o700); err != nil { |
| 457 | return "", err |
| 458 | } |
| 459 | target := filepath.Join(p.Root, targetID) |
| 460 | if _, err := os.Lstat(target); err == nil { |
| 461 | return "", fmt.Errorf("%w: %s", ErrSessionExists, targetID) |
| 462 | } else if !os.IsNotExist(err) { |
| 463 | return "", err |
| 464 | } |
| 465 | staging := filepath.Join(p.Root, "."+targetID+".import-"+randomID()) |
| 466 | if err := exportDirectory(ctx, source, staging); err != nil { |
| 467 | return "", err |
| 468 | } |
| 469 | published := false |
| 470 | defer func() { |
| 471 | if !published { |
| 472 | _ = os.RemoveAll(staging) |
| 473 | } |
| 474 | }() |
| 475 | if targetID != manifest.SessionID { |
| 476 | manifest.SessionID = targetID |
| 477 | if err := writeManifestFile(filepath.Join(staging, "manifest.json"), manifest); err != nil { |
| 478 | return "", err |
| 479 | } |
| 480 | // Storage generations are scoped to the manifest identity. The imported |
| 481 | // event prefix remains valid, but a remapped SessionID must publish a new |
| 482 | // generation before any recovery/query projection can be trusted. |
| 483 | if _, err := ensureStorageIdentity(staging, manifest); err != nil { |
| 484 | return "", err |
| 485 | } |
| 486 | } |
| 487 | if _, err := Replay(staging, nil); err != nil { |
| 488 | return "", fmt.Errorf("validate imported events: %w", err) |
| 489 | } |
| 490 | if options.SessionID == "" { |
| 491 | options.SessionID = targetID |
| 492 | } |
| 493 | header, err := headerForCreate(options) |
| 494 | if err != nil { |
| 495 | return "", err |
| 496 | } |
| 497 | if header != nil { |
| 498 | header.CreatedAt = manifest.CreatedAt |
| 499 | if err := writeSessionHeader(staging, *header); err != nil { |
| 500 | return "", err |
| 501 | } |
| 502 | } |
| 503 | if err := os.Rename(staging, target); err != nil { |
| 504 | return "", fmt.Errorf("publish imported session: %w", err) |
| 505 | } |
| 506 | published = true |
| 507 | return targetID, nil |
| 508 | } |
| 509 | |
| 510 | func (s *Service) Delete(ctx context.Context, ref SessionRef) error { |
| 511 | if err := ref.validate(s.hostID); err != nil { |
| 512 | return err |
| 513 | } |
| 514 | if runtime, ok := s.Runtime(ref); ok { |
| 515 | if err := s.Close(ctx, ref); err != nil { |
| 516 | return err |
| 517 | } |
| 518 | // Exact-instance removal above completed before filesystem deletion; |
| 519 | // delayed callbacks cannot remove a successor runtime. |
| 520 | _ = runtime |
| 521 | } |
| 522 | filesystem, ok := s.persistence.(*FilesystemPersistence) |
| 523 | if !ok { |
| 524 | return errors.New("session: persistence does not support delete") |
| 525 | } |
| 526 | s.query.invalidateCatalog(ref.SessionID) |
| 527 | return filesystem.Delete(ctx, ref.SessionID) |
| 528 | } |
| 529 |