返回 DeepSeek-Reasonix
lifecycle.go
根目录 / desktop / internal / workspacestate / lifecycle.go
1 package workspacestate
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "reflect"
13 "slices"
14 "strings"
15 "time"
16
17 "reasonix/internal/fileutil"
18 )
19
20 const (
21 Active = "active"
22 Archived = "archived"
23 Deleted = "deleted"
24 )
25
26 type SessionState struct {
27 Lifecycle string `json:"lifecycle"`
28 Generation uint64 `json:"generation"`
29 ArchivedAt int64 `json:"archivedAt,omitempty"`
30 extra map[string]json.RawMessage
31 }
32 type SourceMapping struct {
33 RetainedArtifacts []string `json:"retainedArtifacts,omitempty"`
34 SourceKey string `json:"sourceKey"`
35 Path string `json:"path"`
36 HeadID string `json:"headId,omitempty"`
37 Format string `json:"format"`
38 Fingerprint string `json:"fingerprint"`
39 SessionID string `json:"sessionId"`
40 WorkspaceID string `json:"workspaceId"`
41 extra map[string]json.RawMessage
42 }
43 type Presentation struct {
44 TopicID string `json:"topicId,omitempty"`
45 Title string `json:"title,omitempty"`
46 Pinned bool `json:"pinned,omitempty"`
47 SortOrder int `json:"sortOrder"`
48 extra map[string]json.RawMessage
49 }
50 type RecoveryEntry struct {
51 ID string `json:"id"`
52 SourceKey string `json:"sourceKey"`
53 Path string `json:"path,omitempty"`
54 HeadID string `json:"headId,omitempty"`
55 SessionID string `json:"sessionId,omitempty"`
56 WorkspaceID string `json:"workspaceId,omitempty"`
57 Scope string `json:"scope,omitempty"`
58 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
59 Format string `json:"format"`
60 Reason string `json:"reason"`
61 Status string `json:"status"`
62 Fingerprint string `json:"fingerprint,omitempty"`
63 extra map[string]json.RawMessage
64 }
65 type Operation struct {
66 RequestFingerprint string `json:"requestFingerprint,omitempty"`
67 Request json.RawMessage `json:"request,omitempty"`
68 Result json.RawMessage `json:"result,omitempty"`
69 ID string `json:"id"`
70 Kind string `json:"kind"`
71 Phase string `json:"phase"`
72 SessionIDs []string `json:"sessionIds"`
73 WorkspaceID string `json:"workspaceId,omitempty"`
74 Lifecycle string `json:"lifecycle"`
75 ExpectedGeneration uint64 `json:"expectedGeneration"`
76 ResultGeneration uint64 `json:"resultGeneration,omitempty"`
77 RecoveryEntryID string `json:"recoveryEntryId,omitempty"`
78 Mapping *SourceMapping `json:"mapping,omitempty"`
79 Presentation *Presentation `json:"presentation,omitempty"`
80 Dependencies []string `json:"dependencies,omitempty"`
81 extra map[string]json.RawMessage
82 }
83
84 type PurgeState uint8
85
86 const (
87 PurgeAbsent PurgeState = iota
88 PurgePrepared
89 PurgePreparedStale
90 PurgeTombstoned
91 PurgeContentRemoved
92 PurgeCommitted
93 PurgeInvalid
94 )
95
96 func validLifecycle(value string) bool {
97 return value == Active || value == Archived || value == Deleted
98 }
99 func setLifecycle(state *State, id, lifecycle string) {
100 current := state.SessionStates[id]
101 if lifecycle == Archived && current.Lifecycle != Archived {
102 current.ArchivedAt = time.Now().UnixMilli()
103 }
104 current.Lifecycle, current.Generation = lifecycle, state.Generation+1
105 state.SessionStates[id] = current
106 }
107 func validateLifecycleState(state State) error {
108 for id, item := range state.SessionStates {
109 if strings.TrimSpace(id) == "" || !validLifecycle(item.Lifecycle) {
110 return fmt.Errorf("%w: session lifecycle", ErrUnsupportedVersion)
111 }
112 }
113 for key, item := range state.SourceMappings {
114 if key == "" || item.SourceKey != key || item.SessionID == "" || item.Fingerprint == "" {
115 return errors.New("invalid source mapping")
116 }
117 }
118 for key, item := range state.PendingOperations {
119 if key == "" || item.ID != key || !validLifecycle(item.Lifecycle) {
120 return errors.New("invalid session operation")
121 }
122 switch item.Kind {
123 case "import", "archive-import", "archive", "purge", "restore", "command":
124 default:
125 return fmt.Errorf("%w: operation kind", ErrUnsupportedVersion)
126 }
127 switch item.Phase {
128 case "prepared", "content_ready", "committed":
129 case "tombstoned", "content_removed":
130 if item.Kind != "purge" {
131 return ErrUnsupportedVersion
132 }
133 default:
134 return fmt.Errorf("%w: operation phase", ErrUnsupportedVersion)
135 }
136 }
137 for key, item := range state.RecoveryEntries {
138 if key == "" || item.ID != key || item.SourceKey == "" {
139 return errors.New("invalid recovery entry")
140 }
141 switch item.Status {
142 case "pending", "failed", "restored":
143 default:
144 return fmt.Errorf("%w: recovery status", ErrUnsupportedVersion)
145 }
146 }
147 return nil
148 }
149
150 func (s *Store) SetLifecycle(ctx context.Context, ids []string, lifecycle string) error {
151 return s.mutate(ctx, func(state *State) error {
152 if !validLifecycle(lifecycle) {
153 return ErrUnsupportedVersion
154 }
155 for _, id := range ids {
156 if state.SessionStates[id].Lifecycle == Deleted {
157 return ErrMutationConflict
158 }
159 if _, ok := sessionOwner(*state, id); !ok {
160 return ErrSessionNotFound
161 }
162 if err := validateLifecycleSupersedesPreparedPurge(*state, id); err != nil {
163 return err
164 }
165 }
166 for _, id := range ids {
167 cancelPreparedPurge(state, id)
168 setLifecycle(state, id, lifecycle)
169 if lifecycle == Active {
170 owner, _ := sessionOwner(*state, id)
171 workspace := state.Workspaces[owner]
172 workspace.Visible = true
173 state.Workspaces[owner] = workspace
174 }
175 }
176 return nil
177 })
178 }
179
180 func (s *Store) BeginOperation(ctx context.Context, op Operation) error {
181 return s.mutate(ctx, func(state *State) error {
182 for _, id := range op.SessionIDs {
183 if state.SessionStates[id].Lifecycle == Deleted {
184 return ErrMutationConflict
185 }
186 }
187 if op.ID == "" || !validLifecycle(op.Lifecycle) {
188 return ErrMutationConflict
189 }
190 if old, ok := state.PendingOperations[op.ID]; ok {
191 if old.Kind != op.Kind || old.RecoveryEntryID != op.RecoveryEntryID || old.Lifecycle != op.Lifecycle || old.WorkspaceID != op.WorkspaceID || !slices.Equal(old.Dependencies, op.Dependencies) || (len(op.SessionIDs) != 0 && !slices.Equal(old.SessionIDs, op.SessionIDs)) {
192 return ErrMutationConflict
193 }
194 return nil
195 }
196 if op.ExpectedGeneration != 0 && op.ExpectedGeneration != state.Generation {
197 return ErrMutationConflict
198 }
199 // Bind child admission to the original command's observed state,
200 // not to a newer snapshot taken after content/ownership validation.
201 if split := strings.LastIndex(op.ID, "-"); split > 0 {
202 parent := state.PendingOperations[op.ID[:split]]
203 for _, id := range op.SessionIDs {
204 if parent.Kind == "command" && state.SessionStates[id].Generation > parent.ExpectedGeneration {
205 return ErrMutationConflict
206 }
207 }
208 }
209 op.ExpectedGeneration = state.Generation
210 op.Phase = "prepared"
211 if op.SessionIDs == nil {
212 op.SessionIDs = []string{}
213 }
214 state.PendingOperations[op.ID] = op
215 return nil
216 })
217 }
218
219 func (s *Store) ReserveOperationTargets(ctx context.Context, id string, ids []string, sources ...*SourceMapping) error {
220 return s.mutate(ctx, func(state *State) error {
221 op, ok := state.PendingOperations[id]
222 if !ok || len(ids) == 0 {
223 return ErrMutationConflict
224 }
225 if len(op.SessionIDs) != 0 && !slices.Equal(op.SessionIDs, ids) {
226 return ErrMutationConflict
227 }
228 if len(sources) > 0 && sources[0] != nil {
229 mapping := sources[0]
230 if op.Mapping != nil && (op.Mapping.SourceKey != mapping.SourceKey || op.Mapping.Fingerprint != mapping.Fingerprint || op.Mapping.SessionID != mapping.SessionID) {
231 return ErrMutationConflict
232 }
233 if op.Phase == "prepared" {
234 op.Mapping = mapping
235 }
236 }
237 op.SessionIDs = append([]string{}, ids...)
238 state.PendingOperations[id] = op
239 return nil
240 })
241 }
242
243 func (s *Store) PrepareOperationContent(ctx context.Context, id string, ids []string, mapping *SourceMapping, presentation *Presentation) error {
244 return s.mutate(ctx, func(state *State) error {
245 op, ok := state.PendingOperations[id]
246 if !ok {
247 return ErrMutationConflict
248 }
249 if op.Phase != "prepared" {
250 if !slices.Equal(op.SessionIDs, ids) || !reflect.DeepEqual(op.Mapping, mapping) || !reflect.DeepEqual(op.Presentation, presentation) {
251 return ErrMutationConflict
252 }
253 return nil
254 }
255 if len(op.SessionIDs) != 0 && !slices.Equal(op.SessionIDs, ids) {
256 return ErrMutationConflict
257 }
258 op.Phase, op.SessionIDs, op.Mapping, op.Presentation = "content_ready", append([]string{}, ids...), mapping, presentation
259 state.PendingOperations[id] = op
260 return nil
261 })
262 }
263
264 // CommitOperation publishes membership, lifecycle and provenance in one durable
265 // registry replacement. File publication must have been validated beforehand.
266 func (s *Store) CommitOperation(ctx context.Context, id string) error {
267 return s.mutate(ctx, func(state *State) error {
268 if op, ok := state.PendingOperations[id]; ok && op.Kind == "archive-import" {
269 return ErrMutationConflict
270 }
271 return commitOperation(state, id, map[string]bool{})
272 })
273 }
274
275 // CommitHistoricalArchive publishes a standalone legacy trash import without
276 // admitting arbitrary archive-import children intended for an atomic batch.
277 func (s *Store) CommitHistoricalArchive(ctx context.Context, id string, archivedAt ...int64) error {
278 return s.mutate(ctx, func(state *State) error {
279 op, ok := state.PendingOperations[id]
280 if !ok || op.Kind != "archive-import" || op.Lifecycle != Archived || op.Mapping == nil {
281 return ErrMutationConflict
282 }
283 if op.Phase == "committed" {
284 return nil
285 }
286 if err := commitOperation(state, id, map[string]bool{}); err != nil {
287 return err
288 }
289 // No proven archive timestamp is available for these old records.
290 for _, sessionID := range op.SessionIDs {
291 status := state.SessionStates[sessionID]
292 status.ArchivedAt = 0
293 if len(archivedAt) > 0 && archivedAt[0] > 0 {
294 status.ArchivedAt = archivedAt[0]
295 }
296 state.SessionStates[sessionID] = status
297 }
298 return nil
299 })
300 }
301
302 func commitOperation(state *State, id string, visiting map[string]bool) error {
303 if visiting[id] {
304 return ErrMutationConflict
305 }
306 visiting[id] = true
307 defer delete(visiting, id)
308 op, ok := state.PendingOperations[id]
309 if !ok {
310 return ErrMutationConflict
311 }
312 if op.Phase == "committed" {
313 return nil
314 }
315 if op.Phase != "content_ready" || len(op.SessionIDs) == 0 {
316 return ErrMutationConflict
317 }
318 for _, dependency := range op.Dependencies {
319 child, ok := state.PendingOperations[dependency]
320 if !ok || child.Kind != "archive-import" || child.Phase != "content_ready" {
321 return ErrMutationConflict
322 }
323 for _, target := range child.SessionIDs {
324 if !slices.Contains(op.SessionIDs, target) {
325 return ErrMutationConflict
326 }
327 }
328 if err := commitOperation(state, dependency, visiting); err != nil {
329 return err
330 }
331 }
332 for _, sessionID := range op.SessionIDs {
333 if err := validateLifecycleSupersedesPreparedPurge(*state, sessionID); err != nil {
334 return err
335 }
336 if state.SessionStates[sessionID].Lifecycle == Deleted {
337 return ErrMutationConflict
338 }
339 if current, exists := state.SessionStates[sessionID]; exists && current.Generation > op.ExpectedGeneration && current.Generation != state.Generation+1 {
340 return ErrMutationConflict
341 }
342 owner, attached := sessionOwner(*state, sessionID)
343 if attached && op.WorkspaceID != "" && owner != op.WorkspaceID {
344 return ErrMutationConflict
345 }
346 if !attached {
347 workspace, exists := state.Workspaces[op.WorkspaceID]
348 if !exists {
349 return ErrWorkspaceNotFound
350 }
351 workspace.SessionIDs = insertBefore(workspace.SessionIDs, sessionID, "")
352 // Source adoption replaces the imported source slot below. Publishing
353 // a canonical default first would incorrectly override that choice.
354 if op.Mapping == nil {
355 attachOrganizationSession(&workspace, sessionID, "")
356 mirrorOrganizationOrder(&workspace)
357 }
358 workspace.UpdatedAt = time.Now().UTC()
359 state.Workspaces[workspace.ID] = workspace
360 owner = workspace.ID
361 }
362 cancelPreparedPurge(state, sessionID)
363 setLifecycle(state, sessionID, op.Lifecycle)
364 if op.Lifecycle == Active {
365 workspace := state.Workspaces[owner]
366 workspace.Visible = true
367 state.Workspaces[owner] = workspace
368 }
369 if op.Presentation != nil {
370 state.Presentation[sessionID] = *op.Presentation
371 }
372 }
373 if err := commitSourceMapping(state, op.Mapping); err != nil {
374 return err
375 }
376 if op.RecoveryEntryID != "" {
377 entry, exists := state.RecoveryEntries[op.RecoveryEntryID]
378 if !exists {
379 return ErrMutationConflict
380 }
381 entry.Status, entry.SessionID = "restored", op.SessionIDs[0]
382 state.RecoveryEntries[entry.ID] = entry
383 }
384 op.Phase, op.ResultGeneration = "committed", state.Generation+1
385 state.PendingOperations[id] = op
386 return nil
387 }
388
389 func ClassifyPurge(state State, id string) PurgeState {
390 key := "purge-" + id
391 op, exists := state.PendingOperations[key]
392 if !exists {
393 return PurgeAbsent
394 }
395 status, known := state.SessionStates[id]
396 if !known || op.ID != key || op.Kind != "purge" || op.Lifecycle != Deleted || len(op.SessionIDs) != 1 || op.SessionIDs[0] != id {
397 return PurgeInvalid
398 }
399 switch op.Phase {
400 case "prepared":
401 if status.Lifecycle == Deleted {
402 return PurgeInvalid
403 }
404 if status.Lifecycle != Archived || status.Generation > op.ExpectedGeneration {
405 return PurgePreparedStale
406 }
407 return PurgePrepared
408 case "tombstoned":
409 if status.Lifecycle == Deleted {
410 return PurgeTombstoned
411 }
412 case "content_removed":
413 if status.Lifecycle == Deleted {
414 return PurgeContentRemoved
415 }
416 case "committed":
417 if status.Lifecycle == Deleted {
418 return PurgeCommitted
419 }
420 }
421 return PurgeInvalid
422 }
423
424 func validateLifecycleSupersedesPreparedPurge(state State, id string) error {
425 switch ClassifyPurge(state, id) {
426 case PurgeAbsent, PurgePrepared, PurgePreparedStale:
427 return nil
428 default:
429 return ErrMutationConflict
430 }
431 }
432
433 func cancelPreparedPurge(state *State, id string) {
434 key := "purge-" + id
435 if op, exists := state.PendingOperations[key]; exists && op.Kind == "purge" && op.Phase == "prepared" {
436 delete(state.PendingOperations, key)
437 }
438 }
439
440 func samePurgeIdentity(left, right Operation) bool {
441 return left.ID == right.ID && left.Kind == "purge" && right.Kind == "purge" && left.Lifecycle == right.Lifecycle &&
442 left.ExpectedGeneration == right.ExpectedGeneration && slices.Equal(left.SessionIDs, right.SessionIDs)
443 }
444
445 // BeginPurge atomically validates the archived generation, publishes the
446 // deletion tombstone and records the resumable purge operation.
447 func (s *Store) BeginPurge(ctx context.Context, id string, expected uint64) error {
448 return s.beginOrResumePurge(ctx, id, expected, nil)
449 }
450
451 // ResumePurge continues only the observed operation. It cannot recreate a
452 // deletion intent after a restore superseded that operation.
453 func (s *Store) ResumePurge(ctx context.Context, id string, observed Operation) error {
454 return s.beginOrResumePurge(ctx, id, observed.ExpectedGeneration, &observed)
455 }
456
457 // ResumePurgeForRequest keeps both the request snapshot and the observed
458 // transaction identity. Neither may be refreshed while waiting for locks.
459 func (s *Store) ResumePurgeForRequest(ctx context.Context, id string, expected uint64, observed Operation) error {
460 return s.beginOrResumePurge(ctx, id, expected, &observed)
461 }
462
463 func (s *Store) beginOrResumePurge(ctx context.Context, id string, expected uint64, observed *Operation) error {
464 stale := false
465 err := s.mutate(ctx, func(state *State) error {
466 key := "purge-" + id
467 current, exists := state.PendingOperations[key]
468 if observed != nil && (!exists || !samePurgeIdentity(current, *observed)) {
469 return fmt.Errorf("%w: observed purge was removed or replaced", ErrMutationConflict)
470 }
471 switch ClassifyPurge(*state, id) {
472 case PurgePreparedStale:
473 delete(state.PendingOperations, key)
474 stale = true
475 return nil
476 case PurgePrepared:
477 if observed == nil {
478 return ErrMutationConflict
479 }
480 status := state.SessionStates[id]
481 if status.Generation > expected {
482 return fmt.Errorf("%w: purge generation %d exceeds request %d", ErrMutationConflict, status.Generation, expected)
483 }
484 setLifecycle(state, id, Deleted)
485 current.Phase = "tombstoned"
486 state.PendingOperations[key] = current
487 return nil
488 case PurgeTombstoned, PurgeContentRemoved, PurgeCommitted:
489 return nil
490 case PurgeInvalid:
491 return fmt.Errorf("%w: inconsistent purge state", ErrMutationConflict)
492 case PurgeAbsent:
493 if observed != nil {
494 return ErrMutationConflict
495 }
496 status, known := state.SessionStates[id]
497 if !known || status.Lifecycle != Archived || status.Generation > expected {
498 return ErrMutationConflict
499 }
500 state.PendingOperations[key] = Operation{ID: key, Kind: "purge", Phase: "tombstoned", Lifecycle: Deleted, SessionIDs: []string{id}, ExpectedGeneration: status.Generation}
501 setLifecycle(state, id, Deleted)
502 return nil
503 default:
504 return ErrMutationConflict
505 }
506 })
507 if err != nil {
508 return err
509 }
510 if stale {
511 return fmt.Errorf("%w: stale purge preparation removed", ErrMutationConflict)
512 }
513 return nil
514 }
515
516 func (s *Store) AdvancePurge(ctx context.Context, id, phase string) error {
517 return s.mutate(ctx, func(state *State) error {
518 key := "purge-" + id
519 op, ok := state.PendingOperations[key]
520 if !ok || op.Kind != "purge" {
521 return ErrMutationConflict
522 }
523 if ClassifyPurge(*state, id) == PurgeInvalid {
524 return ErrMutationConflict
525 }
526 if op.Phase == "committed" || op.Phase == phase || op.Phase == "content_removed" {
527 return nil
528 }
529 if phase != "content_removed" || op.Phase != "tombstoned" {
530 return ErrMutationConflict
531 }
532 op.Phase = phase
533 state.PendingOperations[key] = op
534 return nil
535 })
536 }
537
538 func (s *Store) CompletePurge(ctx context.Context, id string) error {
539 return s.mutate(ctx, func(state *State) error {
540 key := "purge-" + id
541 op := state.PendingOperations[key]
542 switch ClassifyPurge(*state, id) {
543 case PurgeCommitted:
544 return nil
545 case PurgeContentRemoved:
546 default:
547 return ErrMutationConflict
548 }
549 for key, workspace := range state.Workspaces {
550 workspace.SessionIDs = remove(workspace.SessionIDs, id)
551 state.Workspaces[key] = workspace
552 }
553 delete(state.Presentation, id)
554 op.Phase, op.ResultGeneration = "committed", state.Generation+1
555 state.PendingOperations[key] = op
556 return nil
557 })
558 }
559
560 func (s *Store) RecordSource(ctx context.Context, mapping SourceMapping, presentation Presentation) error {
561 return s.mutate(ctx, func(state *State) error {
562 if old, ok := state.SourceMappings[mapping.SourceKey]; ok {
563 if old.SessionID != mapping.SessionID || old.Fingerprint != mapping.Fingerprint {
564 return ErrMutationConflict
565 }
566 return nil
567 }
568 state.SourceMappings[mapping.SourceKey] = mapping
569 adoptOrganizationSource(state, mapping)
570 if _, exists := state.Presentation[mapping.SessionID]; !exists {
571 state.Presentation[mapping.SessionID] = presentation
572 }
573 return nil
574 })
575 }
576
577 func (s *Store) UpdatePresentation(ctx context.Context, ids []string, title *string, pinned *bool) error {
578 return s.mutate(ctx, func(state *State) error {
579 for _, id := range ids {
580 if _, ok := sessionOwner(*state, id); !ok {
581 return ErrSessionNotFound
582 }
583 value, exists := state.Presentation[id]
584 if !exists {
585 value.SortOrder = -1
586 }
587 if title != nil {
588 value.Title = *title
589 }
590 if pinned != nil {
591 value.Pinned = *pinned
592 }
593 state.Presentation[id] = value
594 }
595 return nil
596 })
597 }
598
599 // EnsureSessionTopic publishes the initial display group without letting
600 // later tab rebuilds overwrite a user's persisted presentation.
601 func (s *Store) EnsureSessionTopic(ctx context.Context, id, topicID, title string) error {
602 if id == "" || topicID == "" {
603 return nil
604 }
605 return s.mutate(ctx, func(state *State) error {
606 if _, ok := sessionOwner(*state, id); !ok {
607 return ErrSessionNotFound
608 }
609 value := state.Presentation[id]
610 if value.TopicID != "" {
611 return nil
612 }
613 value.TopicID = topicID
614 if value.Title == "" {
615 value.Title = title
616 }
617 state.Presentation[id] = value
618 return nil
619 })
620 }
621
622 func (s *Store) RecordRecovery(ctx context.Context, entry RecoveryEntry) error {
623 return s.mutate(ctx, func(state *State) error {
624 if old, ok := state.RecoveryEntries[entry.ID]; ok {
625 if old.Status == "restored" && old.Fingerprint == entry.Fingerprint {
626 return nil
627 }
628 if strings.Contains(old.Reason, "conflict") && !strings.Contains(entry.Reason, "conflict") {
629 entry.Reason = old.Reason
630 }
631 entry.extra = old.extra
632 }
633 if entry.Status == "" {
634 entry.Status = "pending"
635 }
636 state.RecoveryEntries[entry.ID] = entry
637 return nil
638 })
639 }
640
641 // ReconcileDiscoveredSession rechecks ownership under the writer lock. A scan
642 // snapshot can predate a concurrent create, import, archive, or restore.
643 // Discovery must never classify those published/reserved IDs as orphans.
644 func (s *Store) ReconcileDiscoveredSession(ctx context.Context, entry RecoveryEntry, workspace *Workspace) error {
645 return s.mutate(ctx, func(state *State) error {
646 id := entry.SessionID
647 if state.SessionStates[id].Lifecycle == Deleted {
648 return nil
649 }
650 if id == "" {
651 return errors.New("discovery requires a session id")
652 }
653 if _, ok := sessionOwner(*state, id); ok {
654 return nil
655 }
656 if _, ok := state.PendingCreates[id]; ok {
657 return nil
658 }
659 for _, op := range state.PendingOperations {
660 if op.Phase != "committed" && slices.Contains(op.SessionIDs, id) {
661 return nil
662 }
663 }
664 if lifecycle, ok := state.SessionStates[id]; ok && lifecycle.Lifecycle != Active {
665 workspace = nil
666 entry.Reason = "historical_state_unknown"
667 }
668 if workspace == nil {
669 if old, ok := state.RecoveryEntries[entry.ID]; ok {
670 if old.Status == "restored" {
671 return nil
672 }
673 entry.extra = old.extra
674 }
675 state.RecoveryEntries[entry.ID] = entry
676 return nil
677 }
678 // A discovery snapshot can predate another process registering this
679 // directory. Resolve its owner under the writer lock, just like session
680 // ownership above, and preserve the owner's title and visibility.
681 workspaceID, found, err := ResolveWorkspaceID(*state, workspace.Root)
682 if err != nil {
683 return err
684 }
685 if !found {
686 workspaceID = workspace.ID
687 if existing, exists := state.Workspaces[workspaceID]; exists && existing.Root != workspace.Root {
688 return ErrMutationConflict
689 }
690 }
691 value, ok := state.Workspaces[workspaceID]
692 if !ok {
693 value = *workspace
694 state.WorkspaceIDs = append(state.WorkspaceIDs, value.ID)
695 }
696 value.SessionIDs = append(value.SessionIDs, id)
697 value.UpdatedAt = time.Now().UTC()
698 state.Workspaces[value.ID] = value
699 return nil
700 })
701 }
702
703 // backupV1 runs under the same cross-process lock as the schema publication.
704 // Content-addressed backups are immutable: a repeat never overwrites evidence.
705 func backupV1(path string) error {
706 body, err := os.ReadFile(path)
707 if os.IsNotExist(err) {
708 return nil
709 }
710 if err != nil {
711 return err
712 }
713 var header struct {
714 Version int `json:"version"`
715 }
716 if err := json.Unmarshal(body, &header); err != nil {
717 return err
718 }
719 if header.Version != 1 {
720 return nil
721 }
722 digest := sha256.Sum256(body)
723 backup := filepath.Join(filepath.Dir(path), "upgrade-backups", "workspace-v1-"+hex.EncodeToString(digest[:])+".json")
724 if old, err := os.ReadFile(backup); err == nil {
725 if sha256.Sum256(old) != digest {
726 return errors.New("workspace upgrade backup is corrupt")
727 }
728 return nil
729 } else if !os.IsNotExist(err) {
730 return err
731 }
732 if err := os.MkdirAll(filepath.Dir(backup), 0700); err != nil {
733 return err
734 }
735 return fileutil.AtomicWriteFileStrict(backup, body, 0600)
736 }
737
738 // unknownFields retains future, user-owned metadata during read/modify/write.
739 func unknownFields(body []byte, names ...string) (map[string]json.RawMessage, error) {
740 var fields map[string]json.RawMessage
741 if err := json.Unmarshal(body, &fields); err != nil {
742 return nil, err
743 }
744 for _, name := range names {
745 delete(fields, name)
746 }
747 return fields, nil
748 }
749
750 func commitSourceMapping(state *State, mapping *SourceMapping) error {
751 if mapping != nil {
752 mapping := *mapping
753 if old, exists := state.SourceMappings[mapping.SourceKey]; exists && (old.SessionID != mapping.SessionID || old.Fingerprint != mapping.Fingerprint) {
754 return ErrMutationConflict
755 }
756 state.SourceMappings[mapping.SourceKey] = mapping
757 adoptOrganizationSource(state, mapping)
758 }
759 return nil
760 }
761
761 lines GO