mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
382 lines
11 KiB
Go
382 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ErrFileChangedOnDisk is returned by CheckConflict when a file's current
|
|
// on-disk content no longer matches what a tool last read or wrote in this
|
|
// session — i.e. it was modified outside Zero. The write tools surface it with
|
|
// guidance to re-read before retrying, so a stale overwrite cannot silently
|
|
// clobber an external edit.
|
|
var ErrFileChangedOnDisk = errors.New("the file changed on disk since you last read it")
|
|
|
|
// FileVersion is what a tool last observed for a file: an authoritative content
|
|
// hash plus cheap stat metadata kept for diagnostics.
|
|
type FileVersion struct {
|
|
Hash string
|
|
Size int64
|
|
MTime time.Time
|
|
}
|
|
|
|
// FileTracker records, per absolute path, the version of each file a tool read
|
|
// or wrote within a single session. A later write compares the current on-disk
|
|
// content against this baseline to detect a modification made outside Zero.
|
|
//
|
|
// Construct with NewFileTracker. A nil *FileTracker is a valid no-op — every
|
|
// method tolerates it — so a caller that has not wired the feature (tests, MCP)
|
|
// needs no nil guards.
|
|
type FileTracker struct {
|
|
mu sync.Mutex
|
|
versions map[string]FileVersion
|
|
seen map[string]fileObservation
|
|
// created preserves the order in which brand-new files (paths that did not
|
|
// exist before a write tool created them) were first written this session.
|
|
// A map would lose that order and complicates de-duplication, so a slice
|
|
// plus a seen-set is used instead.
|
|
created []string
|
|
createdSeen map[string]bool
|
|
}
|
|
|
|
func NewFileTracker() *FileTracker {
|
|
return &FileTracker{
|
|
versions: make(map[string]FileVersion),
|
|
seen: make(map[string]fileObservation),
|
|
}
|
|
}
|
|
|
|
type lineRange struct {
|
|
start int
|
|
end int
|
|
}
|
|
|
|
type fileObservation struct {
|
|
whole bool
|
|
// total is the file's line count as of the last recorded read, so coverage
|
|
// can be judged from the ranges alone. Without it a file read in pieces
|
|
// could never be known to have been seen in full.
|
|
total int
|
|
ranges []lineRange
|
|
totalBytes int
|
|
byteRanges []lineRange // zero-based, half-open byte intervals
|
|
}
|
|
|
|
type pendingFileObservation struct {
|
|
path string
|
|
output string
|
|
hash string
|
|
start int
|
|
end int
|
|
total int
|
|
byteMode bool
|
|
}
|
|
|
|
// Record stores the version of absPath given its content and optional stat info.
|
|
func (tracker *FileTracker) Record(absPath string, content []byte, info os.FileInfo) {
|
|
tracker.RecordHash(absPath, HashContent(content), info)
|
|
}
|
|
|
|
// RecordHash stores the version of absPath when the caller already computed
|
|
// the same HashContent-compatible SHA-256 while reading the file.
|
|
func (tracker *FileTracker) RecordHash(absPath string, hash string, info os.FileInfo) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
version := FileVersion{Hash: hash}
|
|
if info != nil {
|
|
version.Size = info.Size()
|
|
version.MTime = info.ModTime()
|
|
}
|
|
tracker.mu.Lock()
|
|
if previous, ok := tracker.versions[absPath]; !ok || previous.Hash != hash {
|
|
delete(tracker.seen, absPath)
|
|
}
|
|
tracker.versions[absPath] = version
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// RecordSeenRange records exact, non-elided source lines returned to the model.
|
|
// Ranges accumulate while the content hash remains unchanged.
|
|
func (tracker *FileTracker) RecordSeenRange(absPath string, start, end, total int) {
|
|
if tracker == nil || start < 1 {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
observation := tracker.seen[absPath]
|
|
if total == 0 {
|
|
observation.whole = true
|
|
observation.ranges = nil
|
|
tracker.seen[absPath] = observation
|
|
return
|
|
}
|
|
if end < start {
|
|
return
|
|
}
|
|
// A new line count means the file changed shape since the last read, so
|
|
// previously recorded ranges no longer describe it.
|
|
if observation.total != 0 && observation.total != total {
|
|
observation = fileObservation{}
|
|
}
|
|
observation.total = total
|
|
if start == 1 && end >= total {
|
|
observation.whole = true
|
|
observation.ranges = nil
|
|
tracker.seen[absPath] = observation
|
|
return
|
|
}
|
|
observation.ranges = append(observation.ranges, lineRange{start: start, end: end})
|
|
tracker.seen[absPath] = observation
|
|
}
|
|
|
|
// RecordSeenBytes records an exact, non-elided byte interval returned to the
|
|
// model. Byte intervals make oversized single-line files recoverable without
|
|
// crediting the model for the omitted middle of a truncated line read.
|
|
func (tracker *FileTracker) RecordSeenBytes(absPath string, start, end, total int) {
|
|
if tracker == nil || start < 0 || end < start || total < 0 || end > total {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
observation := tracker.seen[absPath]
|
|
if total == 0 {
|
|
observation.whole = true
|
|
observation.byteRanges = nil
|
|
tracker.seen[absPath] = observation
|
|
return
|
|
}
|
|
if observation.totalBytes != 0 && observation.totalBytes != total {
|
|
observation = fileObservation{}
|
|
}
|
|
observation.totalBytes = total
|
|
if start == 0 && end >= total {
|
|
observation.whole = true
|
|
observation.byteRanges = nil
|
|
tracker.seen[absPath] = observation
|
|
return
|
|
}
|
|
observation.byteRanges = append(observation.byteRanges, lineRange{start: start, end: end})
|
|
tracker.seen[absPath] = observation
|
|
}
|
|
|
|
// coversFully reports whether ranges together cover every line in [start, end].
|
|
//
|
|
// Ranges are merged rather than scanned line by line: a caller asking about a
|
|
// large file would otherwise walk every line against every recorded range, and
|
|
// the answer is the same.
|
|
func coversFully(ranges []lineRange, start int, end int) bool {
|
|
if start > end {
|
|
return true
|
|
}
|
|
if len(ranges) == 0 {
|
|
return false
|
|
}
|
|
sorted := make([]lineRange, len(ranges))
|
|
copy(sorted, ranges)
|
|
sort.Slice(sorted, func(left int, right int) bool {
|
|
if sorted[left].start != sorted[right].start {
|
|
return sorted[left].start < sorted[right].start
|
|
}
|
|
return sorted[left].end < sorted[right].end
|
|
})
|
|
next := start
|
|
for _, seen := range sorted {
|
|
if seen.start > next {
|
|
return false
|
|
}
|
|
if seen.end >= next {
|
|
next = seen.end + 1
|
|
}
|
|
if next > end {
|
|
return true
|
|
}
|
|
}
|
|
return next > end
|
|
}
|
|
|
|
// coversBytes reports whether half-open byte ranges cover [start, end).
|
|
func coversBytes(ranges []lineRange, start, end int) bool {
|
|
if start >= end {
|
|
return true
|
|
}
|
|
if len(ranges) == 0 {
|
|
return false
|
|
}
|
|
sorted := make([]lineRange, len(ranges))
|
|
copy(sorted, ranges)
|
|
sort.Slice(sorted, func(left, right int) bool {
|
|
if sorted[left].start != sorted[right].start {
|
|
return sorted[left].start < sorted[right].start
|
|
}
|
|
return sorted[left].end < sorted[right].end
|
|
})
|
|
next := start
|
|
for _, seen := range sorted {
|
|
if seen.start > next {
|
|
return false
|
|
}
|
|
if seen.end > next {
|
|
next = seen.end
|
|
}
|
|
if next >= end {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SeenRange reports whether every line in the requested range was returned
|
|
// exactly to the model for the currently tracked version.
|
|
func (tracker *FileTracker) SeenRange(absPath string, start, end int) bool {
|
|
if tracker == nil {
|
|
return true
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
observation, ok := tracker.seen[absPath]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if observation.whole {
|
|
return true
|
|
}
|
|
return coversFully(observation.ranges, start, end)
|
|
}
|
|
|
|
// SeenBytes reports whether every byte in [start, end) was returned exactly to
|
|
// the model for the currently tracked version.
|
|
func (tracker *FileTracker) SeenBytes(absPath string, start, end int) bool {
|
|
if tracker == nil {
|
|
return true
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
observation, ok := tracker.seen[absPath]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return observation.whole || coversBytes(observation.byteRanges, start, end)
|
|
}
|
|
|
|
func (tracker *FileTracker) SeenWhole(absPath string) bool {
|
|
if tracker == nil {
|
|
return true
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
observation, ok := tracker.seen[absPath]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if observation.whole {
|
|
return true
|
|
}
|
|
// Derived from the ranges rather than tracked as its own flag. The flag was
|
|
// only ever set by a SINGLE read covering the file, so a file read in two
|
|
// halves stayed "not seen whole" forever even though SeenRange agreed every
|
|
// line had been seen — and write_file, which gates on this, then refused the
|
|
// overwrite with advice to read the file that could not change the answer.
|
|
if observation.total > 0 && coversFully(observation.ranges, 1, observation.total) {
|
|
return true
|
|
}
|
|
return observation.totalBytes > 0 && coversBytes(observation.byteRanges, 0, observation.totalBytes)
|
|
}
|
|
|
|
// Version returns the recorded version for absPath and whether one exists.
|
|
func (tracker *FileTracker) Version(absPath string) (FileVersion, bool) {
|
|
if tracker == nil {
|
|
return FileVersion{}, false
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
version, ok := tracker.versions[absPath]
|
|
return version, ok
|
|
}
|
|
|
|
// RecordCreated notes that absPath is a brand-new file a tool just created in
|
|
// this session (as opposed to an overwrite of something that already existed).
|
|
func (tracker *FileTracker) RecordCreated(absPath string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
if tracker.createdSeen == nil {
|
|
tracker.createdSeen = make(map[string]bool)
|
|
}
|
|
if tracker.createdSeen[absPath] {
|
|
return
|
|
}
|
|
tracker.createdSeen[absPath] = true
|
|
tracker.created = append(tracker.created, absPath)
|
|
}
|
|
|
|
// CreatedFiles returns the absolute paths of every brand-new file recorded via
|
|
// RecordCreated during this session, in first-created order. The caller owns
|
|
// the returned slice.
|
|
func (tracker *FileTracker) CreatedFiles() []string {
|
|
if tracker == nil {
|
|
return nil
|
|
}
|
|
tracker.mu.Lock()
|
|
defer tracker.mu.Unlock()
|
|
out := make([]string, len(tracker.created))
|
|
copy(out, tracker.created)
|
|
return out
|
|
}
|
|
|
|
// Forget drops any recorded version for absPath, so the next read re-establishes
|
|
// the baseline. Used after a tool rewrites a file by a path other than its own
|
|
// (e.g. apply_patch) where the new content is not cheaply available to Record.
|
|
func (tracker *FileTracker) Forget(absPath string) {
|
|
if tracker == nil {
|
|
return
|
|
}
|
|
tracker.mu.Lock()
|
|
delete(tracker.versions, absPath)
|
|
delete(tracker.seen, absPath)
|
|
tracker.mu.Unlock()
|
|
}
|
|
|
|
// CheckConflict reports ErrFileChangedOnDisk when absPath has a recorded baseline
|
|
// and currentContent (the bytes the caller just read from disk) no longer matches
|
|
// it. An untracked path returns nil: with no baseline there is nothing to
|
|
// conflict against, so a first-touch write is always allowed.
|
|
func (tracker *FileTracker) CheckConflict(absPath string, currentContent []byte) error {
|
|
if tracker == nil {
|
|
return nil
|
|
}
|
|
version, ok := tracker.Version(absPath)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if HashContent(currentContent) != version.Hash {
|
|
return ErrFileChangedOnDisk
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HashContent returns the hex-encoded SHA-256 of content. Exported so the write
|
|
// tools and tests share one definition of a file's identity.
|
|
func HashContent(content []byte) string {
|
|
sum := sha256.Sum256(content)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// fileConflictMessage is the actionable error a write tool returns when a target
|
|
// changed on disk since it was last read, telling the model how to recover.
|
|
func fileConflictMessage(relativePath string) string {
|
|
return "Error writing " + relativePath + ": " + ErrFileChangedOnDisk.Error() +
|
|
" (it may have been edited outside Zero). Re-read it with read_file, then re-apply your change so you do not overwrite the newer content."
|
|
}
|
|
|
|
func fileUnseenMessage(relativePath string) string {
|
|
return "Error writing " + relativePath + ": the intended change depends on content that has not been read exactly in this session. Read the affected lines with read_file using offset/limit (or byte_offset/byte_limit for a very long line), then retry the edit."
|
|
}
|