返回 DeepSeek-Reasonix
management.go
根目录 / internal / session / management.go
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 root, err := os.OpenRoot(p.Root)
360 if err != nil {
361 return err
362 }
363 defer root.Close()
364 if err := root.MkdirAll(".trash", 0o700); err != nil {
365 return err
366 }
367 tombstone := filepath.Join(".trash", sessionID+"-"+randomID())
368 if err := root.Rename(sessionID, tombstone); err != nil {
369 return err
370 }
371 _ = root.RemoveAll(filepath.Join(".query-cache", sessionID))
372 return root.RemoveAll(tombstone)
373 }
374
375 func (s *Service) Export(ctx context.Context, ref SessionRef, destination string) error {
376 if err := ref.validate(s.hostID); err != nil {
377 return err
378 }
379 if runtime, ok := s.Runtime(ref); ok {
380 return runtime.session.Export(ctx, destination)
381 }
382 filesystem, ok := s.persistence.(*FilesystemPersistence)
383 if !ok {
384 return errors.New("session: persistence does not support export")
385 }
386 return filesystem.exportCold(ctx, ref.SessionID, destination)
387 }
388
389 // TryExportCold takes a consistent snapshot without waiting for a writer.
390 // Import coordinators use this for historical sources owned by other processes.
391 // The locks remain held during copying; this is not a racy probe then export.
392 func (s *Service) TryExportCold(ctx context.Context, ref SessionRef, destination string) error {
393 if err := ref.validate(s.hostID); err != nil {
394 return err
395 }
396 if _, live := s.Runtime(ref); live {
397 return filelock.ErrHeld
398 }
399 filesystem, ok := s.persistence.(*FilesystemPersistence)
400 if !ok {
401 return errors.New("session: persistence does not support export")
402 }
403 return filesystem.exportColdMode(ctx, ref.SessionID, destination, true)
404 }
405
406 // Import validates and atomically adopts a self-contained exported directory.
407 // The archive's immutable identity is retained; importing over an existing
408 // identity is refused rather than merging two histories.
409 func (s *Service) Import(ctx context.Context, source string) (SessionRef, error) {
410 return s.ImportWithHeader(ctx, source, CreateOptions{})
411 }
412
413 // ImportWithHeader atomically adopts a self-contained export and installs
414 // immutable Desktop ownership metadata before the target directory is
415 // published. Existing import callers remain headerless by passing zero options.
416 func (s *Service) ImportWithHeader(ctx context.Context, source string, options CreateOptions) (SessionRef, error) {
417 filesystem, ok := s.persistence.(*FilesystemPersistence)
418 if !ok {
419 return SessionRef{}, errors.New("session: persistence does not support import")
420 }
421 id, err := filesystem.importDirectory(ctx, source, options)
422 if err != nil {
423 return SessionRef{}, err
424 }
425 return SessionRef{HostID: s.hostID, SessionID: id}, nil
426 }
427
428 func (p *FilesystemPersistence) importDirectory(ctx context.Context, source string, options CreateOptions) (string, error) {
429 if err := ctx.Err(); err != nil {
430 return "", err
431 }
432 source = filepath.Clean(strings.TrimSpace(source))
433 if source == "." {
434 return "", errors.New("session: import source is required")
435 }
436 info, err := os.Lstat(source)
437 if err != nil {
438 return "", err
439 }
440 if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
441 return "", errors.New("session: import source must be a real directory")
442 }
443 manifest, err := readManifest(filepath.Join(source, "manifest.json"))
444 if err != nil {
445 return "", fmt.Errorf("validate import manifest: %w", err)
446 }
447 if err := validateSessionID(manifest.SessionID); err != nil {
448 return "", err
449 }
450 if manifest.ContentRoot != ".content-v1" {
451 return "", errors.New("session: import is not a self-contained export")
452 }
453 targetID := manifest.SessionID
454 if strings.TrimSpace(options.SessionID) != "" {
455 targetID = strings.TrimSpace(options.SessionID)
456 }
457 if err := validateSessionID(targetID); err != nil {
458 return "", err
459 }
460 if err := os.MkdirAll(p.Root, 0o700); err != nil {
461 return "", err
462 }
463 target := filepath.Join(p.Root, targetID)
464 if _, err := os.Lstat(target); err == nil {
465 return "", fmt.Errorf("%w: %s", ErrSessionExists, targetID)
466 } else if !os.IsNotExist(err) {
467 return "", err
468 }
469 staging := filepath.Join(p.Root, "."+targetID+".import-"+randomID())
470 if err := exportDirectory(ctx, source, staging); err != nil {
471 return "", err
472 }
473 published := false
474 defer func() {
475 if !published {
476 _ = os.RemoveAll(staging)
477 }
478 }()
479 if targetID != manifest.SessionID {
480 manifest.SessionID = targetID
481 if err := writeManifestFile(filepath.Join(staging, "manifest.json"), manifest); err != nil {
482 return "", err
483 }
484 }
485 if _, err := Replay(staging, nil); err != nil {
486 return "", fmt.Errorf("validate imported events: %w", err)
487 }
488 // Pre-recovery archives (Desktop 1.38.8) need an identity before history reads.
489 // Keep valid identities and create missing/remapped generations in staging;
490 // the source archive remains untouched and no runtime is needed.
491 if _, err := ensureStorageIdentity(staging, manifest); err != nil {
492 return "", err
493 }
494 if options.SessionID == "" {
495 options.SessionID = targetID
496 }
497 header, err := headerForCreate(options)
498 if err != nil {
499 return "", err
500 }
501 if header != nil {
502 header.CreatedAt = manifest.CreatedAt
503 if err := writeSessionHeader(staging, *header); err != nil {
504 return "", err
505 }
506 }
507 if err := os.Rename(staging, target); err != nil {
508 return "", fmt.Errorf("publish imported session: %w", err)
509 }
510 published = true
511 return targetID, nil
512 }
513
514 func (s *Service) Delete(ctx context.Context, ref SessionRef) error {
515 if err := ref.validate(s.hostID); err != nil {
516 return err
517 }
518 if runtime, ok := s.Runtime(ref); ok {
519 if err := s.Close(ctx, ref); err != nil {
520 return err
521 }
522 // Exact-instance removal above completed before filesystem deletion;
523 // delayed callbacks cannot remove a successor runtime.
524 _ = runtime
525 }
526 filesystem, ok := s.persistence.(*FilesystemPersistence)
527 if !ok {
528 return errors.New("session: persistence does not support delete")
529 }
530 s.query.invalidateCatalog(ref.SessionID)
531 return filesystem.Delete(ctx, ref.SessionID)
532 }
533
533 lines GO