Add canonical previous-session artifact source parsing and validation
This commit is contained in:
@@ -249,6 +249,7 @@ Scriptorium artifact-key and dependency rules:
|
|||||||
Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
|
Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
|
||||||
|
|
||||||
- `previous_session_artifact`
|
- `previous_session_artifact`
|
||||||
|
- `narratio.previous_session.artifact.<configured_artifact_key>`
|
||||||
- `narratio.transcript.merged`
|
- `narratio.transcript.merged`
|
||||||
- `narratio.transcript.polished`
|
- `narratio.transcript.polished`
|
||||||
- `narratio.transcript.full`
|
- `narratio.transcript.full`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||||
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
||||||
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`)
|
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`)
|
||||||
|
var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
|
|
||||||
type artifactContentKind string
|
type artifactContentKind string
|
||||||
|
|
||||||
@@ -118,6 +119,21 @@ func IsConfiguredArtifactSource(source string) bool {
|
|||||||
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
|
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
|
||||||
|
func IsPreviousSessionArtifactSource(source string) bool {
|
||||||
|
_, ok := PreviousSessionArtifactName(source)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviousSessionArtifactName extracts <name> from narratio.previous_session.artifact.<name>.
|
||||||
|
func PreviousSessionArtifactName(source string) (string, bool) {
|
||||||
|
matches := previousSessionArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return matches[1], true
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
|
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
|
||||||
// Resolution order is manifest producer outputs first, then canonical session path fallback.
|
// Resolution order is manifest producer outputs first, then canonical session path fallback.
|
||||||
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
|
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
|
||||||
|
|||||||
@@ -45,6 +45,58 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPreviousSessionArtifactSourceHelpers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
wantName string
|
||||||
|
wantMatch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
source: "narratio.previous_session.artifact.session_recap",
|
||||||
|
wantName: "session_recap",
|
||||||
|
wantMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with surrounding whitespace",
|
||||||
|
source: " narratio.previous_session.artifact.quest_log ",
|
||||||
|
wantName: "quest_log",
|
||||||
|
wantMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing name",
|
||||||
|
source: "narratio.previous_session.artifact.",
|
||||||
|
wantMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid key characters",
|
||||||
|
source: "narratio.previous_session.artifact.session-recap",
|
||||||
|
wantMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong prefix",
|
||||||
|
source: "narratio.previous.artifact.session_recap",
|
||||||
|
wantMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
name, ok := PreviousSessionArtifactName(tt.source)
|
||||||
|
if ok != tt.wantMatch {
|
||||||
|
t.Fatalf("PreviousSessionArtifactName(%q) ok = %t, want %t", tt.source, ok, tt.wantMatch)
|
||||||
|
}
|
||||||
|
if name != tt.wantName {
|
||||||
|
t.Fatalf("PreviousSessionArtifactName(%q) name = %q, want %q", tt.source, name, tt.wantName)
|
||||||
|
}
|
||||||
|
if got := IsPreviousSessionArtifactSource(tt.source); got != tt.wantMatch {
|
||||||
|
t.Fatalf("IsPreviousSessionArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
|||||||
@@ -116,6 +116,75 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
output_kind: session_recap
|
output_kind: session_recap
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "canonical previous-session source is accepted",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: narratio.transcript.polished
|
||||||
|
required: true
|
||||||
|
previous_recap:
|
||||||
|
source: narratio.previous_session.artifact.session_recap
|
||||||
|
required: false
|
||||||
|
vars:
|
||||||
|
session_id: true
|
||||||
|
output_kind: session_recap
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical previous-session source missing artifact key fails validation",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
previous_recap:
|
||||||
|
source: narratio.previous_session.artifact.
|
||||||
|
required: false
|
||||||
|
`,
|
||||||
|
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact." must reference configured artifact key matching ^[a-z][a-z0-9_]*$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical previous-session source invalid artifact key fails validation",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
previous_recap:
|
||||||
|
source: narratio.previous_session.artifact.session-recap
|
||||||
|
required: false
|
||||||
|
`,
|
||||||
|
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.session-recap" must reference configured artifact key matching ^[a-z][a-z0-9_]*$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canonical previous-session source unknown artifact fails validation",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
previous_recap:
|
||||||
|
source: narratio.previous_session.artifact.quest_log
|
||||||
|
required: false
|
||||||
|
`,
|
||||||
|
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.quest_log" references unknown artifact "quest_log"`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "canonical artifact source is accepted",
|
name: "canonical artifact source is accepted",
|
||||||
scriptoriumYAML: `scriptorium:
|
scriptoriumYAML: `scriptorium:
|
||||||
|
|||||||
@@ -579,12 +579,38 @@ var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
|
|||||||
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||||
var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
|
var narratioPreviousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
|
|
||||||
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
|
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
|
||||||
if isStaticSupportedScriptoriumInputSource(source) {
|
trimmedSource := strings.TrimSpace(source)
|
||||||
|
if isStaticSupportedScriptoriumInputSource(trimmedSource) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
matches := narratioArtifactSourceRE.FindStringSubmatch(source)
|
|
||||||
|
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
|
||||||
|
matches := narratioPreviousSessionArtifactSourceRE.FindStringSubmatch(trimmedSource)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
|
||||||
|
artifactName,
|
||||||
|
inputName,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
referenced := matches[1]
|
||||||
|
if _, ok := configuredArtifacts[referenced]; !ok {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
||||||
|
artifactName,
|
||||||
|
inputName,
|
||||||
|
source,
|
||||||
|
referenced,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := narratioArtifactSourceRE.FindStringSubmatch(trimmedSource)
|
||||||
if len(matches) != 2 {
|
if len(matches) != 2 {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
|
||||||
@@ -607,7 +633,7 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isStaticSupportedScriptoriumInputSource(source string) bool {
|
func isStaticSupportedScriptoriumInputSource(source string) bool {
|
||||||
switch strings.TrimSpace(source) {
|
switch source {
|
||||||
case "previous_session_artifact":
|
case "previous_session_artifact":
|
||||||
return true
|
return true
|
||||||
case "narratio.transcript.merged":
|
case "narratio.transcript.merged":
|
||||||
|
|||||||
Reference in New Issue
Block a user