返回 DeepSeek-Reasonix
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 func validLifecycle(value string) bool {
85 return value == Active || value == Archived || value == Deleted
86 }
87 func setLifecycle(state *State, id, lifecycle string) {
88 current := state.SessionStates[id]
89 if lifecycle == Archived && current.Lifecycle != Archived {
90 current.ArchivedAt = time.Now().UnixMilli()
91 }
92 current.Lifecycle, current.Generation = lifecycle, state.Generation+1
93 state.SessionStates[id] = current
94 }
95 func validateLifecycleState(state State) error {
96 for id, item := range state.SessionStates {
97 if strings.TrimSpace(id) == "" || !validLifecycle(item.Lifecycle) {
98 return fmt.Errorf("%w: session lifecycle", ErrUnsupportedVersion)
99 }
100 }
101 for key, item := range state.SourceMappings {
102 if key == "" || item.SourceKey != key || item.SessionID == "" || item.Fingerprint == "" {
103 return errors.New("invalid source mapping")
104 }
105 }
106 for key, item := range state.PendingOperations {
107 if key == "" || item.ID != key || !validLifecycle(item.Lifecycle) {
108 return errors.New("invalid session operation")
109 }
110 switch item.Kind {
111 case "import", "archive-import", "archive", "purge", "restore", "command":
112 default:
113 return fmt.Errorf("%w: operation kind", ErrUnsupportedVersion)
114 }
115 switch item.Phase {
116 case "prepared", "content_ready", "committed":
117 case "tombstoned", "content_removed":
118 if item.Kind != "purge" {
119 return ErrUnsupportedVersion
120 }
121 default:
122 return fmt.Errorf("%w: operation phase", ErrUnsupportedVersion)
123 }
124 }
125 for key, item := range state.RecoveryEntries {
126 if key == "" || item.ID != key || item.SourceKey == "" {
127 return errors.New("invalid recovery entry")
128 }
129 switch item.Status {
130 case "pending", "failed", "restored":
131 default:
132 return fmt.Errorf("%w: recovery status", ErrUnsupportedVersion)
133 }
134 }
135 return nil
136 }
137
138 func (s *Store) SetLifecycle(ctx context.Context, ids []string, lifecycle string) error {
139 return s.mutate(ctx, func(state *State) error {
140 if !validLifecycle(lifecycle) {
141 return ErrUnsupportedVersion
142 }
143 for _, id := range ids {
144 if state.SessionStates[id].Lifecycle == Deleted {
145 return ErrMutationConflict
146 }
147 if _, ok := sessionOwner(*state, id); !ok {
148 return ErrSessionNotFound
149 }
150 }
151 for _, id := range ids {
152 setLifecycle(state, id, lifecycle)
153 if lifecycle == Active {
154 owner, _ := sessionOwner(*state, id)
155 workspace := state.Workspaces[owner]
156 workspace.Visible = true
157 state.Workspaces[owner] = workspace
158 }
159 }
160 return nil
161 })
162 }
163
164 func (s *Store) BeginOperation(ctx context.Context, op Operation) error {
165 return s.mutate(ctx, func(state *State) error {
166 for _, id := range op.SessionIDs {
167 if state.SessionStates[id].Lifecycle == Deleted {
168 return ErrMutationConflict
169 }
170 }
171 if op.ID == "" || !validLifecycle(op.Lifecycle) {
172 return ErrMutationConflict
173 }
174 if old, ok := state.PendingOperations[op.ID]; ok {
175 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)) {
176 return ErrMutationConflict
177 }
178 return nil
179 }
180 if op.ExpectedGeneration != 0 && op.ExpectedGeneration != state.Generation {
181 return ErrMutationConflict
182 }
183 // Bind child admission to the original command's observed state,
184 // not to a newer snapshot taken after content/ownership validation.
185 if split := strings.LastIndex(op.ID, "-"); split > 0 {
186 parent := state.PendingOperations[op.ID[:split]]
187 for _, id := range op.SessionIDs {
188 if parent.Kind == "command" && state.SessionStates[id].Generation > parent.ExpectedGeneration {
189 return ErrMutationConflict
190 }
191 }
192 }
193 op.ExpectedGeneration = state.Generation
194 op.Phase = "prepared"
195 if op.SessionIDs == nil {
196 op.SessionIDs = []string{}
197 }
198 state.PendingOperations[op.ID] = op
199 return nil
200 })
201 }
202
203 func (s *Store) ReserveOperationTargets(ctx context.Context, id string, ids []string, sources ...*SourceMapping) error {
204 return s.mutate(ctx, func(state *State) error {
205 op, ok := state.PendingOperations[id]
206 if !ok || len(ids) == 0 {
207 return ErrMutationConflict
208 }
209 if len(op.SessionIDs) != 0 && !slices.Equal(op.SessionIDs, ids) {
210 return ErrMutationConflict
211 }
212 if len(sources) > 0 && sources[0] != nil {
213 mapping := sources[0]
214 if op.Mapping != nil && (op.Mapping.SourceKey != mapping.SourceKey || op.Mapping.Fingerprint != mapping.Fingerprint || op.Mapping.SessionID != mapping.SessionID) {
215 return ErrMutationConflict
216 }
217 if op.Phase == "prepared" {
218 op.Mapping = mapping
219 }
220 }
221 op.SessionIDs = append([]string{}, ids...)
222 state.PendingOperations[id] = op
223 return nil
224 })
225 }
226
227 func (s *Store) PrepareOperationContent(ctx context.Context, id string, ids []string, mapping *SourceMapping, presentation *Presentation) error {
228 return s.mutate(ctx, func(state *State) error {
229 op, ok := state.PendingOperations[id]
230 if !ok {
231 return ErrMutationConflict
232 }
233 if op.Phase != "prepared" {
234 if !slices.Equal(op.SessionIDs, ids) || !reflect.DeepEqual(op.Mapping, mapping) || !reflect.DeepEqual(op.Presentation, presentation) {
235 return ErrMutationConflict
236 }
237 return nil
238 }
239 if len(op.SessionIDs) != 0 && !slices.Equal(op.SessionIDs, ids) {
240 return ErrMutationConflict
241 }
242 op.Phase, op.SessionIDs, op.Mapping, op.Presentation = "content_ready", append([]string{}, ids...), mapping, presentation
243 state.PendingOperations[id] = op
244 return nil
245 })
246 }
247
248 // CommitOperation publishes membership, lifecycle and provenance in one durable
249 // registry replacement. File publication must have been validated beforehand.
250 func (s *Store) CommitOperation(ctx context.Context, id string) error {
251 return s.mutate(ctx, func(state *State) error {
252 if op, ok := state.PendingOperations[id]; ok && op.Kind == "archive-import" {
253 return ErrMutationConflict
254 }
255 return commitOperation(state, id, map[string]bool{})
256 })
257 }
258
259 // CommitHistoricalArchive publishes a standalone legacy trash import without
260 // admitting arbitrary archive-import children intended for an atomic batch.
261 func (s *Store) CommitHistoricalArchive(ctx context.Context, id string, archivedAt ...int64) error {
262 return s.mutate(ctx, func(state *State) error {
263 op, ok := state.PendingOperations[id]
264 if !ok || op.Kind != "archive-import" || op.Lifecycle != Archived || op.Mapping == nil {
265 return ErrMutationConflict
266 }
267 if op.Phase == "committed" {
268 return nil
269 }
270 if err := commitOperation(state, id, map[string]bool{}); err != nil {
271 return err
272 }
273 // No proven archive timestamp is available for these old records.
274 for _, sessionID := range op.SessionIDs {
275 status := state.SessionStates[sessionID]
276 status.ArchivedAt = 0
277 if len(archivedAt) > 0 && archivedAt[0] > 0 {
278 status.ArchivedAt = archivedAt[0]
279 }
280 state.SessionStates[sessionID] = status
281 }
282 return nil
283 })
284 }
285
286 func commitOperation(state *State, id string, visiting map[string]bool) error {
287 if visiting[id] {
288 return ErrMutationConflict
289 }
290 visiting[id] = true
291 defer delete(visiting, id)
292 op, ok := state.PendingOperations[id]
293 if !ok {
294 return ErrMutationConflict
295 }
296 if op.Phase == "committed" {
297 return nil
298 }
299 if op.Phase != "content_ready" || len(op.SessionIDs) == 0 {
300 return ErrMutationConflict
301 }
302 for _, dependency := range op.Dependencies {
303 child, ok := state.PendingOperations[dependency]
304 if !ok || child.Kind != "archive-import" || child.Phase != "content_ready" {
305 return ErrMutationConflict
306 }
307 for _, target := range child.SessionIDs {
308 if !slices.Contains(op.SessionIDs, target) {
309 return ErrMutationConflict
310 }
311 }
312 if err := commitOperation(state, dependency, visiting); err != nil {
313 return err
314 }
315 }
316 for _, sessionID := range op.SessionIDs {
317 if state.SessionStates[sessionID].Lifecycle == Deleted {
318 return ErrMutationConflict
319 }
320 if current, exists := state.SessionStates[sessionID]; exists && current.Generation > op.ExpectedGeneration && current.Generation != state.Generation+1 {
321 return ErrMutationConflict
322 }
323 owner, attached := sessionOwner(*state, sessionID)
324 if attached && op.WorkspaceID != "" && owner != op.WorkspaceID {
325 return ErrMutationConflict
326 }
327 if !attached {
328 workspace, exists := state.Workspaces[op.WorkspaceID]
329 if !exists {
330 return ErrWorkspaceNotFound
331 }
332 workspace.SessionIDs = insertBefore(workspace.SessionIDs, sessionID, "")
333 workspace.UpdatedAt = time.Now().UTC()
334 state.Workspaces[workspace.ID] = workspace
335 owner = workspace.ID
336 }
337 setLifecycle(state, sessionID, op.Lifecycle)
338 if op.Lifecycle == Active {
339 workspace := state.Workspaces[owner]
340 workspace.Visible = true
341 state.Workspaces[owner] = workspace
342 }
343 if op.Presentation != nil {
344 state.Presentation[sessionID] = *op.Presentation
345 }
346 }
347 if err := commitSourceMapping(state, op.Mapping); err != nil {
348 return err
349 }
350 if op.RecoveryEntryID != "" {
351 entry, exists := state.RecoveryEntries[op.RecoveryEntryID]
352 if !exists {
353 return ErrMutationConflict
354 }
355 entry.Status, entry.SessionID = "restored", op.SessionIDs[0]
356 state.RecoveryEntries[entry.ID] = entry
357 }
358 op.Phase, op.ResultGeneration = "committed", state.Generation+1
359 state.PendingOperations[id] = op
360 return nil
361 }
362
363 // BeginPurge leaves a durable tombstone before content removal. It prevents
364 // restore or source discovery from resurrecting a partially purged session.
365 func (s *Store) BeginPurge(ctx context.Context, id string, expected ...uint64) error {
366 return s.mutate(ctx, func(state *State) error {
367 key := "purge-" + id
368 if op, exists := state.PendingOperations[key]; exists {
369 if op.Kind != "purge" {
370 return ErrMutationConflict
371 }
372 return nil
373 }
374 if state.SessionStates[id].Lifecycle != Archived {
375 return ErrMutationConflict
376 }
377 if len(expected) > 0 && state.SessionStates[id].Generation > expected[0] {
378 return ErrMutationConflict
379 }
380 state.PendingOperations[key] = Operation{ID: key, Kind: "purge", Phase: "prepared", Lifecycle: Deleted, SessionIDs: []string{id}, ExpectedGeneration: state.Generation}
381 return nil
382 })
383 }
384
385 func (s *Store) AdvancePurge(ctx context.Context, id, phase string) error {
386 return s.mutate(ctx, func(state *State) error {
387 key := "purge-" + id
388 op, ok := state.PendingOperations[key]
389 if !ok || op.Kind != "purge" {
390 return ErrMutationConflict
391 }
392 if op.Phase == "committed" || op.Phase == phase || op.Phase == "content_removed" {
393 return nil
394 }
395 if phase == "tombstoned" && op.Phase == "prepared" {
396 if state.SessionStates[id].Lifecycle != Archived || state.SessionStates[id].Generation > op.ExpectedGeneration {
397 return ErrMutationConflict
398 }
399 setLifecycle(state, id, Deleted)
400 } else if phase != "content_removed" || op.Phase != "tombstoned" {
401 return ErrMutationConflict
402 }
403 op.Phase = phase
404 state.PendingOperations[key] = op
405 return nil
406 })
407 }
408
409 func (s *Store) CompletePurge(ctx context.Context, id string) error {
410 return s.mutate(ctx, func(state *State) error {
411 key := "purge-" + id
412 op, ok := state.PendingOperations[key]
413 if !ok || op.Kind != "purge" || state.SessionStates[id].Lifecycle != Deleted {
414 return ErrMutationConflict
415 }
416 if op.Phase == "committed" {
417 return nil
418 }
419 if op.Phase != "content_removed" {
420 return ErrMutationConflict
421 }
422 for key, workspace := range state.Workspaces {
423 workspace.SessionIDs = remove(workspace.SessionIDs, id)
424 state.Workspaces[key] = workspace
425 }
426 delete(state.Presentation, id)
427 op.Phase, op.ResultGeneration = "committed", state.Generation+1
428 state.PendingOperations[key] = op
429 return nil
430 })
431 }
432
433 func (s *Store) RecordSource(ctx context.Context, mapping SourceMapping, presentation Presentation) error {
434 return s.mutate(ctx, func(state *State) error {
435 if old, ok := state.SourceMappings[mapping.SourceKey]; ok {
436 if old.SessionID != mapping.SessionID || old.Fingerprint != mapping.Fingerprint {
437 return ErrMutationConflict
438 }
439 return nil
440 }
441 state.SourceMappings[mapping.SourceKey] = mapping
442 if _, exists := state.Presentation[mapping.SessionID]; !exists {
443 state.Presentation[mapping.SessionID] = presentation
444 }
445 return nil
446 })
447 }
448
449 func (s *Store) UpdatePresentation(ctx context.Context, ids []string, title *string, pinned *bool) error {
450 return s.mutate(ctx, func(state *State) error {
451 for _, id := range ids {
452 if _, ok := sessionOwner(*state, id); !ok {
453 return ErrSessionNotFound
454 }
455 value, exists := state.Presentation[id]
456 if !exists {
457 value.SortOrder = -1
458 }
459 if title != nil {
460 value.Title = *title
461 }
462 if pinned != nil {
463 value.Pinned = *pinned
464 }
465 state.Presentation[id] = value
466 }
467 return nil
468 })
469 }
470
471 // EnsureSessionTopic publishes the initial display group without letting
472 // later tab rebuilds overwrite a user's persisted presentation.
473 func (s *Store) EnsureSessionTopic(ctx context.Context, id, topicID, title string) error {
474 if id == "" || topicID == "" {
475 return nil
476 }
477 return s.mutate(ctx, func(state *State) error {
478 if _, ok := sessionOwner(*state, id); !ok {
479 return ErrSessionNotFound
480 }
481 value := state.Presentation[id]
482 if value.TopicID != "" {
483 return nil
484 }
485 value.TopicID = topicID
486 if value.Title == "" {
487 value.Title = title
488 }
489 state.Presentation[id] = value
490 return nil
491 })
492 }
493
494 func (s *Store) RecordRecovery(ctx context.Context, entry RecoveryEntry) error {
495 return s.mutate(ctx, func(state *State) error {
496 if old, ok := state.RecoveryEntries[entry.ID]; ok {
497 if old.Status == "restored" && old.Fingerprint == entry.Fingerprint {
498 return nil
499 }
500 if strings.Contains(old.Reason, "conflict") && !strings.Contains(entry.Reason, "conflict") {
501 entry.Reason = old.Reason
502 }
503 entry.extra = old.extra
504 }
505 if entry.Status == "" {
506 entry.Status = "pending"
507 }
508 state.RecoveryEntries[entry.ID] = entry
509 return nil
510 })
511 }
512
513 // ReconcileDiscoveredSession rechecks ownership under the writer lock. A scan
514 // snapshot can predate a concurrent create, import, archive, or restore.
515 // Discovery must never classify those published/reserved IDs as orphans.
516 func (s *Store) ReconcileDiscoveredSession(ctx context.Context, entry RecoveryEntry, workspace *Workspace) error {
517 return s.mutate(ctx, func(state *State) error {
518 id := entry.SessionID
519 if state.SessionStates[id].Lifecycle == Deleted {
520 return nil
521 }
522 if id == "" {
523 return errors.New("discovery requires a session id")
524 }
525 if _, ok := sessionOwner(*state, id); ok {
526 return nil
527 }
528 if _, ok := state.PendingCreates[id]; ok {
529 return nil
530 }
531 for _, op := range state.PendingOperations {
532 if op.Phase != "committed" && slices.Contains(op.SessionIDs, id) {
533 return nil
534 }
535 }
536 if lifecycle, ok := state.SessionStates[id]; ok && lifecycle.Lifecycle != Active {
537 workspace = nil
538 entry.Reason = "historical_state_unknown"
539 }
540 if workspace == nil {
541 if old, ok := state.RecoveryEntries[entry.ID]; ok {
542 if old.Status == "restored" {
543 return nil
544 }
545 entry.extra = old.extra
546 }
547 state.RecoveryEntries[entry.ID] = entry
548 return nil
549 }
550 value, ok := state.Workspaces[workspace.ID]
551 if !ok {
552 value = *workspace
553 state.WorkspaceIDs = append(state.WorkspaceIDs, value.ID)
554 }
555 value.SessionIDs = append(value.SessionIDs, id)
556 value.UpdatedAt = time.Now().UTC()
557 state.Workspaces[value.ID] = value
558 return nil
559 })
560 }
561
562 // backupV1 runs under the same cross-process lock as the schema publication.
563 // Content-addressed backups are immutable: a repeat never overwrites evidence.
564 func backupV1(path string) error {
565 body, err := os.ReadFile(path)
566 if os.IsNotExist(err) {
567 return nil
568 }
569 if err != nil {
570 return err
571 }
572 var header struct {
573 Version int `json:"version"`
574 }
575 if err := json.Unmarshal(body, &header); err != nil {
576 return err
577 }
578 if header.Version != 1 {
579 return nil
580 }
581 digest := sha256.Sum256(body)
582 backup := filepath.Join(filepath.Dir(path), "upgrade-backups", "workspace-v1-"+hex.EncodeToString(digest[:])+".json")
583 if old, err := os.ReadFile(backup); err == nil {
584 if sha256.Sum256(old) != digest {
585 return errors.New("workspace upgrade backup is corrupt")
586 }
587 return nil
588 } else if !os.IsNotExist(err) {
589 return err
590 }
591 if err := os.MkdirAll(filepath.Dir(backup), 0700); err != nil {
592 return err
593 }
594 return fileutil.AtomicWriteFileStrict(backup, body, 0600)
595 }
596
597 // unknownFields retains future, user-owned metadata during read/modify/write.
598 func unknownFields(body []byte, names ...string) (map[string]json.RawMessage, error) {
599 var fields map[string]json.RawMessage
600 if err := json.Unmarshal(body, &fields); err != nil {
601 return nil, err
602 }
603 for _, name := range names {
604 delete(fields, name)
605 }
606 return fields, nil
607 }
608
609 func commitSourceMapping(state *State, mapping *SourceMapping) error {
610 if mapping != nil {
611 mapping := *mapping
612 if old, exists := state.SourceMappings[mapping.SourceKey]; exists && (old.SessionID != mapping.SessionID || old.Fingerprint != mapping.Fingerprint) {
613 return ErrMutationConflict
614 }
615 state.SourceMappings[mapping.SourceKey] = mapping
616 }
617 return nil
618 }
619
619 lines GO