返回 DeepSeek-Reasonix
manifest.go
根目录 / internal / sessioninbox / manifest.go
1 package sessioninbox
2
3 import (
4 "encoding/json"
5 "fmt"
6 "maps"
7 "path/filepath"
8 "strings"
9 "time"
10 )
11
12 const (
13 maxIdempotencyReceipts = 512
14 idempotencyReceiptTTL = 7 * 24 * time.Hour
15 maxIdempotencyKeyBytes = 1024
16 )
17
18 type idempotencyReceipt struct {
19 ItemID string `json:"itemId"`
20 RequestHash string `json:"requestHash"`
21 Disposition Disposition `json:"disposition"`
22 CompletedAt time.Time `json:"completedAt"`
23 }
24
25 // manifest is the on-disk revisioned metadata file (no bodies).
26 type manifest struct {
27 SchemaVersion int `json:"schemaVersion"`
28 Revision int64 `json:"revision"`
29 RunID string `json:"runId,omitempty"`
30 Paused bool `json:"paused"`
31 Recovered bool `json:"recovered"`
32 RecoveredN int `json:"recoveredCount,omitempty"`
33 Items []InboxItemMeta `json:"items"`
34 Idempotency map[string]string `json:"idempotency,omitempty"` // key -> itemID
35 // IdempotencyHashes fingerprints the original client request, excluding
36 // enqueue-time reference materialization. It covers both live items and
37 // aliases created by collect mode.
38 IdempotencyHashes map[string]string `json:"idempotencyHashes,omitempty"`
39 Receipts map[string]idempotencyReceipt `json:"receipts,omitempty"`
40 UpdatedAt time.Time `json:"updatedAt"`
41 }
42
43 func emptyManifest(runID string) *manifest {
44 return &manifest{
45 SchemaVersion: SchemaVersion,
46 RunID: runID,
47 Items: []InboxItemMeta{},
48 Idempotency: map[string]string{},
49 IdempotencyHashes: map[string]string{},
50 Receipts: map[string]idempotencyReceipt{},
51 UpdatedAt: time.Now().UTC(),
52 }
53 }
54
55 func (m *manifest) clone() *manifest {
56 if m == nil {
57 return emptyManifest("")
58 }
59 out := *m
60 out.Items = append([]InboxItemMeta(nil), m.Items...)
61 if m.Idempotency != nil {
62 out.Idempotency = make(map[string]string, len(m.Idempotency))
63 maps.Copy(out.Idempotency, m.Idempotency)
64 } else {
65 out.Idempotency = map[string]string{}
66 }
67 if m.IdempotencyHashes != nil {
68 out.IdempotencyHashes = make(map[string]string, len(m.IdempotencyHashes))
69 maps.Copy(out.IdempotencyHashes, m.IdempotencyHashes)
70 } else {
71 out.IdempotencyHashes = map[string]string{}
72 }
73 if m.Receipts != nil {
74 out.Receipts = make(map[string]idempotencyReceipt, len(m.Receipts))
75 maps.Copy(out.Receipts, m.Receipts)
76 } else {
77 out.Receipts = map[string]idempotencyReceipt{}
78 }
79 return &out
80 }
81
82 func (m *manifest) totalBytes() int64 {
83 var n int64
84 for _, it := range m.Items {
85 n += it.ByteSize
86 }
87 return n
88 }
89
90 func (m *manifest) indexOf(id string) int {
91 for i, it := range m.Items {
92 if it.ID == id {
93 return i
94 }
95 }
96 return -1
97 }
98
99 func (m *manifest) item(id string) (InboxItemMeta, bool) {
100 i := m.indexOf(id)
101 if i < 0 {
102 return InboxItemMeta{}, false
103 }
104 return m.Items[i], true
105 }
106
107 func (m *manifest) removeItem(id string) (InboxItemMeta, bool) {
108 i := m.indexOf(id)
109 if i < 0 {
110 return InboxItemMeta{}, false
111 }
112 it := m.Items[i]
113 m.Items = append(m.Items[:i], m.Items[i+1:]...)
114 for key, itemID := range m.Idempotency {
115 if itemID == id {
116 delete(m.Idempotency, key)
117 delete(m.IdempotencyHashes, key)
118 }
119 }
120 return it, true
121 }
122
123 func (m *manifest) idempotencyKeysFor(id string) []string {
124 keys := make([]string, 0, 1)
125 for key, itemID := range m.Idempotency {
126 if itemID == id {
127 keys = append(keys, key)
128 }
129 }
130 return keys
131 }
132
133 func (m *manifest) rememberReceipt(keys []string, itemID string, disposition Disposition, now time.Time) {
134 if m.Receipts == nil {
135 m.Receipts = map[string]idempotencyReceipt{}
136 }
137 for _, key := range keys {
138 if strings.TrimSpace(key) == "" {
139 continue
140 }
141 m.Receipts[key] = idempotencyReceipt{
142 ItemID: itemID,
143 RequestHash: m.IdempotencyHashes[key],
144 Disposition: disposition,
145 CompletedAt: now,
146 }
147 }
148 m.pruneReceipts(now)
149 }
150
151 func (m *manifest) pruneReceipts(now time.Time) {
152 for key, receipt := range m.Receipts {
153 if receipt.CompletedAt.IsZero() || now.Sub(receipt.CompletedAt) > idempotencyReceiptTTL {
154 delete(m.Receipts, key)
155 }
156 }
157 for len(m.Receipts) > maxIdempotencyReceipts {
158 oldestKey := ""
159 var oldest time.Time
160 for key, receipt := range m.Receipts {
161 if oldestKey == "" || receipt.CompletedAt.Before(oldest) {
162 oldestKey = key
163 oldest = receipt.CompletedAt
164 }
165 }
166 delete(m.Receipts, oldestKey)
167 }
168 }
169
170 func decodeManifest(data []byte) (*manifest, error) {
171 var m manifest
172 if err := json.Unmarshal(data, &m); err != nil {
173 return nil, err
174 }
175 if m.Items == nil {
176 m.Items = []InboxItemMeta{}
177 }
178 if m.Idempotency == nil {
179 m.Idempotency = map[string]string{}
180 }
181 if m.IdempotencyHashes == nil {
182 m.IdempotencyHashes = map[string]string{}
183 }
184 if m.Receipts == nil {
185 m.Receipts = map[string]idempotencyReceipt{}
186 }
187 if err := validateManifest(&m, m.SchemaVersion > SchemaVersion); err != nil {
188 return nil, err
189 }
190 return &m, nil
191 }
192
193 func validateManifest(m *manifest, allowUnknownEnums bool) error {
194 if m == nil {
195 return fmt.Errorf("nil manifest")
196 }
197 if m.SchemaVersion < 0 || m.Revision < 0 {
198 return fmt.Errorf("invalid manifest version or revision")
199 }
200 ids := make(map[string]struct{}, len(m.Items))
201 for _, item := range m.Items {
202 if !validBlobStem(item.ID) || !validBlobStem(blobNameFor(item)) {
203 return fmt.Errorf("invalid inbox item path")
204 }
205 if _, exists := ids[item.ID]; exists {
206 return fmt.Errorf("duplicate inbox item id")
207 }
208 ids[item.ID] = struct{}{}
209 if item.ByteSize < 0 {
210 return fmt.Errorf("negative inbox item size")
211 }
212 if !allowUnknownEnums && (!validInboxIntent(item.Intent) || !validInboxState(item.State)) {
213 return fmt.Errorf("invalid inbox item intent or state")
214 }
215 if item.Checksum != "" && !validSHA256(item.Checksum) {
216 return fmt.Errorf("invalid inbox item checksum")
217 }
218 }
219 if allowUnknownEnums {
220 return nil
221 }
222 for key, id := range m.Idempotency {
223 if !validIdempotencyKey(key) {
224 return fmt.Errorf("invalid idempotency key")
225 }
226 if _, exists := ids[id]; !exists {
227 return fmt.Errorf("idempotency key references missing item")
228 }
229 hash := m.IdempotencyHashes[key]
230 if hash == "" {
231 if m.SchemaVersion >= 2 {
232 return fmt.Errorf("missing idempotency request hash")
233 }
234 } else if !validSHA256(hash) {
235 return fmt.Errorf("invalid idempotency request hash")
236 }
237 }
238 for key, receipt := range m.Receipts {
239 if !validIdempotencyKey(key) || !validBlobStem(receipt.ItemID) || !validSHA256(receipt.RequestHash) {
240 return fmt.Errorf("invalid idempotency receipt")
241 }
242 }
243 return nil
244 }
245
246 func validBlobStem(name string) bool {
247 if name == "" || strings.TrimSpace(name) != name || len(name) > 200 || name == "." || name == ".." || !filepath.IsLocal(name) || filepath.Base(name) != name {
248 return false
249 }
250 return !strings.ContainsAny(name, "/\\\x00")
251 }
252
253 func validIdempotencyKey(key string) bool {
254 return key != "" && strings.TrimSpace(key) == key && len(key) <= maxIdempotencyKeyBytes && !strings.ContainsRune(key, '\x00')
255 }
256
257 func validSHA256(value string) bool {
258 if len(value) != 64 {
259 return false
260 }
261 for _, r := range value {
262 if !(r >= '0' && r <= '9') && !(r >= 'a' && r <= 'f') {
263 return false
264 }
265 }
266 return true
267 }
268
269 func validInboxIntent(intent InboxIntent) bool {
270 return intent == IntentFollowup || intent == IntentSteer
271 }
272
273 func validInboxState(state InboxState) bool {
274 switch state {
275 case StateQueued, StateSteerAccepted, StateSteerConsumed, StateRunning, StateBlocked, StateUncertain:
276 return true
277 default:
278 return false
279 }
280 }
281
281 lines GO