返回 DeepSeek-Reasonix
store.go
1 package sessioninbox
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "time"
12
13 filelock "reasonix/internal/identitylock"
14 "reasonix/internal/store"
15 )
16
17 const (
18 manifestName = "manifest.json"
19 blobsDirName = "blobs"
20 quarantineName = "quarantine"
21 blobSuffix = ".json"
22 diskLockName = "transaction.lock"
23 diskLockWait = 5 * time.Second
24 maxManifestBytes = 8 << 20
25 )
26
27 // Store is the transactional durable inbox for one session path.
28 // Disk I/O runs under store.mu only; callers must not hold Controller locks.
29 type Store struct {
30 mu sync.Mutex
31 dir string
32 session string // session transcript path
33 runID string
34 limits Limits
35 man *manifest
36 readonly bool
37 closed bool
38 // listeners receive revision bumps after durable commits (non-blocking).
39 listeners []func(InboxSnapshot)
40 }
41
42 // Open binds a Store to the session's inbox directory. The directory is created
43 // for its transaction lock; body blobs remain lazy. Cross-process recovery
44 // marks uncertain items and pauses.
45 func Open(sessionPath string, limits Limits) (*Store, error) {
46 sessionPath = strings.TrimSpace(sessionPath)
47 if sessionPath == "" {
48 return nil, fmt.Errorf("sessioninbox: empty session path")
49 }
50 dir := store.SessionInboxDir(sessionPath)
51 s := &Store{
52 dir: dir,
53 session: sessionPath,
54 runID: ProcessRunID(),
55 limits: limits.withDefaults(),
56 man: emptyManifest(ProcessRunID()),
57 }
58 if err := s.loadOrInit(); err != nil {
59 return nil, err
60 }
61 return s, nil
62 }
63
64 // Dir returns the on-disk inbox directory.
65 func (s *Store) Dir() string {
66 if s == nil {
67 return ""
68 }
69 s.mu.Lock()
70 defer s.mu.Unlock()
71 return s.dir
72 }
73
74 // SessionPath returns the bound session transcript path.
75 func (s *Store) SessionPath() string {
76 if s == nil {
77 return ""
78 }
79 s.mu.Lock()
80 defer s.mu.Unlock()
81 return s.session
82 }
83
84 // Rebind moves the store to a new session path without copying future work
85 // (used after rename migration that already relocated the directory).
86 func (s *Store) Rebind(sessionPath string) error {
87 if s == nil {
88 return ErrClosed
89 }
90 sessionPath = strings.TrimSpace(sessionPath)
91 if sessionPath == "" {
92 return fmt.Errorf("sessioninbox: empty session path")
93 }
94 s.mu.Lock()
95 defer s.mu.Unlock()
96 if s.closed {
97 return ErrClosed
98 }
99 s.session = sessionPath
100 s.dir = store.SessionInboxDir(sessionPath)
101 release, err := s.beginDiskTransactionLocked()
102 if err != nil {
103 return err
104 }
105 release()
106 return nil
107 }
108
109 // Close seals the store. Further mutations fail with ErrClosed.
110 func (s *Store) Close() {
111 if s == nil {
112 return
113 }
114 s.mu.Lock()
115 s.closed = true
116 s.mu.Unlock()
117 }
118
119 // OnChange registers a non-blocking snapshot listener.
120 func (s *Store) OnChange(fn func(InboxSnapshot)) {
121 if s == nil || fn == nil {
122 return
123 }
124 s.mu.Lock()
125 s.listeners = append(s.listeners, fn)
126 s.mu.Unlock()
127 }
128
129 func (s *Store) loadOrInit() error {
130 s.mu.Lock()
131 defer s.mu.Unlock()
132 release, err := s.beginDiskTransactionLocked()
133 if err != nil {
134 return err
135 }
136 release()
137 return nil
138 }
139
140 // beginDiskTransactionLocked serializes every manifest read/modify/write with
141 // other Store instances and processes, then refreshes the in-memory snapshot.
142 // The caller must hold s.mu and call the returned release function.
143 func (s *Store) beginDiskTransactionLocked() (func(), error) {
144 if err := ensurePrivateDir(s.dir); err != nil {
145 return nil, fmt.Errorf("sessioninbox: create inbox directory: %w", err)
146 }
147 ctx, cancel := context.WithTimeout(context.Background(), diskLockWait)
148 defer cancel()
149 release, err := filelock.Acquire(ctx, filepath.Join(s.dir, diskLockName))
150 if err != nil {
151 return nil, fmt.Errorf("sessioninbox: acquire disk lock: %w", err)
152 }
153 if err := s.loadOrInitLocked(); err != nil {
154 release()
155 return nil, err
156 }
157 return release, nil
158 }
159
160 func (s *Store) loadOrInitLocked() error {
161 path := filepath.Join(s.dir, manifestName)
162 data, err := readRegularFile(path, maxManifestBytes)
163 if errors.Is(err, os.ErrNotExist) {
164 s.man = emptyManifest(s.runID)
165 s.readonly = false
166 return nil
167 }
168 if err != nil {
169 return fmt.Errorf("sessioninbox: read manifest: %w", err)
170 }
171 man, err := decodeManifest(data)
172 if err != nil {
173 // Corrupt manifest → quarantine, salvage orphan blobs as uncertain
174 // items, pause for user inspection. Never present "0 recovered".
175 _ = s.quarantineFileLocked(path, "manifest-corrupt")
176 salvaged := s.salvageOrphanBlobsLocked()
177 s.man = emptyManifest(s.runID)
178 s.man.Paused = true
179 s.man.Recovered = true
180 s.man.RecoveredN = len(salvaged)
181 s.man.Items = salvaged
182 return s.commitManifestLocked(s.man)
183 }
184 if man.SchemaVersion > SchemaVersion {
185 s.man = man
186 s.readonly = true
187 s.man.Paused = true
188 return nil
189 }
190 migrated := man.SchemaVersion < SchemaVersion
191 if migrated {
192 for key, id := range man.Idempotency {
193 if man.IdempotencyHashes[key] != "" {
194 continue
195 }
196 meta, ok := man.item(id)
197 if !ok {
198 return fmt.Errorf("sessioninbox: migrate idempotency target: %w", ErrNotFound)
199 }
200 env, err := s.readBlobLocked(blobNameFor(meta), meta.Checksum)
201 if err != nil {
202 return fmt.Errorf("sessioninbox: migrate idempotency body: %w", err)
203 }
204 hash, err := idempotencyRequestHash(env)
205 if err != nil {
206 return fmt.Errorf("sessioninbox: migrate idempotency hash: %w", err)
207 }
208 man.IdempotencyHashes[key] = hash
209 }
210 man.SchemaVersion = SchemaVersion
211 }
212 // Cross-process recovery: another run left in-flight items.
213 recovered := 0
214 if man.RunID != "" && man.RunID != s.runID {
215 for i := range man.Items {
216 switch man.Items[i].State {
217 case StateRunning, StateSteerAccepted, StateSteerConsumed:
218 man.Items[i].State = StateUncertain
219 man.Items[i].UpdatedAt = time.Now().UTC()
220 recovered++
221 case StateQueued, StateBlocked, StateUncertain:
222 recovered++
223 }
224 }
225 if recovered > 0 || len(man.Items) > 0 {
226 man.Paused = true
227 man.Recovered = true
228 man.RecoveredN = recovered
229 }
230 }
231 man.RunID = s.runID
232 s.man = man
233 s.readonly = false
234 if recovered > 0 || migrated {
235 return s.commitManifestLocked(man)
236 }
237 // GC orphan blobs without holding callers longer than needed.
238 s.gcOrphansLocked()
239 return nil
240 }
241
242 // Snapshot returns a copy of current metadata.
243 func (s *Store) Snapshot() InboxSnapshot {
244 if s == nil {
245 return InboxSnapshot{}
246 }
247 s.mu.Lock()
248 defer s.mu.Unlock()
249 if release, err := s.beginDiskTransactionLocked(); err == nil {
250 release()
251 }
252 return s.snapshotLocked()
253 }
254
255 // CachedSnapshot returns the Store's current in-memory metadata without taking
256 // the cross-process disk lock. It is for owner-local admission decisions that
257 // must not add disk-lock latency; Snapshot remains the authoritative refresh.
258 func (s *Store) CachedSnapshot() InboxSnapshot {
259 if s == nil {
260 return InboxSnapshot{}
261 }
262 s.mu.Lock()
263 defer s.mu.Unlock()
264 return s.snapshotLocked()
265 }
266
267 // TryFreshSnapshot reads current metadata from disk without waiting for either
268 // the Store mutex or the cross-process transaction lock. It does not perform
269 // recovery, migration, cleanup, or any other durable mutation. Callers making
270 // latency-sensitive admission decisions should treat any error conservatively.
271 func (s *Store) TryFreshSnapshot() (InboxSnapshot, error) {
272 if s == nil {
273 return InboxSnapshot{}, ErrClosed
274 }
275 if !s.mu.TryLock() {
276 return InboxSnapshot{}, ErrSnapshotBusy
277 }
278 defer s.mu.Unlock()
279 if s.closed {
280 return InboxSnapshot{}, ErrClosed
281 }
282 if err := validatePrivateDir(s.dir); err != nil {
283 return InboxSnapshot{}, fmt.Errorf("sessioninbox: validate inbox directory: %w", err)
284 }
285 release, err := filelock.TryAcquire(filepath.Join(s.dir, diskLockName))
286 if err != nil {
287 if errors.Is(err, filelock.ErrHeld) {
288 return InboxSnapshot{}, ErrSnapshotBusy
289 }
290 return InboxSnapshot{}, fmt.Errorf("sessioninbox: acquire disk lock: %w", err)
291 }
292 defer release()
293 data, err := readRegularFile(filepath.Join(s.dir, manifestName), maxManifestBytes)
294 if errors.Is(err, os.ErrNotExist) {
295 return s.snapshotLocked(), nil
296 }
297 if err != nil {
298 return InboxSnapshot{}, fmt.Errorf("sessioninbox: read manifest: %w", err)
299 }
300 man, err := decodeManifest(data)
301 if err != nil {
302 return InboxSnapshot{}, fmt.Errorf("sessioninbox: decode manifest: %w", err)
303 }
304 return s.snapshotFromManifestLocked(man, man.SchemaVersion > SchemaVersion), nil
305 }
306
307 func (s *Store) snapshotLocked() InboxSnapshot {
308 m := s.man
309 if m == nil {
310 m = emptyManifest(s.runID)
311 }
312 return s.snapshotFromManifestLocked(m, s.readonly)
313 }
314
315 func (s *Store) snapshotFromManifestLocked(m *manifest, readonly bool) InboxSnapshot {
316 items := append([]InboxItemMeta(nil), m.Items...)
317 return InboxSnapshot{
318 SchemaVersion: m.SchemaVersion,
319 Revision: m.Revision,
320 Paused: m.Paused,
321 Recovered: m.Recovered,
322 RecoveredN: m.RecoveredN,
323 Readonly: readonly,
324 RunID: m.RunID,
325 SessionPath: s.session,
326 Items: items,
327 Capacity: Capacity{
328 Items: len(items),
329 MaxItems: s.limits.MaxItems,
330 Bytes: m.totalBytes(),
331 MaxBytes: s.limits.MaxTotalBytes,
332 MaxItemBytes: s.limits.MaxItemBytes,
333 },
334 }
335 }
336
337 // Enqueue durably appends an item. Only returns a receipt after blob+manifest
338 // commit succeed. Idempotent keys return the original item.
339 func (s *Store) Enqueue(req EnqueueRequest) (InboxReceipt, error) {
340 if s == nil {
341 return InboxReceipt{}, ErrClosed
342 }
343 env := completeEnqueueEnvelope(req.Envelope)
344 hasInvocation := env.Invocation != nil || len(env.Invocations) > 0
345 if strings.TrimSpace(env.SubmitText) == "" && strings.TrimSpace(env.DisplayText) == "" && strings.TrimSpace(env.RawText) == "" && !hasInvocation {
346 return InboxReceipt{}, ErrEmpty
347 }
348 intent := req.Intent
349 if intent != IntentSteer {
350 intent = IntentFollowup
351 }
352 idem := strings.TrimSpace(firstNonEmpty(req.Idempotency, env.Idempotency))
353 if idem != "" && !validIdempotencyKey(idem) {
354 return InboxReceipt{}, fmt.Errorf("sessioninbox: invalid idempotency key")
355 }
356 source := strings.TrimSpace(firstNonEmpty(req.Source, env.Source))
357
358 blobBytes, checksum, byteSize, err := encodeEnvelope(env)
359 if err != nil {
360 return InboxReceipt{}, err
361 }
362 requestHash, err := idempotencyRequestHash(env)
363 if err != nil {
364 return InboxReceipt{}, err
365 }
366
367 s.mu.Lock()
368 defer s.mu.Unlock()
369 release, err := s.beginDiskTransactionLocked()
370 if err != nil {
371 return InboxReceipt{}, err
372 }
373 defer release()
374 if s.closed {
375 return InboxReceipt{}, ErrClosed
376 }
377 if s.readonly {
378 return InboxReceipt{}, ErrSchemaReadonly
379 }
380 if receipt, found, err := s.idempotentReceiptLocked(idem, requestHash); err != nil || found {
381 return receipt, err
382 }
383 if byteSize > s.limits.MaxItemBytes {
384 return InboxReceipt{}, ErrItemTooLarge
385 }
386 if len(s.man.Items) >= s.limits.MaxItems {
387 return InboxReceipt{}, ErrCapacityItems
388 }
389 if s.man.totalBytes()+byteSize > s.limits.MaxTotalBytes {
390 return InboxReceipt{}, ErrCapacityBytes
391 }
392
393 id := newRandomID()
394 blobName := id
395 now := time.Now().UTC()
396 meta := InboxItemMeta{
397 ID: id,
398 SessionID: firstNonEmpty(req.SessionID, agentBranchID(s.session)),
399 Intent: intent,
400 State: StateQueued,
401 Revision: s.man.Revision + 1,
402 BlobName: blobName,
403 Source: source,
404 CreatedAt: now,
405 UpdatedAt: now,
406 Preview: PreviewText(env.DisplayText, DefaultPreviewRunes),
407 ByteSize: byteSize,
408 Checksum: checksum,
409 Idempotency: idem,
410 Refs: refSummaries(env.Refs),
411 RunID: s.runID,
412 }
413
414 // Transaction: write blob → commit manifest → receipt.
415 if err := s.writeBlobLocked(blobName, blobBytes); err != nil {
416 return InboxReceipt{}, err
417 }
418 next := s.man.clone()
419 next.Items = append(next.Items, meta)
420 bindIdempotency(next, idem, id, requestHash)
421 if err := s.commitManifestLocked(next); err != nil {
422 s.removeBlobLocked(blobName)
423 return InboxReceipt{}, err
424 }
425 snap := s.snapshotLocked()
426 s.notifyLocked(snap)
427 return InboxReceipt{
428 ItemID: id,
429 Disposition: DispositionQueuedFollowup,
430 Position: len(next.Items),
431 Paused: next.Paused,
432 Capacity: snap.Capacity,
433 }, nil
434 }
435
436 // ReadItem loads a full PromptEnvelope by ID.
437 func (s *Store) ReadItem(id string) (InboxItemMeta, PromptEnvelope, error) {
438 if s == nil {
439 return InboxItemMeta{}, PromptEnvelope{}, ErrClosed
440 }
441 id = strings.TrimSpace(id)
442 s.mu.Lock()
443 defer s.mu.Unlock()
444 release, err := s.beginDiskTransactionLocked()
445 if err != nil {
446 return InboxItemMeta{}, PromptEnvelope{}, err
447 }
448 defer release()
449 if s.closed {
450 return InboxItemMeta{}, PromptEnvelope{}, ErrClosed
451 }
452 meta, ok := s.man.item(id)
453 if !ok {
454 return InboxItemMeta{}, PromptEnvelope{}, ErrNotFound
455 }
456 env, err := s.readBlobLocked(blobNameFor(meta), meta.Checksum)
457 if err != nil {
458 return meta, PromptEnvelope{}, err
459 }
460 return meta, env, nil
461 }
462
463 // UpdateItem writes a new immutable blob, switches the manifest pointer, then
464 // deletes the old blob. Pre-commit crashes leave only a GC-able orphan; after
465 // commit the checksum always points at the new body.
466 func (s *Store) UpdateItem(id string, env PromptEnvelope) (InboxItemMeta, error) {
467 return s.UpdateItemWithIdempotency(id, env, "", PromptEnvelope{})
468 }
469
470 // UpdateItemWithIdempotency atomically updates an item and optionally binds an
471 // additional client idempotency key to it. aliasEnvelope is the original client
472 // request, not the merged body, so collect-mode redelivery remains deduplicated.
473 func (s *Store) UpdateItemWithIdempotency(id string, env PromptEnvelope, alias string, aliasEnvelope PromptEnvelope) (InboxItemMeta, error) {
474 if s == nil {
475 return InboxItemMeta{}, ErrClosed
476 }
477 id = strings.TrimSpace(id)
478 alias = strings.TrimSpace(alias)
479 if alias != "" && !validIdempotencyKey(alias) {
480 return InboxItemMeta{}, fmt.Errorf("sessioninbox: invalid idempotency key")
481 }
482 env = normalizeEnvelope(env)
483 if strings.TrimSpace(env.SubmitText) == "" && env.Invocation == nil && len(env.Invocations) == 0 {
484 return InboxItemMeta{}, ErrEmpty
485 }
486 blobBytes, checksum, byteSize, err := encodeEnvelope(env)
487 if err != nil {
488 return InboxItemMeta{}, err
489 }
490 aliasHash := ""
491 if alias != "" {
492 aliasEnvelope = completeEnqueueEnvelope(aliasEnvelope)
493 aliasHash, err = idempotencyRequestHash(aliasEnvelope)
494 if err != nil {
495 return InboxItemMeta{}, err
496 }
497 }
498 s.mu.Lock()
499 defer s.mu.Unlock()
500 release, err := s.beginDiskTransactionLocked()
501 if err != nil {
502 return InboxItemMeta{}, err
503 }
504 defer release()
505 if err := s.mutableLocked(); err != nil {
506 return InboxItemMeta{}, err
507 }
508 meta, ok := s.man.item(id)
509 if !ok {
510 return InboxItemMeta{}, ErrNotFound
511 }
512 if !isPendingState(meta.State) {
513 return InboxItemMeta{}, ErrInvalidState
514 }
515 replayed, err := s.idempotentAliasReplayLocked(alias, aliasHash, id)
516 if err != nil {
517 return InboxItemMeta{}, err
518 }
519 if replayed {
520 return meta, nil
521 }
522 if byteSize > s.limits.MaxItemBytes {
523 return InboxItemMeta{}, ErrItemTooLarge
524 }
525 delta := byteSize - meta.ByteSize
526 if s.man.totalBytes()+delta > s.limits.MaxTotalBytes {
527 return InboxItemMeta{}, ErrCapacityBytes
528 }
529 oldBlob := blobNameFor(meta)
530 newBlob := id + "." + newRandomID()
531 if err := s.writeBlobLocked(newBlob, blobBytes); err != nil {
532 return InboxItemMeta{}, err
533 }
534 next := s.man.clone()
535 i := next.indexOf(id)
536 next.Items[i].BlobName = newBlob
537 next.Items[i].ByteSize = byteSize
538 next.Items[i].Checksum = checksum
539 next.Items[i].Preview = PreviewText(env.DisplayText, DefaultPreviewRunes)
540 next.Items[i].Refs = refSummaries(env.Refs)
541 next.Items[i].UpdatedAt = time.Now().UTC()
542 next.Items[i].Revision = next.Revision + 1
543 bindIdempotency(next, alias, id, aliasHash)
544 if next.Items[i].State == StateBlocked {
545 next.Items[i].State = StateQueued
546 next.Items[i].BlockReason = ""
547 }
548 if err := s.commitManifestLocked(next); err != nil {
549 s.removeBlobLocked(newBlob)
550 return InboxItemMeta{}, err
551 }
552 if oldBlob != newBlob {
553 s.removeBlobLocked(oldBlob)
554 }
555 updated := next.Items[i]
556 s.notifyLocked(s.snapshotLocked())
557 return updated, nil
558 }
559
560 func (s *Store) removeBlobLocked(blobName string) {
561 if err := validatePrivateDir(filepath.Join(s.dir, blobsDirName)); err != nil {
562 return
563 }
564 path, err := s.blobPath(blobName)
565 if err == nil {
566 _ = os.Remove(path)
567 }
568 }
569
569 lines GO