Ctrl K
ComunidadGuías…

internal/memory/carry_forward.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package memory

import (
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"github.com/yeomyeonggeori/blueclaw/internal/policy"
	"github.com/yeomyeonggeori/blueclaw/internal/security"
	"github.com/yeomyeonggeori/bluememo"
)

const carriedFileSuffix = ".carrying"

// CarryReport is what one carry forward did.
type CarryReport struct {
	Carried int      `json:"carried"`
	Adopted int      `json:"adopted"`
	Left    []string `json:"left"`
	Removed []string `json:"removed"`
}

// CarryForward brings memory written where blueclaw kept every subject's file
// together into that subject's protected directory. It is
// how a host that wrote memory under the old layout keeps it: nothing else
// looks at the old path, so a start that skipped this would answer from an
// empty memory and say nothing.
//
// A store is checkpointed before it is carried, because a file opened in WAL
// mode holds what was written since its last checkpoint in a sidecar, and
// carrying the database alone would truncate it to that checkpoint rather than
// lose it outright. The carry then copies one file, so no partial move can
// leave a store split across two directories.
//
// The copy is created inside the destination directory, which the POSIX
// synchronizer has already made setgid to the subject, so the new file belongs
// to the group that reads it. Carrying the bytes keeps the embeddings a rename
// would keep and a re-ingest would not.
//
// A file already at the destination is never overwritten. Its store adopts
// what the old file holds, the way two records of one person are merged, so
// memory written at either path is kept.
//
// A circle that holds no directory, such as admin, has nowhere to be carried
// to, so its old file is left where it is and named in the report.
func (stores *Stores) CarryForward(ctx context.Context) (CarryReport, error) {
	report := CarryReport{}
	oldDirectory := filepath.Join(stores.workspaceRootPath, ".blueclaw", "memory")
	for _, scope := range oldLayoutScopes(oldDirectory) {
		oldPath := scope.oldPath
		if scope.scope.Kind == ScopeCircle && !policy.CircleHoldsADirectory(scope.scope.ID) {
			report.Left = append(report.Left, oldPath)
			continue
		}
		destination, errorValue := stores.Path(scope.scope)
		if errorValue != nil {
			return report, fmt.Errorf("where %s belongs now: %w", oldPath, errorValue)
		}
		held, errorValue := holdsFile(destination)
		if errorValue != nil {
			return report, errorValue
		}
		if held {
			if errorValue := stores.adoptFile(ctx, oldPath, scope.scope); errorValue != nil {
				return report, fmt.Errorf("fold %s into %s: %w", oldPath, destination, errorValue)
			}
			report.Adopted++
			continue
		}
		if errorValue := stores.carryOne(ctx, oldPath, destination); errorValue != nil {
			return report, errorValue
		}
		report.Carried++
	}
	if errorValue := stores.removeTheFolderOfACircle(ctx, policy.AdminCircleID, &report); errorValue != nil {
		return report, errorValue
	}
	return report, nil
}

// RemoveRetiredCircleFolders takes away the folders of circles nobody holds
// any more, on the same terms as the admin circle's.
func (stores *Stores) RemoveRetiredCircleFolders(ctx context.Context, circleIDs []string) (CarryReport, error) {
	report := CarryReport{}
	for _, circleID := range circleIDs {
		if errorValue := stores.removeTheFolderOfACircle(ctx, circleID, &report); errorValue != nil {
			return report, errorValue
		}
	}
	return report, nil
}

// removeTheFolderOfACircle takes away a circle's folder. Its store is deleted
// only when it keeps nothing, and the folder only when nothing else is in it;
// otherwise the folder is left and named, so neither a memory nor a file a
// person put there is ever removed.
func (stores *Stores) removeTheFolderOfACircle(ctx context.Context, circleID string, report *CarryReport) error {
	folder := security.CircleDirectoryPath(stores.workspaceRootPath, circleID)
	if _, errorValue := os.Stat(folder); errors.Is(errorValue, os.ErrNotExist) {
		return nil
	} else if errorValue != nil {
		return fmt.Errorf("look at %s: %w", folder, errorValue)
	}
	protected := security.ProtectedDirectoryPath(folder)
	isEmpty, errorValue := stores.storeKeepsNothing(ctx, filepath.Join(protected, memoryFileName))
	if errorValue != nil {
		return fmt.Errorf("look into the memory of the %s circle: %w", circleID, errorValue)
	}
	if !isEmpty {
		report.Left = append(report.Left, folder)
		return nil
	}
	for _, name := range []string{memoryFileName, memoryFileName + "-wal", memoryFileName + "-shm", memoryFileName + carriedFileSuffix} {
		if errorValue := os.Remove(filepath.Join(protected, name)); errorValue != nil && !errors.Is(errorValue, os.ErrNotExist) {
			return fmt.Errorf("remove the memory of the %s circle: %w", circleID, errorValue)
		}
	}
	for _, directory := range []string{protected, folder} {
		entries, errorValue := os.ReadDir(directory)
		if errors.Is(errorValue, os.ErrNotExist) {
			continue
		}
		if errorValue != nil {
			return errorValue
		}
		if len(entries) > 0 {
			report.Left = append(report.Left, directory)
			return nil
		}
		if errorValue := os.Remove(directory); errorValue != nil {
			return fmt.Errorf("remove %s: %w", directory, errorValue)
		}
	}
	report.Removed = append(report.Removed, folder)
	return nil
}

