package artifacts import ( "fmt" "path" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" ) // SessionPaths contains canonical local paths for one session work directory. type SessionPaths struct { WorkspaceRoot string CampaignID string SessionID string Root string InputsDir string AudioDir string TranscriptsDir string TranscriptsRawDir string TranscriptsTrimmedDir string ArtifactsDir string ReportsDir string ConfigDir string LogsDir string CurrentDir string RunsDir string PreviousDir string PreviousManifestPath string PreviousArtifactsDir string ManifestPath string LockPath string } // SessionWorkDirForCampaign returns the canonical campaign-aware work directory for one session. func SessionWorkDirForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(rootDir, config.PathWorkDirSegment, campaign, sessionID) } // SessionManifestPathForCampaign returns the canonical session manifest path. func SessionManifestPathForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathManifestFile) } // SessionRunsDirForCampaign returns the canonical runs directory for one session. func SessionRunsDirForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathRunsDirSegment) } // SessionPreviousDirForCampaign returns the canonical previous-session state directory. func SessionPreviousDirForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathPreviousDirSegment) } // SessionPreviousManifestPathForCampaign returns the canonical previous-session manifest cache path. func SessionPreviousManifestPathForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathManifestFile) } // SessionPreviousArtifactsDirForCampaign returns the canonical previous-session artifact cache directory. func SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathArtifactsDirSegment) } // SessionPreviousArtifactPathForCampaign returns a path under previous/artifacts for one artifact. func SessionPreviousArtifactPathForCampaign(rootDir, campaign, sessionID, artifactRelativePath string) string { return filepath.Join( SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID), filepath.FromSlash(previousArtifactCacheRelativePath(artifactRelativePath)), ) } // SessionRunRootForCampaign returns the canonical run root under runs/{run_id}. func SessionRunRootForCampaign(rootDir, campaign, sessionID, runID string) string { return filepath.Join(SessionRunsDirForCampaign(rootDir, campaign, sessionID), runID) } // SessionRunManifestPathForCampaign returns the canonical run manifest path under runs/{run_id}/manifest.json. func SessionRunManifestPathForCampaign(rootDir, campaign, sessionID, runID string) string { return filepath.Join(SessionRunRootForCampaign(rootDir, campaign, sessionID, runID), config.PathManifestFile) } // SessionRunStageDirForCampaign returns the canonical stage directory under runs/{run_id}/{stage}. func SessionRunStageDirForCampaign(rootDir, campaign, sessionID, runID, stageName string) string { return filepath.Join(SessionRunRootForCampaign(rootDir, campaign, sessionID, runID), stageName) } // SessionSpoolAudioDir returns the campaign/session/run scoped local spool audio path. func SessionSpoolAudioDir(spoolRoot, campaign, sessionID, runID string) string { return filepath.Join(spoolRoot, campaign, sessionID, runID, config.PathAudioDirSegment) } // SessionSpoolDir returns the campaign/session scoped local spool root. func SessionSpoolDir(spoolRoot, campaign, sessionID string) string { return filepath.Join(spoolRoot, campaign, sessionID) } // SessionSpoolRestoreAudioDir returns the local spool audio path for restore downloads. func SessionSpoolRestoreAudioDir(spoolRoot, campaign, sessionID string) string { return filepath.Join(SessionSpoolDir(spoolRoot, campaign, sessionID), "restore", config.PathAudioDirSegment) } // S3AudioCachePath returns the durable local cache path for one S3 audio object. func S3AudioCachePath(cacheRoot, bucket, key string) (string, error) { root := filepath.Clean(strings.TrimSpace(cacheRoot)) if root == "." || root == "" { return "", fmt.Errorf("cache root is required") } bucket = strings.Trim(strings.TrimSpace(bucket), "/") if bucket == "" || bucket == "." || bucket == ".." || strings.Contains(bucket, "/") || strings.Contains(bucket, `\`) { return "", fmt.Errorf("bucket is required and must be a single path segment") } rawKey := strings.ReplaceAll(strings.TrimSpace(key), `\`, "/") if strings.HasPrefix(rawKey, "/") { return "", fmt.Errorf("s3 key must not be absolute") } cleanKey := cleanCacheS3Key(rawKey) if cleanKey == "" { return "", fmt.Errorf("s3 key is required") } if cleanKey == ".." || strings.HasPrefix(cleanKey, "../") || strings.HasPrefix(cleanKey, "/") { return "", fmt.Errorf("s3 key must not escape cache root") } return filepath.Join(root, "s3", bucket, filepath.FromSlash(cleanKey)), nil } // S3AudioCacheNamespaceDir returns the durable local cache namespace for all // Narratio S3 audio objects under one bucket/root prefix. func S3AudioCacheNamespaceDir(cacheRoot, bucket, rootPrefix string) (string, error) { sentinelKey := path.Join(cleanS3PathPart(rootPrefix), config.S3CampaignsSegment, ".narratio-cache-sentinel") sentinelPath, err := S3AudioCachePath(cacheRoot, bucket, sentinelKey) if err != nil { return "", err } return filepath.Dir(sentinelPath), nil } func cleanCacheS3Key(key string) string { normalized := strings.ReplaceAll(strings.TrimSpace(key), `\`, "/") normalized = strings.Trim(normalized, "/") if normalized == "" { return "" } return path.Clean(normalized) } // SessionPreviousDir returns the previous-session state directory for already-resolved session paths. func SessionPreviousDir(paths SessionPaths) string { return paths.PreviousDir } // SessionPreviousManifestPath returns the previous-session manifest path for already-resolved session paths. func SessionPreviousManifestPath(paths SessionPaths) string { return paths.PreviousManifestPath } // SessionPreviousArtifactsDir returns the previous-session artifact directory for already-resolved session paths. func SessionPreviousArtifactsDir(paths SessionPaths) string { return paths.PreviousArtifactsDir } // SessionPreviousArtifactPath returns a path under previous/artifacts for already-resolved session paths. func SessionPreviousArtifactPath(paths SessionPaths, artifactRelativePath string) string { return filepath.Join(paths.PreviousArtifactsDir, filepath.FromSlash(previousArtifactCacheRelativePath(artifactRelativePath))) } func previousArtifactCacheRelativePath(artifactRelativePath string) string { rel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(artifactRelativePath)))) if rel == "." { return "" } const artifactsPrefix = "artifacts/" if strings.HasPrefix(rel, artifactsPrefix) && len(rel) > len(artifactsPrefix) { return strings.TrimPrefix(rel, artifactsPrefix) } return rel } func buildSessionPaths(workspaceRoot, campaign, sessionID string) SessionPaths { root := SessionWorkDirForCampaign(workspaceRoot, campaign, sessionID) return buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root) } func buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root string) SessionPaths { return SessionPaths{ WorkspaceRoot: workspaceRoot, CampaignID: campaign, SessionID: sessionID, Root: root, InputsDir: filepath.Join(root, config.PathInputsDirSegment), AudioDir: filepath.Join(root, config.PathAudioDirSegment), TranscriptsDir: filepath.Join(root, config.PathTranscriptsSegment), TranscriptsRawDir: filepath.Join(root, filepath.FromSlash(config.PathTranscriptsRaw)), TranscriptsTrimmedDir: filepath.Join(root, filepath.FromSlash(config.PathTranscriptsTrimmed)), ArtifactsDir: filepath.Join(root, config.PathArtifactsDirSegment), ReportsDir: filepath.Join(root, config.PathReportsDirSegment), ConfigDir: filepath.Join(root, config.PathConfigDirSegment), LogsDir: filepath.Join(root, config.PathLogsDirSegment), CurrentDir: filepath.Join(root, config.PathCurrentDirSegment), RunsDir: filepath.Join(root, config.PathRunsDirSegment), PreviousDir: filepath.Join(root, config.PathPreviousDirSegment), PreviousManifestPath: filepath.Join(root, config.PathPreviousDirSegment, config.PathManifestFile), PreviousArtifactsDir: filepath.Join(root, config.PathPreviousDirSegment, config.PathArtifactsDirSegment), ManifestPath: filepath.Join(root, config.PathManifestFile), LockPath: filepath.Join(root, config.PathLockFile), } }