Files
DeepSeek-Reasonix/internal/storage/survey.go
T
yhh b18d8ef3aa feat(studio): storage relocation, composer intake, and the checkpoint agreement
Accumulated work from the branch, carried across the rebase onto origin/studio
and merged with what landed there in the meantime.

Storage: a workspace can name where its state lives, and the server tracks the
one relocation it may be running (internal/storage, config/storage_roots,
serve/storage, the Storage panel).

Composer intake: what a drop or a long paste becomes is one pure function —
payload in, action out — so "what happens if I drop a folder" is a test rather
than a branch of an onDrop handler. It sits on the drop router that landed
upstream: that one answers which pane a drop hit, this one answers what it
means and says so before the pointer is released. A paste long enough to bury
the composer becomes a chip beside it instead.

Session: the checkpoint agreement and the lineage/torn-log recovery cases,
plus the conflict diagnostic learning where two transcripts parted and which
roles stood there — a position and a role tell a local reshape from a foreign
write without recording any content.

Model refs: the default-model write checks the live catalog, so an extension
model the runtime knows can be persisted as the default.
2026-08-19 23:52:04 -07:00

167 lines
5.0 KiB
Go

// Package storage measures what the runtime keeps on disk: how much each
// declared root holds, and what the volume under it has left. It reads the root
// table rather than a list of its own, so a root declared in internal/config is
// accounted for here without this package being told about it.
package storage
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/config"
)
// Root is one root as measured: where it resolved to, whether a user may move
// it, and what it costs. Bytes and Files are what the walk actually counted —
// a surface that offers to delete things reports measured sizes, never
// estimates, because that number is what the decision is made on.
type Root struct {
ID config.RootID
Dir string
Relocatable bool
// PinnedBy names the environment variable holding this root, "" when none
// does. A root that is pinned cannot be moved from the application.
PinnedBy string
Bytes int64
Files int64
// Missing is a root that has never been written. It is not an error: a
// fresh install has no worktrees, and reporting zero is the honest answer.
Missing bool
// Err is set when the walk could not finish. Bytes and Files then describe
// what was reachable, so a permission-denied subtree understates rather
// than blanks the report.
Err string
// Volume describes the filesystem Dir sits on. Roots sharing a volume
// report the same one, which is what lets a reader see that moving one of
// them buys nothing.
Volume Volume
}
// Volume is the free/total pair for the filesystem under a path, plus the
// mount point (a drive on Windows) so a reader recognises which disk it is.
type Volume struct {
Path string
Free int64
Total int64
}
// Survey measures every declared root. It never fails as a whole: a root that
// cannot be read carries its own Err, because a single unreadable directory
// must not deny the user the numbers for the rest.
func Survey(ctx context.Context) []Root {
ids := config.RootIDs()
out := make([]Root, 0, len(ids))
volumes := map[string]Volume{}
for _, id := range ids {
root := Root{
ID: id,
Dir: config.RootDir(id),
Relocatable: config.RootRelocatable(id),
PinnedBy: config.RootPinnedBy(id),
}
if root.Dir == "" {
root.Missing = true
out = append(out, root)
continue
}
root.Bytes, root.Files, root.Missing, root.Err = measureRoot(ctx, id, root.Dir)
key := volumeKey(root.Dir)
vol, ok := volumes[key]
if !ok {
vol = readVolume(root.Dir)
volumes[key] = vol
}
root.Volume = vol
out = append(out, root)
}
return out
}
// measureRoot sizes what a root actually owns. A root sharing its directory
// with another counts only its declared entries, or the report would credit
// state with the configuration sitting beside it.
func measureRoot(ctx context.Context, id config.RootID, dir string) (bytes, files int64, missing bool, errText string) {
owned := config.RootOwns(id)
if len(owned) == 0 {
return measure(ctx, dir)
}
missing = true
for _, name := range owned {
b, f, miss, err := measure(ctx, filepath.Join(dir, name))
bytes += b
files += f
if !miss {
missing = false
}
if err != "" && errText == "" {
errText = err
}
}
return bytes, files, missing, errText
}
// measure walks dir. It counts what it can reach and reports the first refusal
// rather than aborting: an unreadable subtree is a smaller number plus a
// reason, which is more use than no number at all.
func measure(ctx context.Context, dir string) (bytes, files int64, missing bool, errText string) {
// Asked before the walk rather than inferred from it: a root nothing has
// written yet and a root that refused to be read are different answers,
// and the walk reports both as the same first callback error.
if _, err := os.Stat(dir); err != nil {
if os.IsNotExist(err) {
return 0, 0, true, ""
}
return 0, 0, false, err.Error()
}
walkErr := filepath.WalkDir(dir, func(_ string, entry fs.DirEntry, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
if err != nil {
if errText == "" {
errText = err.Error()
}
return nil
}
if entry.IsDir() {
return nil
}
info, infoErr := entry.Info()
if infoErr != nil {
if errText == "" {
errText = infoErr.Error()
}
return nil
}
files++
bytes += info.Size()
return nil
})
if walkErr != nil && errText == "" {
errText = walkErr.Error()
}
return bytes, files, false, errText
}
// volumeKey groups roots that sit on one filesystem, so the free-space probe
// runs once per disk instead of once per root.
func volumeKey(dir string) string {
vol := filepath.VolumeName(dir)
if vol != "" {
return strings.ToLower(vol)
}
return "/"
}
// SortedByBytes orders a survey largest first, which is the order a reader
// deciding what to move or delete wants to read it in.
func SortedByBytes(roots []Root) []Root {
out := append([]Root(nil), roots...)
sort.SliceStable(out, func(i, j int) bool { return out[i].Bytes > out[j].Bytes })
return out
}