返回 DeepSeek-Reasonix
observation.go
根目录 / internal / fileops / observation.go
1 // Package fileops owns the host-side observation state used by structured
2 // file tools. Models never receive or provide these versions: a successful
3 // read records one and a later mutation compares it with the current source.
4 package fileops
5
6 import (
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "fmt"
11 "maps"
12 "os"
13 "path/filepath"
14 "reflect"
15 "slices"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20 )
21
22 type ObservationKind uint8
23
24 const (
25 Unseen ObservationKind = iota
26 Absent
27 Present
28 )
29
30 // Target is a normalized source identity. Route separates disk files from
31 // unsaved editor buffers; Key includes the host file identity when available.
32 type Target struct {
33 Route string
34 Key string
35 Path string
36 }
37
38 // Version is an opaque value produced by the host.
39 type Version string
40
41 type Observation struct {
42 Kind ObservationKind
43 Version Version
44 }
45
46 // Store belongs to one live agent session. It is intentionally not serialized.
47 type Store struct {
48 mu sync.Mutex
49 items map[string]Observation
50 paths map[string]Observation
51 }
52
53 func NewStore() *Store {
54 return &Store{items: make(map[string]Observation), paths: make(map[string]Observation)}
55 }
56
57 // Clone transfers live observations to a replacement runtime in the same
58 // session. It is never serialized or reconstructed from transcript data.
59 func (s *Store) Clone() *Store {
60 copy := NewStore()
61 if s == nil {
62 return copy
63 }
64 s.mu.Lock()
65 defer s.mu.Unlock()
66 maps.Copy(copy.items, s.items)
67 maps.Copy(copy.paths, s.paths)
68 return copy
69 }
70
71 func (s *Store) Get(target Target) Observation {
72 if s == nil {
73 return Observation{}
74 }
75 s.mu.Lock()
76 defer s.mu.Unlock()
77 if observation, ok := s.items[target.Route+"\x00"+target.Key]; ok {
78 return observation
79 }
80 return s.paths[target.Route+"\x00"+target.Path]
81 }
82
83 func (s *Store) ObservePresent(target Target, version Version) {
84 if s == nil || target.Key == "" || version == "" {
85 return
86 }
87 s.mu.Lock()
88 observation := Observation{Kind: Present, Version: version}
89 s.items[target.Route+"\x00"+target.Key] = observation
90 s.paths[target.Route+"\x00"+target.Path] = observation
91 s.mu.Unlock()
92 }
93
94 func (s *Store) ObserveAbsent(target Target) {
95 if s == nil || target.Key == "" {
96 return
97 }
98 s.mu.Lock()
99 observation := Observation{Kind: Absent}
100 s.items[target.Route+"\x00"+target.Key] = observation
101 s.paths[target.Route+"\x00"+target.Path] = observation
102 s.mu.Unlock()
103 }
104
105 func (s *Store) Forget(target Target) {
106 if s == nil {
107 return
108 }
109 s.mu.Lock()
110 delete(s.items, target.Route+"\x00"+target.Key)
111 delete(s.paths, target.Route+"\x00"+target.Path)
112 s.mu.Unlock()
113 }
114
115 type storeKey struct{}
116
117 func WithStore(ctx context.Context, store *Store) context.Context {
118 return context.WithValue(ctx, storeKey{}, store)
119 }
120
121 func FromContext(ctx context.Context) *Store {
122 store, _ := ctx.Value(storeKey{}).(*Store)
123 return store
124 }
125
126 // DiskTarget canonicalizes symlinks and uses the native file identity fields
127 // exposed by os.FileInfo.Sys when the file exists. This makes hard-link aliases
128 // share observation and mutation-lock identities on supported platforms.
129 func DiskTarget(path string, info os.FileInfo) Target {
130 path = canonicalPath(path)
131 if identity, _ := diskNativeSnapshot(path); identity != "" {
132 return Target{Route: "disk", Key: "native:" + identity, Path: path}
133 }
134 if info != nil {
135 if identity := nativeIdentity(info); identity != "" {
136 return Target{Route: "disk", Key: "native:" + identity, Path: path}
137 }
138 }
139 return Target{Route: "disk", Key: "path:" + path, Path: path}
140 }
141
142 // DiskSnapshot returns a target identity and version derived from one path
143 // observation. Windows augments os.FileInfo with volume, file-index, and
144 // change-time data obtained from the native handle APIs.
145 func DiskSnapshot(path string, info os.FileInfo) (Target, Version) {
146 path = canonicalPath(path)
147 identity, native := diskNativeSnapshot(path)
148 return diskSnapshot(path, info, identity, native)
149 }
150
151 // DiskHandleSnapshot derives native identity and change metadata from the open
152 // file handle that supplied the bytes. This prevents a pathname replacement
153 // during a bounded read from being mistaken for the source that was observed.
154 func DiskHandleSnapshot(path string, file *os.File, info os.FileInfo) (Target, Version) {
155 path = canonicalPath(path)
156 identity, native := diskNativeHandleSnapshot(file)
157 return diskSnapshot(path, info, identity, native)
158 }
159
160 func diskSnapshot(path string, info os.FileInfo, identity string, native []string) (Target, Version) {
161 if identity == "" && info != nil {
162 identity = nativeIdentity(info)
163 }
164 key := "path:" + path
165 if identity != "" {
166 key = "native:" + identity
167 }
168 target := Target{Route: "disk", Key: key, Path: path}
169 parts := diskVersionParts(info)
170 parts = append(parts, native...)
171 if len(parts) == 0 {
172 return target, ""
173 }
174 sort.Strings(parts)
175 sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
176 return target, Version("disk-v1:" + hex.EncodeToString(sum[:]))
177 }
178
179 func OverlayTarget(path string) Target {
180 path = canonicalPath(path)
181 return Target{Route: "overlay", Key: "path:" + path, Path: path}
182 }
183
184 // OverlayTargetWithIdentity binds an observation to the transport/buffer owner
185 // that supplied the text. The identity is hashed so it stays host-internal and
186 // never appears in a tool result or serialized session.
187 func OverlayTargetWithIdentity(path, identity string) Target {
188 identity = strings.TrimSpace(identity)
189 if identity == "" {
190 return OverlayTarget(path)
191 }
192 sum := sha256.Sum256([]byte(identity))
193 target := OverlayTarget(path)
194 target.Route = "overlay:" + hex.EncodeToString(sum[:])
195 return target
196 }
197
198 func canonicalPath(path string) string {
199 path = filepath.Clean(path)
200 if abs, err := filepath.Abs(path); err == nil {
201 path = abs
202 }
203 // Resolve the nearest existing ancestor too: an absent child beneath a
204 // symlinked directory must keep its identity before and after creation.
205 ancestor, suffix := path, ""
206 for {
207 if resolved, err := filepath.EvalSymlinks(ancestor); err == nil {
208 path = filepath.Join(resolved, suffix)
209 break
210 }
211 parent := filepath.Dir(ancestor)
212 if parent == ancestor {
213 break
214 }
215 suffix = filepath.Join(filepath.Base(ancestor), suffix)
216 ancestor = parent
217 }
218 return filepath.Clean(path)
219 }
220
221 // DiskVersion deliberately uses metadata only. A window read therefore never
222 // scans the rest of a large file merely to mint a version.
223 func DiskVersion(info os.FileInfo) Version {
224 parts := diskVersionParts(info)
225 if len(parts) == 0 {
226 return ""
227 }
228 sort.Strings(parts)
229 sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
230 return Version("disk-v1:" + hex.EncodeToString(sum[:]))
231 }
232
233 func diskVersionParts(info os.FileInfo) []string {
234 if info == nil {
235 return nil
236 }
237 parts := []string{
238 fmt.Sprintf("size=%d", info.Size()),
239 fmt.Sprintf("mode=%#o", uint32(info.Mode())),
240 fmt.Sprintf("mtime=%d", info.ModTime().UnixNano()),
241 }
242 if sys := nativeMetadata(info); len(sys) != 0 {
243 parts = append(parts, sys...)
244 }
245 return parts
246 }
247
248 func OverlayVersion(content string) Version {
249 sum := sha256.Sum256([]byte(content))
250 return Version("overlay-v1:" + hex.EncodeToString(sum[:]))
251 }
252
253 // nativeMetadata extracts stable scalar and time fields without importing a
254 // platform-specific syscall type. This includes inode/device/ctime on Unix and
255 // file index/change timestamps from the native Windows file info structure.
256 func nativeMetadata(info os.FileInfo) []string {
257 v := reflect.ValueOf(info.Sys())
258 if !v.IsValid() {
259 return nil
260 }
261 for v.Kind() == reflect.Pointer {
262 if v.IsNil() {
263 return nil
264 }
265 v = v.Elem()
266 }
267 if v.Kind() != reflect.Struct {
268 return nil
269 }
270 var out []string
271 for i := range v.NumField() {
272 field := v.Type().Field(i)
273 name := strings.ToLower(field.Name)
274 if !metadataField(name) {
275 continue
276 }
277 if value, ok := scalarValue(v.Field(i)); ok {
278 out = append(out, name+"="+value)
279 }
280 }
281 return out
282 }
283
284 func metadataField(name string) bool {
285 for _, part := range []string{"dev", "ino", "fileindex", "volume", "ctim", "change", "mtim", "lastwrite", "creation", "mode", "nlink", "uid", "gid"} {
286 if strings.Contains(name, part) {
287 return true
288 }
289 }
290 return false
291 }
292
293 func scalarValue(v reflect.Value) (string, bool) {
294 if !v.IsValid() {
295 return "", false
296 }
297 switch v.Kind() {
298 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
299 return fmt.Sprintf("%d", v.Int()), true
300 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
301 return fmt.Sprintf("%d", v.Uint()), true
302 case reflect.Struct:
303 if v.CanInterface() {
304 if t, ok := v.Interface().(time.Time); ok {
305 return fmt.Sprintf("%d", t.UnixNano()), true
306 }
307 }
308 var parts []string
309 for i := range v.NumField() {
310 if value, ok := scalarValue(v.Field(i)); ok {
311 parts = append(parts, value)
312 }
313 }
314 if len(parts) != 0 {
315 return strings.Join(parts, ":"), true
316 }
317 }
318 return "", false
319 }
320
321 func nativeIdentity(info os.FileInfo) string {
322 var parts []string
323 for _, item := range nativeMetadata(info) {
324 name := strings.SplitN(item, "=", 2)[0]
325 if strings.Contains(name, "dev") || strings.Contains(name, "ino") || strings.Contains(name, "fileindex") || strings.Contains(name, "volume") {
326 parts = append(parts, item)
327 }
328 }
329 if len(parts) == 0 {
330 return ""
331 }
332 sort.Strings(parts)
333 return strings.Join(parts, ",")
334 }
335
336 const lockStripes = 257
337
338 var mutationLocks [lockStripes]sync.Mutex
339
340 // Lock serializes mutations of one normalized target within this host process.
341 func Lock(target Target) func() {
342 return LockMany(target)
343 }
344
345 // LockMany acquires unique striped locks in stable order.
346 func LockMany(targets ...Target) func() {
347 indices := make([]int, 0, len(targets))
348 seen := make(map[int]struct{}, len(targets))
349 for _, target := range targets {
350 // Replacement changes the inode. Keep a stable path lock as well as
351 // the native identity lock, so new arrivals cannot bypass old waiters.
352 for _, key := range []string{target.Key, "path:" + target.Path} {
353 sum := sha256.Sum256([]byte(target.Route + "\x00" + key))
354 index := int((uint16(sum[0])<<8 | uint16(sum[1])) % lockStripes)
355 if _, ok := seen[index]; !ok {
356 seen[index] = struct{}{}
357 indices = append(indices, index)
358 }
359 }
360 }
361 sort.Ints(indices)
362 for _, index := range indices {
363 mutationLocks[index].Lock()
364 }
365 return func() {
366 for _, index := range slices.Backward(indices) {
367 mutationLocks[index].Unlock()
368 }
369 }
370 }
371
371 lines GO