type oldLayoutFile struct {
	oldPath string
	scope   Scope
}

func oldLayoutScopes(oldDirectory string) []oldLayoutFile {
	files := []oldLayoutFile{}
	workspacePath := filepath.Join(oldDirectory, "workspace.db")
	if held, _ := holdsFile(workspacePath); held {
		files = append(files, oldLayoutFile{oldPath: workspacePath, scope: WorkspaceScope()})
	}
	for directory, scopeOf := range map[string]func(string) Scope{"persons": PersonScope, "circles": CircleScope} {
		entries, errorValue := os.ReadDir(filepath.Join(oldDirectory, directory))
		if errorValue != nil {
			continue
		}
		for _, entry := range entries {
			if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".db") {
				continue
			}
			subject := strings.TrimSuffix(entry.Name(), ".db")
			files = append(files, oldLayoutFile{
				oldPath: filepath.Join(oldDirectory, directory, entry.Name()),
				scope:   scopeOf(subject),
			})
		}
	}
	return files
}

func (stores *Stores) carryOne(ctx context.Context, oldPath string, destination string) error {
	if errorValue := checkpoint(ctx, oldPath, stores.configuration); errorValue != nil {
		return fmt.Errorf("settle what %s holds in its log before carrying it: %w", oldPath, errorValue)
	}
	if errorValue := os.MkdirAll(filepath.Dir(destination), 0o700); errorValue != nil {
		return fmt.Errorf("make room for %s: %w", destination, errorValue)
	}
	if errorValue := copyFile(oldPath, destination); errorValue != nil {
		return errorValue
	}
	return removeStoreFile(oldPath)
}

// removeStoreFile takes away a store file whose memory now lives elsewhere,
// with the sidecars a WAL-mode file may have left beside it.
func removeStoreFile(path string) error {
	for _, sidecar := range []string{"-wal", "-shm"} {
		_ = os.Remove(path + sidecar)
	}
	if errorValue := os.Remove(path); errorValue != nil {
		return fmt.Errorf("take %s away once its memory lives elsewhere: %w", path, errorValue)
	}
	return nil
}

// checkpoint opens a store only so that closing it folds what its log holds
// into the database, leaving one file to carry.
func (stores *Stores) storeKeepsNothing(ctx context.Context, path string) (bool, error) {
	if held, errorValue := holdsFile(path); errorValue != nil || !held {
		return errorValue == nil, errorValue
	}
	store, errorValue := bluememo.Open(ctx, path, stores.configuration)
	if errorValue != nil {
		return false, errorValue
	}
	defer store.Close()
	return store.IsEmpty(ctx)
}

func checkpoint(ctx context.Context, path string, configuration bluememo.Configuration) error {
	store, errorValue := bluememo.Open(ctx, path, configuration)
	if errorValue != nil {
		return errorValue
	}
	return store.Close()
}

func copyFile(from string, to string) error {
	source, errorValue := os.Open(from)
	if errorValue != nil {
		return fmt.Errorf("read %s: %w", from, errorValue)
	}
	defer source.Close()
	carrying := to + carriedFileSuffix
	destination, errorValue := os.OpenFile(carrying, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
	if errorValue != nil {
		return fmt.Errorf("write %s: %w", carrying, errorValue)
	}
	if _, errorValue = io.Copy(destination, source); errorValue != nil {
		destination.Close()
		_ = os.Remove(carrying)
		return fmt.Errorf("carry %s to %s: %w", from, to, errorValue)
	}
	if errorValue = destination.Sync(); errorValue != nil {
		destination.Close()
		_ = os.Remove(carrying)
		return fmt.Errorf("put %s on disk: %w", carrying, errorValue)
	}
	if errorValue = destination.Close(); errorValue != nil {
		_ = os.Remove(carrying)
		return fmt.Errorf("close %s: %w", carrying, errorValue)
	}
	if errorValue = os.Chmod(carrying, 0o640); errorValue != nil {
		_ = os.Remove(carrying)
		return fmt.Errorf("let the subject's group read %s: %w", carrying, errorValue)
	}
	if errorValue = os.Rename(carrying, to); errorValue != nil {
		_ = os.Remove(carrying)
		return fmt.Errorf("put %s in place: %w", to, errorValue)
	}
	return nil
}

func holdsFile(path string) (bool, error) {
	information, errorValue := os.Stat(path)
	if errors.Is(errorValue, os.ErrNotExist) {
		return false, nil
	}
	if errorValue != nil {
		return false, fmt.Errorf("look at %s: %w", path, errorValue)
	}
	return !information.IsDir(), nil
}