| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | goaldomain "reasonix/internal/goal" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | type legacyGoalProjection struct { |
| 16 | Goal string `json:"goal"` |
| 17 | Status string `json:"status"` |
| 18 | TurnsUsed int `json:"turnsUsed"` |
| 19 | Block string `json:"block"` |
| 20 | } |
| 21 | |
| 22 | // goalLifecycleFromProjection is the only compatibility boundary between |
| 23 | // imported legacy goal sidecars and the current versioned goal domain. It does |
| 24 | // not write during cold open and never restores todo or process activation. |
| 25 | func goalLifecycleFromProjection(raw json.RawMessage, sessionID string, createdAt time.Time) (*goaldomain.Machine, error) { |
| 26 | machine := goaldomain.NewMachine(nil, nil) |
| 27 | if len(raw) == 0 { |
| 28 | return machine, nil |
| 29 | } |
| 30 | var header struct { |
| 31 | Version *int `json:"version"` |
| 32 | } |
| 33 | if err := json.Unmarshal(raw, &header); err != nil { |
| 34 | return machine, fmt.Errorf("decode goal projection: %w", err) |
| 35 | } |
| 36 | if header.Version != nil { |
| 37 | if _, err := machine.Restore(raw); err != nil { |
| 38 | return machine, err |
| 39 | } |
| 40 | return machine, nil |
| 41 | } |
| 42 | return importLegacyGoalProjection(machine, raw, sessionID, createdAt) |
| 43 | } |
| 44 | |
| 45 | func importLegacyGoalProjection(machine *goaldomain.Machine, raw json.RawMessage, sessionID string, createdAt time.Time) (*goaldomain.Machine, error) { |
| 46 | var legacy legacyGoalProjection |
| 47 | if err := json.Unmarshal(raw, &legacy); err != nil { |
| 48 | return machine, fmt.Errorf("decode legacy goal projection: %w", err) |
| 49 | } |
| 50 | legacy.Goal = strings.TrimSpace(legacy.Goal) |
| 51 | if legacy.Goal == "" { |
| 52 | return machine, nil |
| 53 | } |
| 54 | if legacy.TurnsUsed < 0 { |
| 55 | return machine, fmt.Errorf("legacy goal has negative admitted rounds") |
| 56 | } |
| 57 | var phase goaldomain.Phase |
| 58 | var blockedReason *goaldomain.BlockReason |
| 59 | switch strings.TrimSpace(legacy.Status) { |
| 60 | case GoalStatusRunning: |
| 61 | phase = goaldomain.PhaseActive |
| 62 | case GoalStatusComplete: |
| 63 | phase = goaldomain.PhaseComplete |
| 64 | case GoalStatusBlocked: |
| 65 | phase = goaldomain.PhaseBlocked |
| 66 | message := strings.TrimSpace(legacy.Block) |
| 67 | if message == "" { |
| 68 | message = "legacy goal stopped without a recorded reason" |
| 69 | } |
| 70 | blockedReason = &goaldomain.BlockReason{Code: "legacy-blocked", Message: message} |
| 71 | case "", GoalStatusStopped: |
| 72 | phase = goaldomain.PhasePaused |
| 73 | default: |
| 74 | return machine, fmt.Errorf("legacy goal has unsupported status %q", legacy.Status) |
| 75 | } |
| 76 | if createdAt.IsZero() { |
| 77 | createdAt = time.Unix(0, 0).UTC() |
| 78 | } else { |
| 79 | createdAt = createdAt.UTC() |
| 80 | } |
| 81 | hash := sha256.Sum256(append(append([]byte(sessionID), 0), raw...)) |
| 82 | id := "legacy-" + hex.EncodeToString(hash[:12]) |
| 83 | document := map[string]any{ |
| 84 | "version": goaldomain.StateVersion, |
| 85 | "current": goaldomain.Snapshot{ |
| 86 | ID: id, Revision: 1, Objective: legacy.Goal, Phase: phase, |
| 87 | MaxGoalRounds: nil, RoundsStarted: uint64(legacy.TurnsUsed), |
| 88 | BlockedReason: blockedReason, CreatedAt: createdAt, UpdatedAt: createdAt, |
| 89 | }, |
| 90 | "legacyState": json.RawMessage(append([]byte(nil), raw...)), |
| 91 | } |
| 92 | encoded, err := json.Marshal(document) |
| 93 | if err != nil { |
| 94 | return machine, err |
| 95 | } |
| 96 | if _, err := machine.Restore(encoded); err != nil { |
| 97 | return machine, err |
| 98 | } |
| 99 | return machine, nil |
| 100 | } |
| 101 | |
| 102 | func (c *Controller) installGoalLifecycle(runtime *session.Runtime) { |
| 103 | machine := goaldomain.NewMachine(nil, nil) |
| 104 | var loadErr error |
| 105 | if runtime != nil && runtime.Session() != nil { |
| 106 | snapshot := runtime.Session().ExecutionSnapshot() |
| 107 | createdAt := runtime.Session().Handle().Manifest().CreatedAt |
| 108 | machine, loadErr = goalLifecycleFromProjection(snapshot.Projection.GoalState, runtime.Ref().SessionID, createdAt) |
| 109 | } |
| 110 | c.goalLifecycleMu.Lock() |
| 111 | c.goalLifecycle = machine |
| 112 | c.goalLifecycleLoadErr = loadErr |
| 113 | c.goalLifecycleMu.Unlock() |
| 114 | } |
| 115 | |
| 116 | func (c *Controller) goalLifecycleView() (*goaldomain.View, error) { |
| 117 | if c == nil { |
| 118 | return nil, nil |
| 119 | } |
| 120 | c.goalLifecycleMu.RLock() |
| 121 | machine, loadErr := c.goalLifecycle, c.goalLifecycleLoadErr |
| 122 | c.goalLifecycleMu.RUnlock() |
| 123 | if loadErr != nil { |
| 124 | return nil, loadErr |
| 125 | } |
| 126 | if machine == nil { |
| 127 | return nil, nil |
| 128 | } |
| 129 | return machine.Get(), nil |
| 130 | } |
| 131 |