返回 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 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
529 lines GO