296 lines
8.9 KiB
Go
296 lines
8.9 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
|
)
|
|
|
|
// ErrLockConflict is returned when a session lock is currently held.
|
|
var ErrLockConflict = errors.New("session workdir is already locked")
|
|
|
|
// LockHandle tracks a held lock for a session work directory.
|
|
type LockHandle struct {
|
|
path string
|
|
file *os.File
|
|
}
|
|
|
|
// LocalStore stores artifacts and session working state on the local filesystem.
|
|
type LocalStore struct {
|
|
WorkspaceRoot string
|
|
}
|
|
|
|
// NewLocalStore constructs a local store rooted at workspaceRoot.
|
|
func NewLocalStore(workspaceRoot string) *LocalStore {
|
|
return &LocalStore{WorkspaceRoot: workspaceRoot}
|
|
}
|
|
|
|
// SessionPathsFor resolves canonical campaign-aware paths for a session workdir.
|
|
func (s *LocalStore) SessionPathsFor(campaign, sessionID string) SessionPaths {
|
|
return buildSessionPaths(s.WorkspaceRoot, campaign, sessionID)
|
|
}
|
|
|
|
// EnsureLayoutFor creates and verifies campaign-aware session layout.
|
|
func (s *LocalStore) EnsureLayoutFor(campaign, sessionID string) (SessionPaths, error) {
|
|
if strings.TrimSpace(s.WorkspaceRoot) == "" {
|
|
return SessionPaths{}, fmt.Errorf("workspace root is required")
|
|
}
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return SessionPaths{}, fmt.Errorf("sessionID is required")
|
|
}
|
|
|
|
campaign = strings.TrimSpace(campaign)
|
|
if campaign == "" {
|
|
return SessionPaths{}, fmt.Errorf("campaign is required")
|
|
}
|
|
if err := ValidateSessionIdentity(campaign, sessionID); err != nil {
|
|
return SessionPaths{}, err
|
|
}
|
|
|
|
return s.ensureLayout(s.SessionPathsFor(campaign, sessionID))
|
|
}
|
|
|
|
func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) {
|
|
if strings.TrimSpace(s.WorkspaceRoot) == "" {
|
|
return SessionPaths{}, fmt.Errorf("workspace root is required")
|
|
}
|
|
if strings.TrimSpace(paths.SessionID) == "" {
|
|
return SessionPaths{}, fmt.Errorf("sessionID is required")
|
|
}
|
|
if err := ValidateSessionIdentity(paths.CampaignID, paths.SessionID); err != nil {
|
|
return SessionPaths{}, err
|
|
}
|
|
|
|
dirs := []string{
|
|
paths.Root,
|
|
paths.InputsDir,
|
|
paths.AudioDir,
|
|
paths.TranscriptsDir,
|
|
paths.TranscriptsRawDir,
|
|
paths.TranscriptsTrimmedDir,
|
|
paths.ArtifactsDir,
|
|
paths.ReportsDir,
|
|
paths.ConfigDir,
|
|
paths.LogsDir,
|
|
paths.CurrentDir,
|
|
paths.RunsDir,
|
|
paths.PreviousDir,
|
|
paths.PreviousArtifactsDir,
|
|
}
|
|
|
|
for _, dir := range dirs {
|
|
if err := fileops.EnsureWorkspaceDirectory(dir); err != nil {
|
|
return SessionPaths{}, fmt.Errorf("ensure layout: create %q: %w", dir, err)
|
|
}
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// CopyInputFor copies an input file into the campaign-aware session workdir under destRelativePath.
|
|
func (s *LocalStore) CopyInputFor(campaign, sessionID, srcPath, destRelativePath string) (Ref, error) {
|
|
paths, err := s.EnsureLayoutFor(campaign, sessionID)
|
|
if err != nil {
|
|
return Ref{}, err
|
|
}
|
|
return s.copyInputWithPaths(paths, sessionID, srcPath, destRelativePath)
|
|
}
|
|
|
|
func (s *LocalStore) copyInputWithPaths(paths SessionPaths, sessionID, srcPath, destRelativePath string) (Ref, error) {
|
|
destAbs, err := resolveInRoot(paths.Root, destRelativePath)
|
|
if err != nil {
|
|
return Ref{}, fmt.Errorf("copy input: %w", err)
|
|
}
|
|
|
|
if err := fileops.CopyFileAtomic(srcPath, destAbs, fileops.WorkspaceFileMode); err != nil {
|
|
return Ref{}, fmt.Errorf("copy input %q -> %q: %w", srcPath, destAbs, err)
|
|
}
|
|
|
|
checksum, err := s.Checksum(destAbs)
|
|
if err != nil {
|
|
return Ref{}, fmt.Errorf("copy input checksum %q: %w", destAbs, err)
|
|
}
|
|
|
|
return Ref{
|
|
Kind: "input",
|
|
Category: "inputs",
|
|
SessionID: sessionID,
|
|
RelativePath: filepath.Clean(destRelativePath),
|
|
AbsolutePath: destAbs,
|
|
Checksum: checksum,
|
|
}, nil
|
|
}
|
|
|
|
// Exists reports whether path exists.
|
|
func (s *LocalStore) Exists(path string) (bool, error) {
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("exists %q: %w", path, err)
|
|
}
|
|
|
|
// ExistsRef reports whether ref.AbsolutePath exists.
|
|
func (s *LocalStore) ExistsRef(ref Ref) (bool, error) {
|
|
if strings.TrimSpace(ref.AbsolutePath) == "" {
|
|
return false, fmt.Errorf("exists ref: absolute path is required")
|
|
}
|
|
return s.Exists(ref.AbsolutePath)
|
|
}
|
|
|
|
// WriteFileAtomic writes a file via temp-file + rename within the same directory.
|
|
func (s *LocalStore) WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("write file atomic: path is required")
|
|
}
|
|
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
|
|
return fmt.Errorf("write file atomic: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Checksum computes SHA-256 for a file path.
|
|
func (s *LocalStore) Checksum(path string) (string, error) {
|
|
digest, err := SHA256File(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("checksum %q: %w", path, err)
|
|
}
|
|
return digest, nil
|
|
}
|
|
|
|
// AcquireSessionLockFor acquires an exclusive lock file for a campaign/session workdir.
|
|
func (s *LocalStore) AcquireSessionLockFor(campaign, sessionID string) (*LockHandle, error) {
|
|
paths, err := s.EnsureLayoutFor(campaign, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.acquireSessionLockForPaths(paths)
|
|
}
|
|
|
|
// AcquireSessionLockForContext waits for a session lock until it becomes
|
|
// available or ctx is cancelled.
|
|
func (s *LocalStore) AcquireSessionLockForContext(ctx context.Context, campaign, sessionID string) (*LockHandle, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
paths, err := s.EnsureLayoutFor(campaign, sessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for {
|
|
lock, err := s.acquireSessionLockForPaths(paths)
|
|
if !errors.Is(err, ErrLockConflict) {
|
|
return lock, err
|
|
}
|
|
timer := time.NewTimer(100 * time.Millisecond)
|
|
select {
|
|
case <-ctx.Done():
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
return nil, fmt.Errorf("wait for session lock %q: %w", paths.LockPath, ctx.Err())
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *LocalStore) acquireSessionLockForPaths(paths SessionPaths) (*LockHandle, error) {
|
|
f, err := fileops.OpenFileConfined(paths.LockPath, os.O_CREATE|os.O_RDWR, fileops.WorkspaceFileMode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acquire lock %q: %w", paths.LockPath, err)
|
|
}
|
|
if err := acquireHeldFileLockFn(f); err != nil {
|
|
closeErr := f.Close()
|
|
if errors.Is(err, errHeldLockConflict) {
|
|
return nil, fmt.Errorf("%w: %s", ErrLockConflict, paths.LockPath)
|
|
}
|
|
if closeErr != nil {
|
|
return nil, errors.Join(fmt.Errorf("acquire lock %q: hold: %w", paths.LockPath, err), closeErr)
|
|
}
|
|
return nil, fmt.Errorf("acquire lock %q: hold: %w", paths.LockPath, err)
|
|
}
|
|
failed := true
|
|
defer func() {
|
|
if failed {
|
|
_ = releaseHeldFileLockFn(f)
|
|
_ = f.Close()
|
|
}
|
|
}()
|
|
if err := f.Chmod(fileops.WorkspaceFileMode); err != nil {
|
|
return nil, fmt.Errorf("acquire lock %q: set permissions: %w", paths.LockPath, err)
|
|
}
|
|
if err := f.Truncate(0); err != nil {
|
|
return nil, fmt.Errorf("acquire lock %q: clear metadata: %w", paths.LockPath, err)
|
|
}
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
return nil, fmt.Errorf("acquire lock %q: seek metadata: %w", paths.LockPath, err)
|
|
}
|
|
|
|
metadata := "pid=" + strconv.Itoa(os.Getpid()) + "\nacquired_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n"
|
|
if _, err := io.WriteString(f, metadata); err != nil {
|
|
return nil, fmt.Errorf("acquire lock %q: write metadata: %w", paths.LockPath, err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return nil, fmt.Errorf("acquire lock %q: sync: %w", paths.LockPath, err)
|
|
}
|
|
|
|
failed = false
|
|
return &LockHandle{path: paths.LockPath, file: f}, nil
|
|
}
|
|
|
|
// ReleaseSessionLock releases a previously acquired session lock.
|
|
func (s *LocalStore) ReleaseSessionLock(lock *LockHandle) error {
|
|
if lock == nil {
|
|
return nil
|
|
}
|
|
|
|
if lock.file == nil {
|
|
return nil
|
|
}
|
|
releaseErr := releaseHeldFileLockFn(lock.file)
|
|
closeErr := closeHeldLockFile(lock.file)
|
|
lock.file = nil
|
|
if releaseErr != nil && closeErr != nil {
|
|
return fmt.Errorf("release lock %q: %w", lock.path, errors.Join(releaseErr, closeErr))
|
|
}
|
|
if releaseErr != nil {
|
|
return fmt.Errorf("release lock %q: unlock: %w", lock.path, releaseErr)
|
|
}
|
|
if closeErr != nil {
|
|
return fmt.Errorf("release lock %q: close: %w", lock.path, closeErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveInRoot(root, relative string) (string, error) {
|
|
joined, err := pathsafe.JoinSlashRelativeUnderRoot(root, filepath.ToSlash(relative))
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, pathsafe.ErrRelativePathRequired):
|
|
return "", fmt.Errorf("relative destination path is required")
|
|
case errors.Is(err, pathsafe.ErrRelativePathAbsolute):
|
|
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
|
|
case errors.Is(err, pathsafe.ErrRelativePathEscape):
|
|
return "", fmt.Errorf("relative destination escapes root: %q", relative)
|
|
default:
|
|
return "", fmt.Errorf("resolve destination in root: %w", err)
|
|
}
|
|
}
|
|
if strings.TrimSpace(joined) == "" {
|
|
return "", fmt.Errorf("relative destination path is required")
|
|
}
|
|
return joined, nil
|
|
}
|