Resolve canonical previous-session artifact sources from prepared previous cache
This commit is contained in:
@@ -18,6 +18,9 @@ const (
|
|||||||
ArtifactTranscriptFull = "narratio.transcript.full"
|
ArtifactTranscriptFull = "narratio.transcript.full"
|
||||||
ArtifactTranscriptTrimmed = "narratio.transcript.trimmed"
|
ArtifactTranscriptTrimmed = "narratio.transcript.trimmed"
|
||||||
ArtifactBoundsSession = "narratio.bounds.session"
|
ArtifactBoundsSession = "narratio.bounds.session"
|
||||||
|
|
||||||
|
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
||||||
|
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||||
@@ -186,6 +189,9 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str
|
|||||||
// configured narratio.artifact.<name> sources through runtime catalog availability.
|
// configured narratio.artifact.<name> sources through runtime catalog availability.
|
||||||
func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest, source string, catalog *ArtifactCatalog) (ResolvedSessionArtifact, error) {
|
func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest, source string, catalog *ArtifactCatalog) (ResolvedSessionArtifact, error) {
|
||||||
normalized := strings.TrimSpace(source)
|
normalized := strings.TrimSpace(source)
|
||||||
|
if IsPreviousSessionArtifactSource(normalized) {
|
||||||
|
return ResolvePreviousSessionArtifactWithCatalog(paths, m, normalized, catalog)
|
||||||
|
}
|
||||||
if !IsConfiguredArtifactSource(normalized) {
|
if !IsConfiguredArtifactSource(normalized) {
|
||||||
return ResolveSessionArtifact(paths, m, normalized)
|
return ResolveSessionArtifact(paths, m, normalized)
|
||||||
}
|
}
|
||||||
@@ -211,6 +217,72 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvePreviousSessionArtifactWithCatalog resolves one canonical previous-session source id
|
||||||
|
// to the prepared current-session previous-cache path.
|
||||||
|
func ResolvePreviousSessionArtifactWithCatalog(
|
||||||
|
paths SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
source string,
|
||||||
|
catalog *ArtifactCatalog,
|
||||||
|
) (ResolvedSessionArtifact, error) {
|
||||||
|
artifactName, ok := PreviousSessionArtifactName(source)
|
||||||
|
if !ok {
|
||||||
|
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source)
|
||||||
|
}
|
||||||
|
if catalog == nil {
|
||||||
|
return ResolvedSessionArtifact{}, fmt.Errorf("previous-session artifact source %q requires runtime artifact catalog", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredSourceID := ConfiguredArtifactSourceID(artifactName)
|
||||||
|
entry, ok := catalog.Lookup(configuredSourceID)
|
||||||
|
if !ok {
|
||||||
|
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source)
|
||||||
|
}
|
||||||
|
candidates := previousSessionCacheCandidatePaths(paths, entry.CanonicalRelPath)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestInputPaths := manifestInputPathSet(paths, m)
|
||||||
|
fallback := ""
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
exists, isDir, statErr := pathExists(candidate)
|
||||||
|
if statErr != nil {
|
||||||
|
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate, statErr)
|
||||||
|
}
|
||||||
|
if !exists || isDir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateResolvedContent(candidate, contentText); err != nil {
|
||||||
|
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", source, err)
|
||||||
|
}
|
||||||
|
if _, ok := manifestInputPaths[candidate]; ok {
|
||||||
|
return ResolvedSessionArtifact{
|
||||||
|
ID: source,
|
||||||
|
Path: candidate,
|
||||||
|
ProducerStage: "prepare",
|
||||||
|
OutputKind: "previous_session_artifact",
|
||||||
|
Provenance: ArtifactProvenancePreviousCacheManifestInput,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fallback != "" {
|
||||||
|
return ResolvedSessionArtifact{
|
||||||
|
ID: source,
|
||||||
|
Path: fallback,
|
||||||
|
ProducerStage: "prepare",
|
||||||
|
OutputKind: "previous_session_artifact",
|
||||||
|
Provenance: ArtifactProvenancePreviousCacheFilesystem,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
|
||||||
|
}
|
||||||
|
|
||||||
func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact {
|
func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact {
|
||||||
if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" {
|
if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -259,6 +331,50 @@ func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSession
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string {
|
||||||
|
trimmed := strings.TrimSpace(canonicalRelPath)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalized := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed)))
|
||||||
|
if normalized == "." || normalized == "" || normalized == ".." || strings.HasPrefix(normalized, "../") || strings.HasPrefix(normalized, "/") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relCandidates := []string{normalized}
|
||||||
|
const artifactsPrefix = "artifacts/"
|
||||||
|
if strings.HasPrefix(normalized, artifactsPrefix) && len(normalized) > len(artifactsPrefix) {
|
||||||
|
relCandidates = append(relCandidates, strings.TrimPrefix(normalized, artifactsPrefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(relCandidates))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, rel := range relCandidates {
|
||||||
|
abs := filepath.Clean(SessionPreviousArtifactPath(paths, rel))
|
||||||
|
if _, ok := seen[abs]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[abs] = struct{}{}
|
||||||
|
out = append(out, abs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestInputPathSet(paths SessionPaths, m *manifest.Manifest) map[string]struct{} {
|
||||||
|
if m == nil || len(m.Inputs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]struct{}, len(m.Inputs))
|
||||||
|
for _, in := range m.Inputs {
|
||||||
|
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, in.Path))
|
||||||
|
if strings.TrimSpace(resolved) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[resolved] = struct{}{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func pathExists(path string) (exists bool, isDir bool, err error) {
|
func pathExists(path string) (exists bool, isDir bool, err error) {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -318,3 +318,120 @@ func TestResolveSessionArtifactWithCatalogUnsupportedConfiguredSourceFails(t *te
|
|||||||
t.Fatalf("error = %q, want unsupported artifact source", err.Error())
|
t.Fatalf("error = %q, want unsupported artifact source", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolvePreviousSessionArtifactWithCatalogPrefersManifestInputRecord(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
|
||||||
|
manifestBackedPath := SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
|
||||||
|
fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(manifestBackedPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(manifestBackedPath, []byte("recap from manifest input\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fallbackPath, []byte("recap fallback\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := NewArtifactCatalog()
|
||||||
|
if err := catalog.RegisterConfiguredArtifacts(
|
||||||
|
map[string]ConfiguredArtifactDefinition{
|
||||||
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := manifest.New("session", time.Now().UTC())
|
||||||
|
m.Inputs = []manifest.InputRecord{
|
||||||
|
{Kind: "previous_artifact", Path: manifestBackedPath},
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ResolvePreviousSessionArtifactWithCatalog(
|
||||||
|
paths,
|
||||||
|
m,
|
||||||
|
"narratio.previous_session.artifact.session_recap",
|
||||||
|
catalog,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Path != manifestBackedPath {
|
||||||
|
t.Fatalf("resolved path = %q, want %q", resolved.Path, manifestBackedPath)
|
||||||
|
}
|
||||||
|
if resolved.Provenance != ArtifactProvenancePreviousCacheManifestInput {
|
||||||
|
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheManifestInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePreviousSessionArtifactWithCatalogFallsBackToPreparedCachePath(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
|
||||||
|
fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(fallbackPath, []byte("recap fallback\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := NewArtifactCatalog()
|
||||||
|
if err := catalog.RegisterConfiguredArtifacts(
|
||||||
|
map[string]ConfiguredArtifactDefinition{
|
||||||
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := ResolvePreviousSessionArtifactWithCatalog(
|
||||||
|
paths,
|
||||||
|
nil,
|
||||||
|
"narratio.previous_session.artifact.session_recap",
|
||||||
|
catalog,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err)
|
||||||
|
}
|
||||||
|
if resolved.Path != fallbackPath {
|
||||||
|
t.Fatalf("resolved path = %q, want %q", resolved.Path, fallbackPath)
|
||||||
|
}
|
||||||
|
if resolved.Provenance != ArtifactProvenancePreviousCacheFilesystem {
|
||||||
|
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheFilesystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePreviousSessionArtifactWithCatalogMissingReturnsTypedError(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
catalog := NewArtifactCatalog()
|
||||||
|
if err := catalog.RegisterConfiguredArtifacts(
|
||||||
|
map[string]ConfiguredArtifactDefinition{
|
||||||
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ResolvePreviousSessionArtifactWithCatalog(
|
||||||
|
paths,
|
||||||
|
nil,
|
||||||
|
"narratio.previous_session.artifact.session_recap",
|
||||||
|
catalog,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrSessionArtifactNotFound) {
|
||||||
|
t.Fatalf("errors.Is(err, ErrSessionArtifactNotFound)=false; err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -632,13 +632,21 @@ func resolveScriptoriumInput(
|
|||||||
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
|
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
|
||||||
source := strings.TrimSpace(inputCfg.Source)
|
source := strings.TrimSpace(inputCfg.Source)
|
||||||
if artifacts.IsPreviousSessionArtifactSource(source) {
|
if artifacts.IsPreviousSessionArtifactSource(source) {
|
||||||
if inputCfg.Required {
|
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
||||||
return "", false, nil, fmt.Errorf(
|
if err == nil {
|
||||||
"required previous-session input source %q is unavailable; run narratio run-stage --force prepare",
|
copy := resolved
|
||||||
source,
|
return resolved.Path, true, ©, nil
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return "", false, nil, nil
|
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
||||||
|
if inputCfg.Required {
|
||||||
|
return "", false, nil, fmt.Errorf(
|
||||||
|
"required previous-session input source %q is unavailable; run narratio run-stage --force prepare",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "", false, nil, nil
|
||||||
|
}
|
||||||
|
return "", false, nil, err
|
||||||
}
|
}
|
||||||
switch source {
|
switch source {
|
||||||
case "previous_session_artifact":
|
case "previous_session_artifact":
|
||||||
|
|||||||
@@ -736,6 +736,61 @@ func TestAnalyzeRequiredPreviousSessionArtifactInputGuidesPrepareForce(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *testing.T) {
|
||||||
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||||
|
|
||||||
|
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
|
||||||
|
writeAnalyzeFile(t, previousPath, "previous recap\n")
|
||||||
|
m.Inputs = append(m.Inputs, manifest.InputRecord{
|
||||||
|
Kind: "previous_artifact",
|
||||||
|
Path: previousPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.previous_session.artifact.session_recap",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 {
|
||||||
|
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
if got := fake.RunRequests[0].InputPaths["previous_recap"]; got != previousPath {
|
||||||
|
t.Fatalf("previous_recap input = %q, want %q", got, previousPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeOmitsOptionalMissingCanonicalPreviousSessionArtifact(t *testing.T) {
|
||||||
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||||
|
|
||||||
|
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.previous_session.artifact.session_recap",
|
||||||
|
Required: false,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 {
|
||||||
|
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
if _, exists := fake.RunRequests[0].InputPaths["previous_recap"]; exists {
|
||||||
|
t.Fatalf("optional canonical previous_recap should be omitted when unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) {
|
func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) {
|
||||||
env, m, fake := setupAnalyzeEnv(t)
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
|||||||
Reference in New Issue
Block a user