| 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 |