Make configuration truthful and clean remote session files

This commit is contained in:
2026-08-10 21:41:00 +00:00
parent 72a200968a
commit 32653f54f9
32 changed files with 395 additions and 72 deletions

View File

@@ -50,6 +50,8 @@ Rules:
- `--previous-session-id` is a strict expectation: the selected session file - `--previous-session-id` is a strict expectation: the selected session file
must contain the same `previous_session_id`. must contain the same `previous_session_id`.
- `clean --all` cannot be combined with campaign/session selectors. - `clean --all` cannot be combined with campaign/session selectors.
- notification delivery is currently limited to the configured `noop` mode; see
the [configuration reference](./config.md#notifications).
## Session ID Input Rules ## Session ID Input Rules

View File

@@ -38,6 +38,10 @@ If local session discovery fails and a `session_id` is known, Narratio attempts
using configured object storage. using configured object storage.
The downloaded remote session file is command-scoped: Narratio removes it after
the command finishes and records only the remote object provenance alongside
the durable copied session input.
### Identity segments ### Identity segments
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
@@ -235,9 +239,7 @@ Rules:
| `pipeline.scriptorium.timeout` | duration | No | `10m` | | `pipeline.scriptorium.timeout` | duration | No | `10m` |
| `pipeline.scriptorium.render_debug` | bool | No | `false` | | `pipeline.scriptorium.render_debug` | bool | No | `false` |
| `pipeline.scriptorium.artifacts` | map | No | empty | | `pipeline.scriptorium.artifacts` | map | No | empty |
| `pipeline.notification.backend` | string | No | empty | | `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
| `pipeline.notification.recipient` | string | No | empty |
| `pipeline.notification.timeout` | duration | No | empty |
### Notarius Output Entries ### Notarius Output Entries
@@ -282,10 +284,20 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| Field | Type | Required | Rule | | Field | Type | Required | Rule |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `source` | string | Yes | built-in runtime source, prepared input source, `narratio.extraction.<name>`, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` | | `source` | string | Yes | built-in runtime source, prepared input source, `narratio.extraction.<name>`, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` |
| `artifact` | string | No | optional passthrough adapter field |
| `path` | string | No | optional passthrough adapter field |
| `required` | bool | No | optional input requirement | | `required` | bool | No | optional input requirement |
`artifact` and `path` are obsolete and rejected by strict configuration
loading. Use the canonical `source` identifier to select the input; Narratio
does not provide adapter-specific input passthrough fields.
### Notifications
Narratio currently supports only `notification.mode: noop`, which is also the
default when the section is omitted. The notify stage performs no delivery in
this mode. Backend, recipient, timeout, and other provider settings are
rejected by strict configuration loading until Narratio has a provider
integration.
### Campaign ### Campaign
| Field | Type | Required | Notes | | Field | Type | Required | Notes |

View File

@@ -57,11 +57,11 @@ The implemented canonical order is:
8. [`render`](stage-render.md) 8. [`render`](stage-render.md)
9. [`analyze`](stage-analyze.md) 9. [`analyze`](stage-analyze.md)
10. [`publish`](stage-publish.md) 10. [`publish`](stage-publish.md)
11. `notify` (placeholder) 11. `notify` (no-op)
`notify` currently has optional notifier call behavior and no persisted pipeline `notify` currently has no persisted pipeline outputs and uses the explicit
outputs; its default collaborator is a no-op sender. The focused stage `noop` notification mode. The focused stage documents own implementation
documents own implementation mechanics. The mechanics. The
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation [CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
and execution semantics. and execution semantics.

View File

@@ -776,6 +776,8 @@ temporary paths within a command-scoped lifecycle.
backend/recipient rejection, every remote-config exit path, cancellation, stale backend/recipient rejection, every remote-config exit path, cancellation, stale
cleanup, and manifest inspection for ephemeral paths. cleanup, and manifest inspection for ephemeral paths.
**Status:** Completed.
## Stage 23 — Stream WhisperX uploads and make the adapter race-safe ## Stage 23 — Stream WhisperX uploads and make the adapter race-safe
**Read first:** `audit-findings.md` lines 22542281 (COR-016), 31133156 **Read first:** `audit-findings.md` lines 22542281 (COR-016), 31133156

View File

@@ -260,7 +260,5 @@ scriptorium:
output_kind: player_handout output_kind: player_handout
notification: notification:
# Optional notification settings. # No delivery provider is currently implemented.
backend: "" mode: noop
recipient: ""
timeout: 30s

View File

@@ -125,4 +125,4 @@ scriptorium:
output_kind: player_handout output_kind: player_handout
notification: notification:
timeout: 30s mode: noop

View File

@@ -40,10 +40,12 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("clean: session_id is required unless --all is set") return fmt.Errorf("clean: session_id is required unless --all is set")
} }
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("clean: %w", err) return fmt.Errorf("clean: %w", err)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return fmt.Errorf("clean: resolved pipeline and session config are required") return fmt.Errorf("clean: resolved pipeline and session config are required")
} }

View File

@@ -218,7 +218,7 @@ audita:
binary: ` + auditaBinary + ` binary: ` + auditaBinary + `
llm_api_key_env: OPENROUTER_API_KEY llm_api_key_env: OPENROUTER_API_KEY
notification: notification:
timeout: 10s mode: noop
` `
sessionYAML := `session_id: ` + sessionID + ` sessionYAML := `session_id: ` + sessionID + `
campaign: sample-campaign campaign: sample-campaign
@@ -283,7 +283,7 @@ seriatim:
audita: audita:
binary: audita binary: audita
notification: notification:
timeout: 10s mode: noop
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign campaign: sample-campaign
@@ -510,7 +510,7 @@ seriatim:
audita: audita:
binary: ` + auditaBinary + ` binary: ` + auditaBinary + `
notification: notification:
timeout: 10s mode: noop
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03

View File

@@ -2,13 +2,16 @@ package app
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
) )
type pipelineCampaignConfig struct { type pipelineCampaignConfig struct {
@@ -18,14 +21,48 @@ type pipelineCampaignConfig struct {
Campaign *config.CampaignConfig Campaign *config.CampaignConfig
} }
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) { var downloadObjectToTempFn = storage.DownloadObjectToTemp
type commandConfig struct {
Config *config.Config
cleanup func() error
}
func (c *commandConfig) Close() error {
if c == nil || c.cleanup == nil {
return nil
}
cleanup := c.cleanup
c.cleanup = nil
return cleanup()
}
func retainedCommandConfig(cfg *config.Config) *commandConfig {
return &commandConfig{Config: cfg}
}
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (loaded *commandConfig, err error) {
var cleanup func() error
defer func() {
if err == nil || cleanup == nil {
return
}
if cleanupErr := cleanup(); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
}()
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag) base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" { if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts) cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
if err != nil {
return nil, err
}
return retainedCommandConfig(cfg), nil
} }
discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths) discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths)
@@ -33,7 +70,11 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
return nil, err return nil, err
} }
if discoveredSession.Path != "" { if discoveredSession.Path != "" {
return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts) cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
if err != nil {
return nil, err
}
return retainedCommandConfig(cfg), nil
} }
sessionID := strings.TrimSpace(sessionOpts.SessionID) sessionID := strings.TrimSpace(sessionOpts.SessionID)
@@ -62,20 +103,26 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
if err != nil { if err != nil {
return nil, missingSessionConfigError(discoveredSession.Searched, err.Error()) return nil, missingSessionConfigError(discoveredSession.Searched, err.Error())
} }
sessionTempPath, err := storage.DownloadObjectToTemp(ctx, store, remoteKey, "narratio-session-*.yml") sessionTempPath, err := downloadObjectToTempFn(ctx, store, remoteKey, "narratio-session-*.yml")
if err != nil { if err != nil {
return nil, missingSessionConfigError(discoveredSession.Searched, fmt.Sprintf("remote session %q download failed: %v", remoteKey, err)) return nil, missingSessionConfigError(discoveredSession.Searched, fmt.Sprintf("remote session %q download failed: %v", remoteKey, err))
} }
cleanup = func() error {
if err := fileops.RemoveAllUnderRoot(filepath.Dir(sessionTempPath), sessionTempPath); err != nil {
return fmt.Errorf("remove downloaded remote session config: %w", err)
}
return nil
}
sessionBytes, err := os.ReadFile(sessionTempPath) sessionBytes, err := os.ReadFile(sessionTempPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err) return nil, fmt.Errorf("read downloaded remote session config: %w", err)
} }
sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(base.Pipeline)+"/"+remoteKey, sessionBytes, sessionOpts) sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(base.Pipeline)+"/"+remoteKey, sessionBytes, sessionOpts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return config.Resolve( cfg, err := config.Resolve(
base.PipelinePath, base.PipelinePath,
base.Pipeline, base.Pipeline,
base.CampaignPath, base.CampaignPath,
@@ -89,9 +136,14 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
S3Key: remoteKey, S3Key: remoteKey,
S3Size: sessionInfo.Size, S3Size: sessionInfo.Size,
S3ETag: sessionInfo.ETag, S3ETag: sessionInfo.ETag,
SpoolPath: sessionTempPath,
}, },
) )
if err != nil {
return nil, err
}
loaded = &commandConfig{Config: cfg, cleanup: cleanup}
cleanup = nil
return loaded, nil
} }
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) { func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {

View File

@@ -22,10 +22,11 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("artifacts list: session_id is required") return fmt.Errorf("artifacts list: session_id is required")
} }
cfg, store, locks, m, err := loadHelperContext(ctx, flags, remote) cfg, store, locks, m, cleanup, err := loadHelperContext(ctx, flags, remote)
if err != nil { if err != nil {
return fmt.Errorf("artifacts list: %w", err) return fmt.Errorf("artifacts list: %w", err)
} }
defer cleanup()
catalog, err := buildHelperArtifactCatalog(cfg, m) catalog, err := buildHelperArtifactCatalog(cfg, m)
if err != nil { if err != nil {
return fmt.Errorf("artifacts list: %w", err) return fmt.Errorf("artifacts list: %w", err)

View File

@@ -90,33 +90,41 @@ func Artifacts(ctx context.Context, args []string, out io.Writer) error {
} }
} }
func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) { func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, func(), error) {
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, nil, err
} }
release := true
defer func() {
if release {
_ = loaded.Close()
}
}()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, nil, err
} }
var store storage.ObjectStore var store storage.ObjectStore
if needStore { if needStore {
store, err = newCommandObjectStore(ctx, cfg, nil) store, err = newCommandObjectStore(ctx, cfg, nil)
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, nil, err
} }
} else { } else {
store, _ = objectStoreIfConfigured(ctx, cfg) store, _ = objectStoreIfConfigured(ctx, cfg)
} }
locks, err := loadEffectiveLocks(ctx, cfg, store) locks, err := loadEffectiveLocks(ctx, cfg, store)
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, nil, err
} }
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
m, err := loadLocalManifest(ctx, paths.ManifestPath) m, err := loadLocalManifest(ctx, paths.ManifestPath)
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, nil, err
} }
return cfg, store, locks, m, nil release = false
return cfg, store, locks, m, func() { _ = loaded.Close() }, nil
} }
func objectStoreIfConfigured(ctx context.Context, cfg *config.Config) (storage.ObjectStore, error) { func objectStoreIfConfigured(ctx context.Context, cfg *config.Config) (storage.ObjectStore, error) {

View File

@@ -38,10 +38,11 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks: session_id is required") return fmt.Errorf("locks: session_id is required")
} }
cfg, _, locks, _, err := loadHelperContext(ctx, flags, true) cfg, _, locks, _, cleanup, err := loadHelperContext(ctx, flags, true)
if err != nil { if err != nil {
return fmt.Errorf("locks: %w", err) return fmt.Errorf("locks: %w", err)
} }
defer cleanup()
writeLocks(out, cfg, locks) writeLocks(out, cfg, locks)
return nil return nil
} }
@@ -63,10 +64,11 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks add: session_id is required") return fmt.Errorf("locks add: session_id is required")
} }
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true) cfg, store, locks, _, cleanup, err := loadHelperContext(ctx, flags, true)
if err != nil { if err != nil {
return fmt.Errorf("locks add: %w", err) return fmt.Errorf("locks add: %w", err)
} }
defer cleanup()
if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks add"); err != nil { if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks add"); err != nil {
return fmt.Errorf("locks add: %w", err) return fmt.Errorf("locks add: %w", err)
} }
@@ -106,10 +108,11 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks remove: session_id is required") return fmt.Errorf("locks remove: session_id is required")
} }
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true) cfg, store, locks, _, cleanup, err := loadHelperContext(ctx, flags, true)
if err != nil { if err != nil {
return fmt.Errorf("locks remove: %w", err) return fmt.Errorf("locks remove: %w", err)
} }
defer cleanup()
if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks remove"); err != nil { if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, cfg.Pipeline.Notarius, "locks remove"); err != nil {
return fmt.Errorf("locks remove: %w", err) return fmt.Errorf("locks remove: %w", err)
} }

View File

@@ -25,11 +25,13 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
} }
findings := []finding{} findings := []finding{}
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
findings = append(findings, errorFinding("config", err.Error())) findings = append(findings, errorFinding("config", err.Error()))
return renderFindings(out, "", "", findings) return renderFindings(out, "", "", findings)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
findings = append(findings, errorFinding("config", err.Error())) findings = append(findings, errorFinding("config", err.Error()))
} else { } else {

View File

@@ -26,10 +26,12 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("status: session_id is required") return fmt.Errorf("status: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("status: %w", err) return fmt.Errorf("status: %w", err)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("status: %w", err) return fmt.Errorf("status: %w", err)
} }

View File

@@ -30,10 +30,12 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
if flags.sessionID == "" { if flags.sessionID == "" {
return fmt.Errorf("plan: session_id is required") return fmt.Errorf("plan: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("plan: %w", err) return fmt.Errorf("plan: %w", err)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("plan: %w", err) return fmt.Errorf("plan: %w", err)
} }

View File

@@ -109,7 +109,7 @@ seriatim:
audita: audita:
binary: audita binary: audita
notification: notification:
timeout: 10s mode: noop
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign campaign: sample-campaign

View File

@@ -13,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
) )
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) { func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
@@ -44,6 +45,146 @@ inputs:
} }
} }
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
tests := []struct {
name string
sessionYAML string
command []string
configureRun func()
wantSuccessful bool
}{
{
name: "success",
sessionYAML: `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
`,
command: []string{"session", "plan", "2026-05-03"},
wantSuccessful: true,
},
{
name: "validation failure",
sessionYAML: `session_id: 2026-05-03
`,
command: []string{"session", "plan", "2026-05-03"},
},
{
name: "load failure",
sessionYAML: `session_id: 2026-05-03
unknown: true
`,
command: []string{"session", "plan", "2026-05-03"},
},
{
name: "adapter failure",
sessionYAML: `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
`,
command: []string{"run", "2026-05-03"},
configureRun: func() {
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
return nil, errors.New("adapter failed")
}
},
},
{
name: "cancellation",
sessionYAML: `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
`,
command: []string{"run", "2026-05-03"},
configureRun: func() {
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
return nil, context.Canceled
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", tt.sessionYAML)
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var downloadedPath string
captureRemoteSessionTempPath(t, &downloadedPath)
if tt.configureRun != nil {
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
tt.configureRun()
}
args := append(append([]string(nil), tt.command...), "--config", pipelinePath, "--campaign-file", campaignPath)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(args, &stdout, &stderr)
if tt.wantSuccessful && code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !tt.wantSuccessful && code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if downloadedPath == "" {
t.Fatal("remote session download path was not captured")
}
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
}
})
}
}
func TestRemoteSessionConfigCloseIsIdempotent(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
`)
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var downloadedPath string
captureRemoteSessionTempPath(t, &downloadedPath)
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"})
if err != nil {
t.Fatalf("loadCommandConfig() error = %v", err)
}
if err := loaded.Close(); err != nil {
t.Fatalf("first Close() error = %v", err)
}
if err := loaded.Close(); err != nil {
t.Fatalf("second Close() error = %v", err)
}
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
}
}
func captureRemoteSessionTempPath(t *testing.T, destination *string) {
t.Helper()
original := downloadObjectToTempFn
downloadObjectToTempFn = func(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) {
path, err := original(ctx, store, key, pattern)
if err == nil {
*destination = path
}
return path, err
}
t.Cleanup(func() { downloadObjectToTempFn = original })
}
func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testing.T) { func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -54,10 +54,12 @@ func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("restore: session_id is required") return fmt.Errorf("restore: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("restore: %w", err) return fmt.Errorf("restore: %w", err)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("restore: %w", err) return fmt.Errorf("restore: %w", err)
} }

