返回 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 // Optional diagnostic attribution. Old writers may drop it; such records
40 // remain valid operations but must never be guessed into a session export.
41 DiagnosticScope string `json:"diagnosticScope,omitempty"`
42 ID string `json:"id"`
43 SessionID string `json:"sessionId"`
44 Generation string `json:"generation"`
45 TabID string `json:"tabId"`
46 Epoch uint64 `json:"epoch"`
47 DocumentToken string `json:"documentToken"`
48 Action string `json:"action"`
49 Digest string `json:"digest"`
50 State State `json:"state"`
51 ReservedAt time.Time `json:"reservedAt"`
52 SettledAt time.Time `json:"settledAt,omitempty"`
53 Reason string `json:"reason,omitempty"`
54 }
55
56 type ledgerFile struct {
57 Version int `json:"version"`
58 Operations map[string]*Operation `json:"operations"`
59 }
60
61 // Ledger is the durable operation log for one desktop data home.
62 type Ledger struct {
63 path string
64 mu sync.Mutex
65 file ledgerFile
66 now func() time.Time
67 }
68
69 var (
70 ErrDuplicateOperation = errors.New("browser operation id already recorded")
71 ErrInvalidOperationID = errors.New("browser operation id must match [A-Za-z0-9_-]{1,100}")
72 ErrUnknownOperation = errors.New("browser operation not reserved")
73 ErrAlreadySettled = errors.New("browser operation already settled")
74 )
75
76 // Open loads or creates the ledger. Operations left reserved by a previous
77 // process are marked unknown before anything else can run.
78 func Open(path string) (*Ledger, error) {
79 l := &Ledger{path: path, now: func() time.Time { return time.Now().UTC() }}
80 data, err := os.ReadFile(path)
81 switch {
82 case errors.Is(err, os.ErrNotExist):
83 l.file = ledgerFile{Version: ledgerVersion, Operations: map[string]*Operation{}}
84 return l, nil
85 case err != nil:
86 return nil, err
87 }
88 if err := json.Unmarshal(data, &l.file); err != nil {
89 return nil, fmt.Errorf("browser ledger %s: %w", path, err)
90 }
91 if l.file.Version != ledgerVersion {
92 return nil, fmt.Errorf("browser ledger %s: unsupported version %d", path, l.file.Version)
93 }
94 if l.file.Operations == nil {
95 l.file.Operations = map[string]*Operation{}
96 }
97 recovered := false
98 for _, op := range l.file.Operations {
99 if op.State == StateReserved {
100 op.State, op.SettledAt, op.Reason = StateUnknown, l.now(), "process exited before settlement"
101 recovered = true
102 }
103 }
104 if recovered {
105 if err := l.persistLocked(); err != nil {
106 return nil, err
107 }
108 }
109 return l, nil
110 }
111
112 // Reserve durably records the operation before any side effect. A repeated
113 // ID fails even when the earlier attempt is unknown: the caller must read the
114 // page again and mint a new ID instead of replaying.
115 func (l *Ledger) Reserve(op Operation) error {
116 if !operationIDRe.MatchString(op.ID) {
117 return ErrInvalidOperationID
118 }
119 l.mu.Lock()
120 defer l.mu.Unlock()
121 if _, exists := l.file.Operations[op.ID]; exists {
122 return ErrDuplicateOperation
123 }
124 op.State = StateReserved
125 op.ReservedAt = l.now()
126 op.SettledAt = time.Time{}
127 stored := op
128 l.file.Operations[op.ID] = &stored
129 if err := l.persistLocked(); err != nil {
130 delete(l.file.Operations, op.ID)
131 return err
132 }
133 return nil
134 }
135
136 // Settle records the outcome of a reserved operation exactly once.
137 func (l *Ledger) Settle(id string, state State, reason string) error {
138 if state == StateReserved {
139 return fmt.Errorf("browser operation %s: cannot settle to reserved", id)
140 }
141 l.mu.Lock()
142 defer l.mu.Unlock()
143 op, ok := l.file.Operations[id]
144 if !ok {
145 return ErrUnknownOperation
146 }
147 if op.State != StateReserved {
148 return ErrAlreadySettled
149 }
150 previous := *op
151 op.State, op.SettledAt, op.Reason = state, l.now(), reason
152 if err := l.persistLocked(); err != nil {
153 *op = previous
154 return err
155 }
156 return nil
157 }
158
159 // Lookup returns a copy of the recorded operation.
160 func (l *Ledger) Lookup(id string) (Operation, bool) {
161 l.mu.Lock()
162 defer l.mu.Unlock()
163 op, ok := l.file.Operations[id]
164 if !ok {
165 return Operation{}, false
166 }
167 return *op, true
168 }
169
170 // Unsettled lists operations whose outcome is unknown, oldest first, so the
171 // UI can show the user what may have reached a website.
172 func (l *Ledger) Unsettled() []Operation {
173 l.mu.Lock()
174 defer l.mu.Unlock()
175 var out []Operation
176 for _, op := range l.file.Operations {
177 if op.State == StateUnknown {
178 out = append(out, *op)
179 }
180 }
181 sort.Slice(out, func(i, j int) bool { return out[i].ReservedAt.Before(out[j].ReservedAt) })
182 return out
183 }
184
185 func (l *Ledger) persistLocked() error {
186 data, err := json.MarshalIndent(&l.file, "", " ")
187 if err != nil {
188 return err
189 }
190 if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
191 return err
192 }
193 suffix := make([]byte, 8)
194 if _, err := rand.Read(suffix); err != nil {
195 return err
196 }
197 tmp := l.path + "." + hex.EncodeToString(suffix) + ".tmp"
198 f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
199 if err != nil {
200 return err
201 }
202 if _, err := f.Write(data); err != nil {
203 _ = f.Close()
204 _ = os.Remove(tmp)
205 return err
206 }
207 if err := f.Sync(); err != nil {
208 _ = f.Close()
209 _ = os.Remove(tmp)
210 return err
211 }
212 if err := f.Close(); err != nil {
213 _ = os.Remove(tmp)
214 return err
215 }
216 return os.Rename(tmp, l.path)
217 }
218
218 lines GO