Implemented operations helper commands for validation, locking, and status
This commit is contained in:
@@ -112,6 +112,11 @@ type ArchiveLockRule struct {
|
||||
Reason string `yaml:"reason"`
|
||||
}
|
||||
|
||||
// ArchiveLockStore is the mutable per-session remote lock store.
|
||||
type ArchiveLockStore struct {
|
||||
Locks []ArchiveLockRule `yaml:"locks"`
|
||||
}
|
||||
|
||||
// WhisperXConfig configures WhisperX adapter settings.
|
||||
type WhisperXConfig struct {
|
||||
TranscribeURL string `yaml:"transcribe_url"`
|
||||
|
||||
@@ -85,6 +85,33 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// LoadArchiveLockStoreBytes loads a mutable session lock store with strict
|
||||
// field checking and source validation.
|
||||
func LoadArchiveLockStoreBytes(label string, data []byte, scriptorium *ScriptoriumConfig) (*ArchiveLockStore, error) {
|
||||
var store ArchiveLockStore
|
||||
if err := decodeStrictYAMLFromReader("archive lock store", label, strings.NewReader(string(data)), &store); err != nil {
|
||||
return nil, fmt.Errorf("load archive lock store: %w", err)
|
||||
}
|
||||
locks, err := ValidateArchiveLockRules(store.Locks, scriptorium, "locks")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load archive lock store: %w", err)
|
||||
}
|
||||
store.Locks = locks
|
||||
return &store, nil
|
||||
}
|
||||
|
||||
// MarshalArchiveLockStore serializes a mutable lock store as strict-compatible YAML.
|
||||
func MarshalArchiveLockStore(store *ArchiveLockStore) ([]byte, error) {
|
||||
if store == nil {
|
||||
store = &ArchiveLockStore{}
|
||||
}
|
||||
data, err := yaml.Marshal(store)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal archive lock store: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Load loads and resolves combined pipeline, campaign, and session configuration.
|
||||
// Passing only a session path is supported for package-internal compatibility;
|
||||
// in that form campaign.yml is expected next to the session file.
|
||||
|
||||
@@ -395,6 +395,54 @@ archive:
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveLockStoreBytesStrictDecodeAndValidation(t *testing.T) {
|
||||
store, err := LoadArchiveLockStoreBytes("locks.yml", []byte(`locks:
|
||||
- source: narratio.transcript.trimmed
|
||||
reason: reviewed
|
||||
`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadArchiveLockStoreBytes() error = %v", err)
|
||||
}
|
||||
if len(store.Locks) != 1 || store.Locks[0].Source != "narratio.transcript.trimmed" || store.Locks[0].Reason != "reviewed" {
|
||||
t.Fatalf("locks = %#v", store.Locks)
|
||||
}
|
||||
|
||||
_, err = LoadArchiveLockStoreBytes("locks.yml", []byte(`locks:
|
||||
- source: narratio.transcript.trimmed
|
||||
dest: transcripts/trimmed.json
|
||||
`), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "strict decode failed") {
|
||||
t.Fatalf("unknown field error = %v, want strict decode failed", err)
|
||||
}
|
||||
|
||||
_, err = LoadArchiveLockStoreBytes("locks.yml", []byte(`locks:
|
||||
- source: narratio.transcript.trimmed
|
||||
- source: narratio.transcript.trimmed
|
||||
`), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicates another archive lock source") {
|
||||
t.Fatalf("duplicate error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeArchiveLockRulesStaticWins(t *testing.T) {
|
||||
merged := MergeArchiveLockRules(
|
||||
[]ArchiveLockRule{{Source: "narratio.transcript.trimmed", Reason: "static"}},
|
||||
[]ArchiveLockRule{
|
||||
{Source: "narratio.transcript.trimmed", Reason: "remote"},
|
||||
{Source: "narratio.transcript.full", Reason: "remote full"},
|
||||
},
|
||||
)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged len = %d, want 2: %#v", len(merged), merged)
|
||||
}
|
||||
if merged[0].Source != "narratio.transcript.trimmed" || merged[0].Reason != "static" {
|
||||
t.Fatalf("merged[0] = %#v, want static lock", merged[0])
|
||||
}
|
||||
if merged[1].Source != "narratio.transcript.full" {
|
||||
t.Fatalf("merged[1] = %#v, want remote full lock", merged[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAudioS3Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -152,24 +152,67 @@ func validateArchive(cfg *ArchiveConfig, scriptorium *ScriptoriumConfig) error {
|
||||
}
|
||||
seenDest[normalizedDest] = struct{}{}
|
||||
}
|
||||
locks, err := ValidateArchiveLockRules(cfg.Locks, scriptorium, "pipeline.archive.locks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Locks = locks
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateArchiveLockRules validates and normalizes source-based archive locks.
|
||||
func ValidateArchiveLockRules(locks []ArchiveLockRule, scriptorium *ScriptoriumConfig, label string) ([]ArchiveLockRule, error) {
|
||||
seenLocks := map[string]struct{}{}
|
||||
for i, item := range cfg.Locks {
|
||||
prefix := fmt.Sprintf("pipeline.archive.locks[%d]", i)
|
||||
out := make([]ArchiveLockRule, 0, len(locks))
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = "archive.locks"
|
||||
}
|
||||
for i, item := range locks {
|
||||
prefix := fmt.Sprintf("%s[%d]", label, i)
|
||||
source := strings.TrimSpace(item.Source)
|
||||
if source == "" {
|
||||
return fmt.Errorf("%s.source is required", prefix)
|
||||
return nil, fmt.Errorf("%s.source is required", prefix)
|
||||
}
|
||||
if _, err := archiveSourceKnown(source, scriptorium); err != nil {
|
||||
return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
|
||||
return nil, fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
|
||||
}
|
||||
if _, ok := seenLocks[source]; ok {
|
||||
return fmt.Errorf("%s.source %q duplicates another archive lock source", prefix, source)
|
||||
return nil, fmt.Errorf("%s.source %q duplicates another archive lock source", prefix, source)
|
||||
}
|
||||
seenLocks[source] = struct{}{}
|
||||
cfg.Locks[i].Source = source
|
||||
cfg.Locks[i].Reason = strings.TrimSpace(item.Reason)
|
||||
out = append(out, ArchiveLockRule{
|
||||
Source: source,
|
||||
Reason: strings.TrimSpace(item.Reason),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MergeArchiveLockRules returns the union of static and remote locks. Static
|
||||
// locks win when both sources contain the same lock.
|
||||
func MergeArchiveLockRules(staticLocks, remoteLocks []ArchiveLockRule) []ArchiveLockRule {
|
||||
out := make([]ArchiveLockRule, 0, len(staticLocks)+len(remoteLocks))
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range staticLocks {
|
||||
source := strings.TrimSpace(item.Source)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ArchiveLockRule{Source: source, Reason: strings.TrimSpace(item.Reason)})
|
||||
seen[source] = struct{}{}
|
||||
}
|
||||
for _, item := range remoteLocks {
|
||||
source := strings.TrimSpace(item.Source)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[source]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, ArchiveLockRule{Source: source, Reason: strings.TrimSpace(item.Reason)})
|
||||
seen[source] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func archiveSourceKnown(source string, scriptorium *ScriptoriumConfig) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user