View File

@@ -27,10 +27,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
if flags.sessionID == "" { if flags.sessionID == "" {
return fmt.Errorf("run: session_id is required") return fmt.Errorf("run: session_id is required")
} }
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }

View File

@@ -204,13 +204,15 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }
cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{ loaded, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
SessionID: req.SessionID, SessionID: req.SessionID,
PreviousSessionID: req.PreviousSessionID, PreviousSessionID: req.PreviousSessionID,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }
defer func() { _ = loaded.Close() }()
cfg := loaded.Config
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }

View File

@@ -1603,7 +1603,7 @@ func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) {
whisperx: whisperx:
transcribe_url: https://example.com/transcribe transcribe_url: https://example.com/transcribe
notification: notification:
timeout: 10s mode: noop
` `
campaignYAML := `campaign_id: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:

View File

@@ -11,7 +11,7 @@ func TestCacheDefaults(t *testing.T) {
whisperx: whisperx:
transcribe_url: https://example.com/transcribe transcribe_url: https://example.com/transcribe
notification: notification:
timeout: 10s mode: noop
`, `session_id: 2026-05-03 `, `session_id: 2026-05-03
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio

View File

@@ -18,7 +18,7 @@ campaigns:
whisperx: whisperx:
transcribe_url: https://example.com/transcribe transcribe_url: https://example.com/transcribe
notification: notification:
timeout: 10s mode: noop
` `
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
@@ -196,7 +196,7 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
campaignPath := filepath.Join(dir, "campaign.yml") campaignPath := filepath.Join(dir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nnotification:\n timeout: 10s\n" pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nnotification:\n mode: noop\n"
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
} }

View File

@@ -255,8 +255,6 @@ type ScriptoriumArtifactConfig struct {
// ScriptoriumInputConfig configures one named prompt input source. // ScriptoriumInputConfig configures one named prompt input source.
type ScriptoriumInputConfig struct { type ScriptoriumInputConfig struct {
Source string `yaml:"source"` Source string `yaml:"source"`
Artifact string `yaml:"artifact"`
Path string `yaml:"path"`
Required bool `yaml:"required"` Required bool `yaml:"required"`
} }
@@ -280,11 +278,9 @@ type NotariusOutputConfig struct {
ModuleKey string `yaml:"module_key"` ModuleKey string `yaml:"module_key"`
} }
// NotificationConfig configures notification backend settings. // NotificationConfig configures the supported notification behavior.
type NotificationConfig struct { type NotificationConfig struct {
Backend string `yaml:"backend"` Mode string `yaml:"mode"`
Recipient string `yaml:"recipient"`
Timeout string `yaml:"timeout"`
} }
// SessionInputsConfig contains per-session input references. // SessionInputsConfig contains per-session input references.
@@ -329,5 +325,4 @@ type SessionSource struct {
S3Key string S3Key string
S3Size int64 S3Size int64
S3ETag string S3ETag string
SpoolPath string
} }

View File

@@ -62,6 +62,7 @@ const (
DefaultArchiveEnabled = true DefaultArchiveEnabled = true
DefaultArchiveUploadRun = true DefaultArchiveUploadRun = true
DefaultNotificationMode = "noop"
PathWorkDirSegment = "work" PathWorkDirSegment = "work"
PathInputsDirSegment = "inputs" PathInputsDirSegment = "inputs"

View File

@@ -378,6 +378,16 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
applyRenderDefaults(&cfg.Render) applyRenderDefaults(&cfg.Render)
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
applyNotariusDefaults(cfg.Notarius) applyNotariusDefaults(cfg.Notarius)
applyNotificationDefaults(&cfg.Notification)
}
func applyNotificationDefaults(cfg *NotificationConfig) {
if cfg == nil {
return
}
if strings.TrimSpace(cfg.Mode) == "" {
cfg.Mode = DefaultNotificationMode
}
} }
func applyCampaignsDefaults(cfg *CampaignsConfig) { func applyCampaignsDefaults(cfg *CampaignsConfig) {

View File

@@ -0,0 +1,68 @@
package config
import (
"strings"
"testing"
)
func TestNotificationConfigSupportsOnlyNoopMode(t *testing.T) {
tests := []struct {
name string
section string
wantLoad string
wantValidate string
}{
{
name: "default noop mode",
section: "",
},
{
name: "explicit noop mode",
section: "notification:\n mode: noop\n",
},
{
name: "backend is rejected",
section: "notification:\n backend: email\n",
wantLoad: "field backend not found",
},
{
name: "recipient is rejected",
section: "notification:\n recipient: party@example.com\n",
wantLoad: "field recipient not found",
},
{
name: "provider mode is rejected",
section: "notification:\n mode: email\n",
wantValidate: "pipeline.notification.mode must be \"noop\"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML+"\n"+tt.section, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if tt.wantLoad != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantLoad) {
t.Fatalf("Load() error = %v, want %q", err, tt.wantLoad)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got := cfg.Pipeline.Notification.Mode; tt.wantValidate == "" && got != DefaultNotificationMode {
t.Fatalf("notification.mode = %q, want %q", got, DefaultNotificationMode)
}
err = Validate(cfg)
if tt.wantValidate != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantValidate) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantValidate)
}
return
}
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}

View File

@@ -95,7 +95,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration", wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration",
}, },
{ {
name: "legacy previous session artifact source fails validation", name: "legacy previous session source fails validation",
scriptoriumYAML: `scriptorium: scriptoriumYAML: `scriptorium:
binary: scriptorium binary: scriptorium
artifacts: artifacts:
@@ -109,8 +109,6 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
required: true required: true
previous_recap: previous_recap:
source: ` + legacyPreviousSource + ` source: ` + legacyPreviousSource + `
artifact: session_recap
path: ""
required: false required: false
vars: vars:
session_id: true session_id: true
@@ -118,6 +116,21 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
`, `,
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "` + legacyPreviousSource + `" is unsupported`, wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "` + legacyPreviousSource + `" is unsupported`,
}, },
{
name: "obsolete input passthrough fields fail strict decoding",
scriptoriumYAML: `scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
artifact: session_recap
`,
wantLoadErr: "field artifact not found",
},
{ {
name: "canonical previous-session source is accepted", name: "canonical previous-session source is accepted",
scriptoriumYAML: `scriptorium: scriptoriumYAML: `scriptorium:

View File

@@ -122,13 +122,20 @@ func validatePipeline(cfg *PipelineConfig) error {
if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil { if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil {
return err return err
} }
if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil { if err := validateNotification(cfg.Notification); err != nil {
return err return err
} }
return nil return nil
} }
func validateNotification(cfg NotificationConfig) error {
if strings.EqualFold(strings.TrimSpace(cfg.Mode), DefaultNotificationMode) {
return nil
}
return fmt.Errorf("pipeline.notification.mode must be %q until a notification provider is configured", DefaultNotificationMode)
}
func validateStorage(cfg StorageConfig) error { func validateStorage(cfg StorageConfig) error {
backend := strings.ToLower(strings.TrimSpace(cfg.Backend)) backend := strings.ToLower(strings.TrimSpace(cfg.Backend))
switch backend { switch backend {

View File

@@ -23,7 +23,6 @@ func TestValidateDurationsRequirePositiveValues(t *testing.T) {
p.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: value} p.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: value}
}, want: "pipeline.scriptorium.artifacts.session_recap.timeout"}, }, want: "pipeline.scriptorium.artifacts.session_recap.timeout"},
{name: "trim bounds timeout", set: func(p *PipelineConfig, value string) { p.Trim.Bounds.Timeout = value }, want: "pipeline.trim.bounds.timeout"}, {name: "trim bounds timeout", set: func(p *PipelineConfig, value string) { p.Trim.Bounds.Timeout = value }, want: "pipeline.trim.bounds.timeout"},
{name: "notification timeout", set: func(p *PipelineConfig, value string) { p.Notification.Timeout = value }, want: "pipeline.notification.timeout"},
} }
for _, value := range []string{"0s", "-1ms"} { for _, value := range []string{"0s", "-1ms"} {
@@ -82,7 +81,6 @@ func TestValidateDurationRejectsOverflowAndAcceptsPositiveSubsecondValues(t *tes
cfg.Pipeline.Audita.Timeout = "1ms" cfg.Pipeline.Audita.Timeout = "1ms"
cfg.Pipeline.Scriptorium.Timeout = "1ms" cfg.Pipeline.Scriptorium.Timeout = "1ms"
cfg.Pipeline.Trim.Bounds.Timeout = "1ms" cfg.Pipeline.Trim.Bounds.Timeout = "1ms"
cfg.Pipeline.Notification.Timeout = "1ms"
if cfg.Pipeline.Scriptorium.Artifacts == nil { if cfg.Pipeline.Scriptorium.Artifacts == nil {
cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{} cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{}
} }

View File

@@ -135,15 +135,14 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
source.Source = "session_config" source.Source = "session_config"
} }
inputs = append(inputs, manifest.InputRecord{ inputs = append(inputs, manifest.InputRecord{
Kind: "session_config", Kind: "session_config",
Path: path, Path: path,
Checksum: checksum, Checksum: checksum,
Source: source.Source, Source: source.Source,
S3Bucket: source.S3Bucket, S3Bucket: source.S3Bucket,
S3Key: source.S3Key, S3Key: source.S3Key,
S3Size: source.S3Size, S3Size: source.S3Size,
S3ETag: source.S3ETag, S3ETag: source.S3ETag,
SpoolPath: source.SpoolPath,
}) })
} }

View File

@@ -186,7 +186,6 @@ func TestPrepareStageRecordsRemoteSessionProvenance(t *testing.T) {
S3Key: "dnd/campaigns/sample-campaign/sessions/2026-05-03/session.yml", S3Key: "dnd/campaigns/sample-campaign/sessions/2026-05-03/session.yml",
S3Size: 58, S3Size: 58,
S3ETag: "session-etag", S3ETag: "session-etag",
SpoolPath: remoteSessionPath,
} }
env.Config.Pipeline.Spool = config.SpoolConfig{Root: filepath.Join(t.TempDir(), "spool")} env.Config.Pipeline.Spool = config.SpoolConfig{Root: filepath.Join(t.TempDir(), "spool")}
env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"} env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"}
@@ -207,7 +206,7 @@ func TestPrepareStageRecordsRemoteSessionProvenance(t *testing.T) {
if sessionInput.S3Bucket != "my-dnd-archive" || sessionInput.S3Key != "dnd/campaigns/sample-campaign/sessions/2026-05-03/session.yml" { if sessionInput.S3Bucket != "my-dnd-archive" || sessionInput.S3Key != "dnd/campaigns/sample-campaign/sessions/2026-05-03/session.yml" {
t.Fatalf("remote session input missing bucket/key: %#v", sessionInput) t.Fatalf("remote session input missing bucket/key: %#v", sessionInput)
} }
if sessionInput.S3Size != 58 || sessionInput.S3ETag != "session-etag" || sessionInput.SpoolPath != remoteSessionPath { if sessionInput.S3Size != 58 || sessionInput.S3ETag != "session-etag" || sessionInput.SpoolPath != "" {
t.Fatalf("remote session input missing metadata: %#v", sessionInput) t.Fatalf("remote session input missing metadata: %#v", sessionInput)
} }
} }