| 1 | package goal |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | const StateVersion = 1 |
| 15 | |
| 16 | type Phase string |
| 17 | |
| 18 | const ( |
| 19 | PhaseActive Phase = "active" |
| 20 | PhasePaused Phase = "paused" |
| 21 | PhaseBlocked Phase = "blocked" |
| 22 | PhaseComplete Phase = "complete" |
| 23 | ) |
| 24 | |
| 25 | type Activation string |
| 26 | |
| 27 | const ( |
| 28 | ActivationArmed Activation = "armed" |
| 29 | ActivationDisarmed Activation = "disarmed" |
| 30 | ) |
| 31 | |
| 32 | type ErrorCode string |
| 33 | |
| 34 | const ( |
| 35 | ErrNotFound ErrorCode = "GOAL_NOT_FOUND" |
| 36 | ErrAlreadyExists ErrorCode = "GOAL_ALREADY_EXISTS" |
| 37 | ErrStaleRevision ErrorCode = "GOAL_STALE_REVISION" |
| 38 | ErrInvalidObjective ErrorCode = "GOAL_INVALID_OBJECTIVE" |
| 39 | ErrInvalidRoundLimit ErrorCode = "GOAL_INVALID_MAX_ROUNDS" |
| 40 | ErrInvalidBlockReason ErrorCode = "GOAL_INVALID_BLOCK_REASON" |
| 41 | ErrInvalidEdit ErrorCode = "GOAL_INVALID_EDIT" |
| 42 | ErrInvalidTransition ErrorCode = "GOAL_INVALID_TRANSITION" |
| 43 | ErrRoundLimit ErrorCode = "GOAL_ROUND_LIMIT" |
| 44 | ErrBlockedTooEarly ErrorCode = "GOAL_BLOCKED_TOO_EARLY" |
| 45 | ErrUserAuthorityRequired ErrorCode = "GOAL_USER_AUTHORITY_REQUIRED" |
| 46 | ErrUnsupportedVersion ErrorCode = "GOAL_UNSUPPORTED_VERSION" |
| 47 | ) |
| 48 | |
| 49 | type Error struct { |
| 50 | Code ErrorCode |
| 51 | Message string |
| 52 | } |
| 53 | |
| 54 | func (e *Error) Error() string { return e.Message } |
| 55 | |
| 56 | func ErrorCodeOf(err error) ErrorCode { |
| 57 | var target *Error |
| 58 | if errors.As(err, &target) { |
| 59 | return target.Code |
| 60 | } |
| 61 | return "" |
| 62 | } |
| 63 | |
| 64 | func goalError(code ErrorCode, format string, args ...any) error { |
| 65 | return &Error{Code: code, Message: fmt.Sprintf(format, args...)} |
| 66 | } |
| 67 | |
| 68 | type Ref struct { |
| 69 | ID string `json:"id"` |
| 70 | Revision uint64 `json:"revision"` |
| 71 | } |
| 72 | |
| 73 | type BlockReason struct { |
| 74 | Code string `json:"code"` |
| 75 | Message string `json:"message"` |
| 76 | } |
| 77 | |
| 78 | type Snapshot struct { |
| 79 | ID string `json:"id"` |
| 80 | Revision uint64 `json:"revision"` |
| 81 | Objective string `json:"objective"` |
| 82 | Phase Phase `json:"phase"` |
| 83 | MaxGoalRounds *uint64 `json:"maxGoalRounds"` |
| 84 | RoundsStarted uint64 `json:"roundsStarted"` |
| 85 | BlockedReason *BlockReason `json:"blockedReason,omitempty"` |
| 86 | CreatedAt time.Time `json:"createdAt"` |
| 87 | UpdatedAt time.Time `json:"updatedAt"` |
| 88 | } |
| 89 | |
| 90 | func (s Snapshot) Ref() Ref { return Ref{ID: s.ID, Revision: s.Revision} } |
| 91 | |
| 92 | type View struct { |
| 93 | Snapshot |
| 94 | Activation Activation `json:"activation"` |
| 95 | StopReason string `json:"stopReason,omitempty"` |
| 96 | } |
| 97 | |
| 98 | func (v View) Ref() Ref { return v.Snapshot.Ref() } |
| 99 | |
| 100 | type CreateRequest struct { |
| 101 | Objective string |
| 102 | MaxGoalRounds *uint64 |
| 103 | } |
| 104 | |
| 105 | // RoundLimitChange distinguishes an omitted edit from explicitly removing a |
| 106 | // limit. Set=false leaves the existing limit unchanged; Set=true with a nil |
| 107 | // Value selects unlimited rounds. |
| 108 | type RoundLimitChange struct { |
| 109 | Set bool |
| 110 | Value *uint64 |
| 111 | } |
| 112 | |
| 113 | type EditRequest struct { |
| 114 | Objective *string |
| 115 | MaxGoalRounds RoundLimitChange |
| 116 | } |
| 117 | |
| 118 | type stateDocument struct { |
| 119 | Version int `json:"version"` |
| 120 | Current *Snapshot `json:"current"` |
| 121 | Cleared *Ref `json:"cleared,omitempty"` |
| 122 | ClearedAt *time.Time `json:"clearedAt,omitempty"` |
| 123 | Extra map[string]json.RawMessage `json:"-"` |
| 124 | } |
| 125 | |
| 126 | type Machine struct { |
| 127 | mu sync.Mutex |
| 128 | now func() time.Time |
| 129 | newID func() string |
| 130 | current *Snapshot |
| 131 | activation Activation |
| 132 | stopReason string |
| 133 | cleared *Ref |
| 134 | clearedAt *time.Time |
| 135 | extra map[string]json.RawMessage |
| 136 | } |
| 137 | |
| 138 | func NewMachine(now func() time.Time, newID func() string) *Machine { |
| 139 | if now == nil { |
| 140 | now = time.Now |
| 141 | } |
| 142 | if newID == nil { |
| 143 | newID = randomID |
| 144 | } |
| 145 | return &Machine{now: now, newID: newID, activation: ActivationDisarmed} |
| 146 | } |
| 147 | |
| 148 | func randomID() string { |
| 149 | var value [16]byte |
| 150 | if _, err := rand.Read(value[:]); err != nil { |
| 151 | return fmt.Sprintf("goal-%d", time.Now().UnixNano()) |
| 152 | } |
| 153 | return hex.EncodeToString(value[:]) |
| 154 | } |
| 155 | |
| 156 | func (m *Machine) Get() *View { |
| 157 | m.mu.Lock() |
| 158 | defer m.mu.Unlock() |
| 159 | return m.viewLocked() |
| 160 | } |
| 161 | |
| 162 | // Clone returns an independent candidate with the same durable and live state. |
| 163 | // Hosts use it to prepare a mutation before the corresponding session event is |
| 164 | // accepted, then publish the candidate atomically after Append succeeds. |
| 165 | func (m *Machine) Clone() *Machine { |
| 166 | if m == nil { |
| 167 | return NewMachine(nil, nil) |
| 168 | } |
| 169 | m.mu.Lock() |
| 170 | defer m.mu.Unlock() |
| 171 | extra := make(map[string]json.RawMessage, len(m.extra)) |
| 172 | for key, value := range m.extra { |
| 173 | extra[key] = append(json.RawMessage(nil), value...) |
| 174 | } |
| 175 | return &Machine{ |
| 176 | now: m.now, |
| 177 | newID: m.newID, |
| 178 | current: cloneSnapshot(m.current), |
| 179 | activation: m.activation, |
| 180 | stopReason: m.stopReason, |
| 181 | cleared: cloneRef(m.cleared), |
| 182 | clearedAt: cloneTime(m.clearedAt), |
| 183 | extra: extra, |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // InheritRuntimeFrom copies only process-local activation state when both |
| 188 | // machines describe the exact same durable goal version. It never changes the |
| 189 | // persisted snapshot or lifecycle revision. |
| 190 | func (m *Machine) InheritRuntimeFrom(previous *Machine) error { |
| 191 | if m == nil || previous == nil { |
| 192 | return nil |
| 193 | } |
| 194 | prior := previous.Get() |
| 195 | m.mu.Lock() |
| 196 | defer m.mu.Unlock() |
| 197 | if prior == nil && m.current == nil { |
| 198 | return nil |
| 199 | } |
| 200 | if prior == nil || m.current == nil || prior.ID != m.current.ID || prior.Revision != m.current.Revision || prior.RoundsStarted != m.current.RoundsStarted || prior.Phase != m.current.Phase { |
| 201 | return goalError(ErrStaleRevision, "cannot inherit activation across different goal snapshots") |
| 202 | } |
| 203 | m.activation = prior.Activation |
| 204 | m.stopReason = prior.StopReason |
| 205 | return nil |
| 206 | } |
| 207 | |
| 208 | func (m *Machine) Create(request CreateRequest) (View, error) { |
| 209 | m.mu.Lock() |
| 210 | defer m.mu.Unlock() |
| 211 | if m.current != nil && m.current.Phase != PhaseComplete { |
| 212 | return View{}, goalError(ErrAlreadyExists, "an unfinished goal already exists") |
| 213 | } |
| 214 | objective, err := validObjective(request.Objective) |
| 215 | if err != nil { |
| 216 | return View{}, err |
| 217 | } |
| 218 | if err := validLimit(request.MaxGoalRounds, 0); err != nil { |
| 219 | return View{}, err |
| 220 | } |
| 221 | now := m.now().UTC() |
| 222 | m.current = &Snapshot{ |
| 223 | ID: m.newID(), |
| 224 | Revision: 1, |
| 225 | Objective: objective, |
| 226 | Phase: PhaseActive, |
| 227 | MaxGoalRounds: cloneLimit(request.MaxGoalRounds), |
| 228 | CreatedAt: now, |
| 229 | UpdatedAt: now, |
| 230 | } |
| 231 | m.activation = ActivationArmed |
| 232 | m.stopReason = "" |
| 233 | m.cleared, m.clearedAt = nil, nil |
| 234 | return *m.viewLocked(), nil |
| 235 | } |
| 236 | |
| 237 | // Replace is the explicit host/UI operation for installing a new goal while |
| 238 | // preserving the replaced goal reference as a clear tombstone in the same |
| 239 | // versioned snapshot. Model create_goal intentionally cannot invoke it. |
| 240 | func (m *Machine) Replace(request CreateRequest) (View, error) { |
| 241 | m.mu.Lock() |
| 242 | defer m.mu.Unlock() |
| 243 | objective, err := validObjective(request.Objective) |
| 244 | if err != nil { |
| 245 | return View{}, err |
| 246 | } |
| 247 | if err := validLimit(request.MaxGoalRounds, 0); err != nil { |
| 248 | return View{}, err |
| 249 | } |
| 250 | now := m.now().UTC() |
| 251 | if m.current != nil { |
| 252 | ref := m.current.Ref() |
| 253 | m.cleared = &ref |
| 254 | m.clearedAt = &now |
| 255 | } |
| 256 | m.current = &Snapshot{ |
| 257 | ID: m.newID(), Revision: 1, Objective: objective, Phase: PhaseActive, |
| 258 | MaxGoalRounds: cloneLimit(request.MaxGoalRounds), CreatedAt: now, UpdatedAt: now, |
| 259 | } |
| 260 | m.activation = ActivationArmed |
| 261 | m.stopReason = "" |
| 262 | return *m.viewLocked(), nil |
| 263 | } |
| 264 | |
| 265 | func (m *Machine) Edit(ref Ref, request EditRequest) (View, error) { |
| 266 | m.mu.Lock() |
| 267 | defer m.mu.Unlock() |
| 268 | current, err := m.exactLocked(ref) |
| 269 | if err != nil { |
| 270 | return View{}, err |
| 271 | } |
| 272 | if request.Objective == nil && !request.MaxGoalRounds.Set { |
| 273 | return View{}, goalError(ErrInvalidEdit, "goal edit requires objective and/or max_goal_rounds") |
| 274 | } |
| 275 | if request.Objective != nil { |
| 276 | objective, validateErr := validObjective(*request.Objective) |
| 277 | if validateErr != nil { |
| 278 | return View{}, validateErr |
| 279 | } |
| 280 | current.Objective = objective |
| 281 | } |
| 282 | if request.MaxGoalRounds.Set { |
| 283 | if validateErr := validLimit(request.MaxGoalRounds.Value, current.RoundsStarted); validateErr != nil { |
| 284 | return View{}, validateErr |
| 285 | } |
| 286 | current.MaxGoalRounds = cloneLimit(request.MaxGoalRounds.Value) |
| 287 | } |
| 288 | m.bumpLocked(current) |
| 289 | return *m.viewLocked(), nil |
| 290 | } |
| 291 | |
| 292 | func (m *Machine) Pause(ref Ref) (View, error) { |
| 293 | m.mu.Lock() |
| 294 | defer m.mu.Unlock() |
| 295 | current, err := m.exactLocked(ref) |
| 296 | if err != nil { |
| 297 | return View{}, err |
| 298 | } |
| 299 | if current.Phase != PhaseActive { |
| 300 | return View{}, goalError(ErrInvalidTransition, "only an active goal can be paused") |
| 301 | } |
| 302 | current.Phase = PhasePaused |
| 303 | current.BlockedReason = nil |
| 304 | m.activation = ActivationDisarmed |
| 305 | m.stopReason = "user-paused" |
| 306 | m.bumpLocked(current) |
| 307 | return *m.viewLocked(), nil |
| 308 | } |
| 309 | |
| 310 | func (m *Machine) Resume(ref Ref, directUser bool) (View, error) { |
| 311 | m.mu.Lock() |
| 312 | defer m.mu.Unlock() |
| 313 | current, err := m.exactLocked(ref) |
| 314 | if err != nil { |
| 315 | return View{}, err |
| 316 | } |
| 317 | if !directUser { |
| 318 | return View{}, goalError(ErrUserAuthorityRequired, "resuming a goal requires current direct user authority") |
| 319 | } |
| 320 | if current.Phase == PhaseComplete || (current.Phase == PhaseActive && m.activation == ActivationArmed) { |
| 321 | return View{}, goalError(ErrInvalidTransition, "goal cannot be resumed from %s/%s", current.Phase, m.activation) |
| 322 | } |
| 323 | if current.MaxGoalRounds != nil && current.RoundsStarted >= *current.MaxGoalRounds { |
| 324 | return View{}, goalError(ErrRoundLimit, "goal exhausted its configured round limit") |
| 325 | } |
| 326 | current.Phase = PhaseActive |
| 327 | current.BlockedReason = nil |
| 328 | m.activation = ActivationArmed |
| 329 | m.stopReason = "" |
| 330 | m.bumpLocked(current) |
| 331 | return *m.viewLocked(), nil |
| 332 | } |
| 333 | |
| 334 | func (m *Machine) Complete(ref Ref) (View, error) { |
| 335 | m.mu.Lock() |
| 336 | defer m.mu.Unlock() |
| 337 | current, err := m.exactLocked(ref) |
| 338 | if err != nil { |
| 339 | return View{}, err |
| 340 | } |
| 341 | if current.Phase != PhaseActive { |
| 342 | return View{}, goalError(ErrInvalidTransition, "only an active goal can complete") |
| 343 | } |
| 344 | current.Phase = PhaseComplete |
| 345 | current.BlockedReason = nil |
| 346 | m.activation = ActivationDisarmed |
| 347 | m.stopReason = "complete" |
| 348 | m.bumpLocked(current) |
| 349 | return *m.viewLocked(), nil |
| 350 | } |
| 351 | |
| 352 | func (m *Machine) Block(ref Ref, reason BlockReason, directUser bool, minimumAutomaticRounds uint64) (View, error) { |
| 353 | m.mu.Lock() |
| 354 | defer m.mu.Unlock() |
| 355 | current, err := m.exactLocked(ref) |
| 356 | if err != nil { |
| 357 | return View{}, err |
| 358 | } |
| 359 | if current.Phase != PhaseActive { |
| 360 | return View{}, goalError(ErrInvalidTransition, "only an active goal can be blocked") |
| 361 | } |
| 362 | reason.Code = strings.TrimSpace(reason.Code) |
| 363 | reason.Message = strings.TrimSpace(reason.Message) |
| 364 | if reason.Code == "" || reason.Message == "" { |
| 365 | return View{}, goalError(ErrInvalidBlockReason, "blocked goal requires a code and message") |
| 366 | } |
| 367 | if !directUser && current.RoundsStarted < minimumAutomaticRounds { |
| 368 | return View{}, goalError(ErrBlockedTooEarly, "automatic blocking requires at least %d admitted rounds", minimumAutomaticRounds) |
| 369 | } |
| 370 | current.Phase = PhaseBlocked |
| 371 | current.BlockedReason = &reason |
| 372 | m.activation = ActivationDisarmed |
| 373 | m.stopReason = reason.Code |
| 374 | m.bumpLocked(current) |
| 375 | return *m.viewLocked(), nil |
| 376 | } |
| 377 | |
| 378 | func (m *Machine) AdmitRound(ref Ref) (View, error) { |
| 379 | m.mu.Lock() |
| 380 | defer m.mu.Unlock() |
| 381 | current, err := m.exactLocked(ref) |
| 382 | if err != nil { |
| 383 | return View{}, err |
| 384 | } |
| 385 | if current.Phase != PhaseActive || m.activation != ActivationArmed { |
| 386 | return View{}, goalError(ErrInvalidTransition, "goal is not armed for automatic continuation") |
| 387 | } |
| 388 | if current.MaxGoalRounds != nil && current.RoundsStarted >= *current.MaxGoalRounds { |
| 389 | return View{}, goalError(ErrRoundLimit, "goal exhausted its configured round limit") |
| 390 | } |
| 391 | current.RoundsStarted++ |
| 392 | return *m.viewLocked(), nil |
| 393 | } |
| 394 | |
| 395 | func (m *Machine) Disarm(reason string) *View { |
| 396 | m.mu.Lock() |
| 397 | defer m.mu.Unlock() |
| 398 | if m.current == nil { |
| 399 | return nil |
| 400 | } |
| 401 | if m.current.Phase != PhaseActive { |
| 402 | return m.viewLocked() |
| 403 | } |
| 404 | m.activation = ActivationDisarmed |
| 405 | m.stopReason = strings.TrimSpace(reason) |
| 406 | return m.viewLocked() |
| 407 | } |
| 408 | |
| 409 | func (m *Machine) Clear(ref Ref) error { |
| 410 | m.mu.Lock() |
| 411 | defer m.mu.Unlock() |
| 412 | current, err := m.exactLocked(ref) |
| 413 | if err != nil { |
| 414 | return err |
| 415 | } |
| 416 | cleared := current.Ref() |
| 417 | clearedAt := m.now().UTC() |
| 418 | m.current = nil |
| 419 | m.activation = ActivationDisarmed |
| 420 | m.stopReason = "cleared" |
| 421 | m.cleared = &cleared |
| 422 | m.clearedAt = &clearedAt |
| 423 | return nil |
| 424 | } |
| 425 | |
| 426 | func (m *Machine) Encode() ([]byte, error) { |
| 427 | m.mu.Lock() |
| 428 | defer m.mu.Unlock() |
| 429 | doc := map[string]any{"version": StateVersion, "current": m.current} |
| 430 | if m.cleared != nil { |
| 431 | doc["cleared"] = m.cleared |
| 432 | doc["clearedAt"] = m.clearedAt |
| 433 | } |
| 434 | for key, value := range m.extra { |
| 435 | if _, reserved := doc[key]; !reserved { |
| 436 | doc[key] = json.RawMessage(append([]byte(nil), value...)) |
| 437 | } |
| 438 | } |
| 439 | return json.Marshal(doc) |
| 440 | } |
| 441 | |
| 442 | func (m *Machine) Restore(data []byte) (*View, error) { |
| 443 | var raw map[string]json.RawMessage |
| 444 | if err := json.Unmarshal(data, &raw); err != nil { |
| 445 | return nil, goalError(ErrUnsupportedVersion, "decode goal state: %v", err) |
| 446 | } |
| 447 | var doc stateDocument |
| 448 | if err := json.Unmarshal(data, &doc); err != nil { |
| 449 | return nil, goalError(ErrUnsupportedVersion, "decode goal state: %v", err) |
| 450 | } |
| 451 | if doc.Version != StateVersion { |
| 452 | return nil, goalError(ErrUnsupportedVersion, "unsupported goal state version %d", doc.Version) |
| 453 | } |
| 454 | if doc.Current != nil { |
| 455 | if err := validateSnapshot(*doc.Current); err != nil { |
| 456 | return nil, err |
| 457 | } |
| 458 | } |
| 459 | delete(raw, "version") |
| 460 | delete(raw, "current") |
| 461 | delete(raw, "cleared") |
| 462 | delete(raw, "clearedAt") |
| 463 | m.mu.Lock() |
| 464 | defer m.mu.Unlock() |
| 465 | m.current = cloneSnapshot(doc.Current) |
| 466 | m.cleared = cloneRef(doc.Cleared) |
| 467 | m.clearedAt = cloneTime(doc.ClearedAt) |
| 468 | m.extra = raw |
| 469 | m.activation = ActivationDisarmed |
| 470 | m.stopReason = "cold-restore" |
| 471 | return m.viewLocked(), nil |
| 472 | } |
| 473 | |
| 474 | func (m *Machine) exactLocked(ref Ref) (*Snapshot, error) { |
| 475 | if m.current == nil { |
| 476 | return nil, goalError(ErrNotFound, "no current goal") |
| 477 | } |
| 478 | if ref.ID != m.current.ID || ref.Revision != m.current.Revision { |
| 479 | return nil, goalError(ErrStaleRevision, "goal reference is stale") |
| 480 | } |
| 481 | return m.current, nil |
| 482 | } |
| 483 | |
| 484 | func (m *Machine) bumpLocked(current *Snapshot) { |
| 485 | current.Revision++ |
| 486 | current.UpdatedAt = m.now().UTC() |
| 487 | } |
| 488 | |
| 489 | func (m *Machine) viewLocked() *View { |
| 490 | if m.current == nil { |
| 491 | return nil |
| 492 | } |
| 493 | return &View{Snapshot: *cloneSnapshot(m.current), Activation: m.activation, StopReason: m.stopReason} |
| 494 | } |
| 495 | |
| 496 | func validObjective(value string) (string, error) { |
| 497 | value = strings.TrimSpace(value) |
| 498 | if value == "" { |
| 499 | return "", goalError(ErrInvalidObjective, "goal objective must not be empty") |
| 500 | } |
| 501 | return value, nil |
| 502 | } |
| 503 | |
| 504 | func validLimit(limit *uint64, roundsStarted uint64) error { |
| 505 | if limit != nil && (*limit == 0 || *limit < roundsStarted) { |
| 506 | return goalError(ErrInvalidRoundLimit, "max goal rounds must be positive and not below admitted rounds") |
| 507 | } |
| 508 | return nil |
| 509 | } |
| 510 | |
| 511 | func validateSnapshot(snapshot Snapshot) error { |
| 512 | if snapshot.ID == "" || snapshot.Revision == 0 { |
| 513 | return goalError(ErrUnsupportedVersion, "goal state has invalid identity") |
| 514 | } |
| 515 | if _, err := validObjective(snapshot.Objective); err != nil { |
| 516 | return err |
| 517 | } |
| 518 | if err := validLimit(snapshot.MaxGoalRounds, snapshot.RoundsStarted); err != nil { |
| 519 | return err |
| 520 | } |
| 521 | switch snapshot.Phase { |
| 522 | case PhaseActive, PhasePaused, PhaseComplete: |
| 523 | if snapshot.BlockedReason != nil { |
| 524 | return goalError(ErrUnsupportedVersion, "non-blocked goal contains blockedReason") |
| 525 | } |
| 526 | case PhaseBlocked: |
| 527 | if snapshot.BlockedReason == nil || strings.TrimSpace(snapshot.BlockedReason.Code) == "" || strings.TrimSpace(snapshot.BlockedReason.Message) == "" { |
| 528 | return goalError(ErrInvalidBlockReason, "blocked goal requires a code and message") |
| 529 | } |
| 530 | default: |
| 531 | return goalError(ErrUnsupportedVersion, "unsupported goal phase %q", snapshot.Phase) |
| 532 | } |
| 533 | if snapshot.CreatedAt.IsZero() || snapshot.UpdatedAt.Before(snapshot.CreatedAt) { |
| 534 | return goalError(ErrUnsupportedVersion, "goal state has invalid timestamps") |
| 535 | } |
| 536 | return nil |
| 537 | } |
| 538 | |
| 539 | func cloneLimit(value *uint64) *uint64 { |
| 540 | if value == nil { |
| 541 | return nil |
| 542 | } |
| 543 | copy := *value |
| 544 | return © |
| 545 | } |
| 546 | |
| 547 | func cloneSnapshot(value *Snapshot) *Snapshot { |
| 548 | if value == nil { |
| 549 | return nil |
| 550 | } |
| 551 | copy := *value |
| 552 | copy.MaxGoalRounds = cloneLimit(value.MaxGoalRounds) |
| 553 | if value.BlockedReason != nil { |
| 554 | reason := *value.BlockedReason |
| 555 | copy.BlockedReason = &reason |
| 556 | } |
| 557 | return © |
| 558 | } |
| 559 | |
| 560 | func cloneRef(value *Ref) *Ref { |
| 561 | if value == nil { |
| 562 | return nil |
| 563 | } |
| 564 | copy := *value |
| 565 | return © |
| 566 | } |
| 567 | |
| 568 | func cloneTime(value *time.Time) *time.Time { |
| 569 | if value == nil { |
| 570 | return nil |
| 571 | } |
| 572 | copy := *value |
| 573 | return © |
| 574 | } |
| 575 |