返回 DeepSeek-Reasonix
domain.go
根目录 / internal / goal / domain.go
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 &copy
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 &copy
558 }
559
560 func cloneRef(value *Ref) *Ref {
561 if value == nil {
562 return nil
563 }
564 copy := *value
565 return &copy
566 }
567
568 func cloneTime(value *time.Time) *time.Time {
569 if value == nil {
570 return nil
571 }
572 copy := *value
573 return &copy
574 }
575
575 lines GO