返回 DeepSeek-Reasonix
ledger.go
根目录 / desktop / internal / browserops / ledger.go
1 // Package browserops records every agent write to a hosted browser before the
2 // write happens and settles it afterwards. A process death between the two
3 // leaves the operation "unknown", which is never retried automatically: the
4 // page may or may not have accepted the submission, and only a fresh snapshot
5 // can tell. The ledger is a versioned JSON file that the previous desktop
6 // shell never reads, so it can grow without touching core session formats.
7 package browserops
8
9 import (
10 "crypto/rand"
11 "encoding/hex"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "os"
16 "path/filepath"
17 "regexp"
18 "sort"
19 "sync"
20 "time"
21 )
22
23 const ledgerVersion = 1
24
25 // State is the outcome recorded for one operation.
26 type State string
27
28 const (
29 StateReserved State = "reserved"
30 StateExecuted State = "executed"
31 StateNotExecuted State = "not_executed"
32 StateUnknown State = "unknown"
33 )
34
35 var operationIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,100}$`)
36
37 // Operation is one reserved browser write and its settlement.
38 type Operation struct {
39 ID string `json:"id"`
40 SessionID string `json:"sessionId"`
41 Generation string `json:"generation"`
42 TabID string `json:"tabId"`
43 Epoch uint64 `json:"epoch"`
44 DocumentToken string `json:"documentToken"`
45 Action string `json:"action"`
46 Digest string `json:"digest"`
47 State State `json:"state"`
48 ReservedAt time.Time `json:"reservedAt"`
49 SettledAt time.Time `json:"settledAt,omitempty"`
50 Reason string `json:"reason,omitempty"`
51 }
52
53 type ledgerFile struct {
54 Version int `json:"version"`
55 Operations map[string]*Operation `json:"operations"`
56 }
57
58 // Ledger is the durable operation log for one desktop data home.
59 type Ledger struct {
60 path string
61 mu sync.Mutex
62 file ledgerFile
63 now func() time.Time
64 }
65
66 var (
67 ErrDuplicateOperation = errors.New("browser operation id already recorded")
68 ErrInvalidOperationID = errors.New("browser operation id must match [A-Za-z0-9_-]{1,100}")
69 ErrUnknownOperation = errors.New("browser operation not reserved")
70 ErrAlreadySettled = errors.New("browser operation already settled")
71 )
72
73 // Open loads or creates the ledger. Operations left reserved by a previous
74 // process are marked unknown before anything else can run.
75 func Open(path string) (*Ledger, error) {
76 l := &Ledger{path: path, now: func() time.Time { return time.Now().UTC() }}
77 data, err := os.ReadFile(path)
78 switch {
79 case errors.Is(err, os.ErrNotExist):
80 l.file = ledgerFile{Version: ledgerVersion, Operations: map[string]*Operation{}}
81 return l, nil
82 case err != nil:
83 return nil, err
84 }
85 if err := json.Unmarshal(data, &l.file); err != nil {
86 return nil, fmt.Errorf("browser ledger %s: %w", path, err)
87 }
88 if l.file.Version != ledgerVersion {
89 return nil, fmt.Errorf("browser ledger %s: unsupported version %d", path, l.file.Version)
90 }
91 if l.file.Operations == nil {
92 l.file.Operations = map[string]*Operation{}
93 }
94 recovered := false
95 for _, op := range l.file.Operations {
96 if op.State == StateReserved {
97 op.State, op.SettledAt, op.Reason = StateUnknown, l.now(), "process exited before settlement"
98 recovered = true
99 }
100 }
101 if recovered {
102 if err := l.persistLocked(); err != nil {
103 return nil, err
104 }
105 }
106 return l, nil
107 }
108
109 // Reserve durably records the operation before any side effect. A repeated
110 // ID fails even when the earlier attempt is unknown: the caller must read the
111 // page again and mint a new ID instead of replaying.
112 func (l *Ledger) Reserve(op Operation) error {
113 if !operationIDRe.MatchString(op.ID) {
114 return ErrInvalidOperationID
115 }
116 l.mu.Lock()
117 defer l.mu.Unlock()
118 if _, exists := l.file.Operations[op.ID]; exists {
119 return ErrDuplicateOperation
120 }
121 op.State = StateReserved
122 op.ReservedAt = l.now()
123 op.SettledAt = time.Time{}
124 stored := op
125 l.file.Operations[op.ID] = &stored
126 if err := l.persistLocked(); err != nil {
127 delete(l.file.Operations, op.ID)
128 return err
129 }
130 return nil
131 }
132
133 // Settle records the outcome of a reserved operation exactly once.
134 func (l *Ledger) Settle(id string, state State, reason string) error {
135 if state == StateReserved {
136 return fmt.Errorf("browser operation %s: cannot settle to reserved", id)
137 }
138 l.mu.Lock()
139 defer l.mu.Unlock()
140 op, ok := l.file.Operations[id]
141 if !ok {
142 return ErrUnknownOperation
143 }
144 if op.State != StateReserved {
145 return ErrAlreadySettled
146 }
147 previous := *op
148 op.State, op.SettledAt, op.Reason = state, l.now(), reason
149 if err := l.persistLocked(); err != nil {
150 *op = previous
151 return err
152 }
153 return nil
154 }
155
156 // Lookup returns a copy of the recorded operation.
157 func (l *Ledger) Lookup(id string) (Operation, bool) {
158 l.mu.Lock()
159 defer l.mu.Unlock()
160 op, ok := l.file.Operations[id]
161 if !ok {
162 return Operation{}, false
163 }
164 return *op, true
165 }
166
167 // Unsettled lists operations whose outcome is unknown, oldest first, so the
168 // UI can show the user what may have reached a website.
169 func (l *Ledger) Unsettled() []Operation {
170 l.mu.Lock()
171 defer l.mu.Unlock()
172 var out []Operation
173 for _, op := range l.file.Operations {
174 if op.State == StateUnknown {
175 out = append(out, *op)
176 }
177 }
178 sort.Slice(out, func(i, j int) bool { return out[i].ReservedAt.Before(out[j].ReservedAt) })
179 return out
180 }
181
182 func (l *Ledger) persistLocked() error {
183 data, err := json.MarshalIndent(&l.file, "", " ")
184 if err != nil {
185 return err
186 }
187 if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
188 return err
189 }
190 suffix := make([]byte, 8)
191 if _, err := rand.Read(suffix); err != nil {
192 return err
193 }
194 tmp := l.path + "." + hex.EncodeToString(suffix) + ".tmp"
195 f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
196 if err != nil {
197 return err
198 }
199 if _, err := f.Write(data); err != nil {
200 _ = f.Close()
201 _ = os.Remove(tmp)
202 return err
203 }
204 if err := f.Sync(); err != nil {
205 _ = f.Close()
206 _ = os.Remove(tmp)
207 return err
208 }
209 if err := f.Close(); err != nil {
210 _ = os.Remove(tmp)
211 return err
212 }
213 return os.Rename(tmp, l.path)
214 }
215
215 lines GO