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 }