From dd7881acfb67ee40dcd9d5a750eadece63744d3c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 1 Aug 2026 20:00:54 +0000 Subject: [PATCH] Remove workspace state subsystem --- examples/config.yml | 8 - internal/app/app.go | 28 -- internal/cli/root.go | 2 +- internal/cli/run_test.go | 2 +- internal/config/config.go | 10 - internal/config/config_test.go | 13 +- internal/config/defaults.go | 8 - internal/config/validate.go | 3 - internal/fileutil/fileutil.go | 10 +- internal/fileutil/fileutil_test.go | 21 -- internal/promptdebug/debug_writer.go | 4 +- internal/state/filesystem.go | 316 ------------------ internal/state/metadata.go | 151 --------- internal/state/metadata_reached_paths_test.go | 46 --- internal/state/prompt_artifacts.go | 278 --------------- internal/state/store.go | 98 ------ 16 files changed, 12 insertions(+), 986 deletions(-) delete mode 100644 internal/state/filesystem.go delete mode 100644 internal/state/metadata.go delete mode 100644 internal/state/metadata_reached_paths_test.go delete mode 100644 internal/state/prompt_artifacts.go delete mode 100644 internal/state/store.go diff --git a/examples/config.yml b/examples/config.yml index 8a4a281..744f63c 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -40,14 +40,6 @@ promptkit: local: concurrency_limit: 1 -workspace: - root: workspace - snapshots_dir: snapshots - reports_dir: reports - data_packages_dir: data-packages - preflight_dir: preflight - notifications_dir: notifications - dayparts: - name: overnight start: "00:00" diff --git a/internal/app/app.go b/internal/app/app.go index 01c8bde..d456ba0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,7 +14,6 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" - "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug" @@ -66,11 +65,6 @@ type BatchRequest struct { Notifier Notifier } -type FetchBundleRequest struct { - Config config.Config - OutputPath string -} - type ModuleSnapshotRequest struct { Config config.Config Resolved report.Resolved @@ -568,14 +562,6 @@ func reportRegistry(cfg config.Config) (report.Registry, error) { return registry, nil } -func FetchBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { - result, err := collectWeather(ctx, req.Config, nil) - if err != nil { - return nil, err - } - return result.Bundle, nil -} - func collectWeather(ctx context.Context, cfg config.Config, collector Collector) (*collect.Result, error) { if collector == nil { collector = defaultCollector{} @@ -593,20 +579,6 @@ func collectWeather(ctx context.Context, cfg config.Config, collector Collector) return result, nil } -func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherdata.Bundle, error) { - if req.OutputPath == "" { - return nil, fmt.Errorf("output path is required") - } - bundle, err := FetchBundle(ctx, req) - if err != nil { - return nil, err - } - if err := fileutil.WriteJSONAtomic(req.OutputPath, bundle); err != nil { - return nil, fmt.Errorf("save bundle: %w", err) - } - return bundle, nil -} - func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, outputPath, runID string, generatedAt time.Time, notifier Notifier) (*NotificationResult, error) { notifier, enabled := reportNotifier(cfg, notifier) if !enabled { diff --git a/internal/cli/root.go b/internal/cli/root.go index f2dac87..3a25565 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -34,7 +34,7 @@ Options: --units VALUE Override weather API units. --tz NAME Override weather API timezone. --out PATH Write the generated Markdown report to PATH. - --llm-debug-dir PATH Write sensitive prompt debug artifacts outside the managed workspace. + --llm-debug-dir PATH Write sensitive prompt debug artifacts under PATH. --out-dir PATH Write generated Markdown reports beneath PATH for run commands. --quiet Suppress successful generate and run output. ` diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index eeee1ed..5ba4575 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -24,7 +24,7 @@ func TestResolveRunActionConstructsOneExecutor(t *testing.T) { for _, command := range []string{"morning", "evening"} { t.Run(command, func(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil { + if err := os.WriteFile(configPath, []byte("weather_api:\n base_url: https://weather.api.example.com/\n"), 0o600); err != nil { t.Fatal(err) } calls := 0 diff --git a/internal/config/config.go b/internal/config/config.go index 6b30b46..fddce15 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,7 +28,6 @@ type Config struct { Notify NotifyConfig `yaml:"notify"` MissingSource MissingSourceConfig `yaml:"missing_source"` Promptkit PromptkitConfig `yaml:"promptkit"` - Workspace WorkspaceConfig `yaml:"workspace"` Dayparts []DaypartConfig `yaml:"dayparts"` Reports map[string]ReportConfig `yaml:"reports"` } @@ -93,15 +92,6 @@ type PromptkitLocalConfig struct { ConcurrencyLimit int `yaml:"concurrency_limit"` } -type WorkspaceConfig struct { - Root string `yaml:"root"` - SnapshotsDir string `yaml:"snapshots_dir"` - ReportsDir string `yaml:"reports_dir"` - DataPackagesDir string `yaml:"data_packages_dir"` - PreflightDir string `yaml:"preflight_dir"` - NotificationsDir string `yaml:"notifications_dir"` -} - type DaypartConfig struct { Name string `yaml:"name"` Start string `yaml:"start"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 02815fc..a4e94cd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -147,12 +147,6 @@ func TestLoadMinimalExampleConfig(t *testing.T) { if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 { t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit) } - if cfg.Workspace.Root != "workspace" { - t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root) - } - if cfg.Workspace.NotificationsDir != "notifications" { - t.Fatalf("Workspace.NotificationsDir = %q, want notifications", cfg.Workspace.NotificationsDir) - } if cfg.Location.Name != "Brentwood" { t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name) } @@ -172,6 +166,13 @@ func TestLoadRejectsRemovedRecentChangeConfiguration(t *testing.T) { } } +func TestLoadRejectsRemovedWorkspaceConfiguration(t *testing.T) { + _, err := LoadFile(writeConfig(t, "workspace:\n root: workspace\n")) + if err == nil || !strings.Contains(err.Error(), "workspace") { + t.Fatalf("LoadFile() error = %v, want removed configuration rejection", err) + } +} + func TestLoadReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 36a52c4..6ae2c1a 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -49,14 +49,6 @@ func Defaults() Config { ConcurrencyLimit: 1, }, }, - Workspace: WorkspaceConfig{ - Root: "workspace", - SnapshotsDir: "snapshots", - ReportsDir: "reports", - DataPackagesDir: "data-packages", - PreflightDir: "preflight", - NotificationsDir: "notifications", - }, Dayparts: []DaypartConfig{ {Name: "overnight", Start: "00:00", End: "06:00"}, {Name: "morning", Start: "06:00", End: "10:00"}, diff --git a/internal/config/validate.go b/internal/config/validate.go index a2fcac7..fd6035a 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -62,9 +62,6 @@ func Validate(cfg Config) error { if err := validatePromptkit(cfg.Promptkit); err != nil { return err } - if cfg.Workspace.Root == "" { - return fmt.Errorf("workspace.root is required") - } if len(cfg.Dayparts) == 0 { return fmt.Errorf("dayparts must contain at least one entry") } diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index b470f7b..c7e803e 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -1,4 +1,4 @@ -// Package fileutil provides narrow filesystem helpers for durable artifacts. +// Package fileutil provides narrow filesystem helpers for operator-owned outputs. package fileutil import ( @@ -38,11 +38,3 @@ func WriteJSONAtomic(path string, value any) error { } return WriteFileAtomic(path, data) } - -func CopyFileAtomic(source string, target string) error { - data, err := os.ReadFile(source) - if err != nil { - return fmt.Errorf("read %q: %w", source, err) - } - return WriteFileAtomic(target, data) -} diff --git a/internal/fileutil/fileutil_test.go b/internal/fileutil/fileutil_test.go index 191903d..96b839f 100644 --- a/internal/fileutil/fileutil_test.go +++ b/internal/fileutil/fileutil_test.go @@ -80,24 +80,3 @@ func TestWriteJSONAtomic(t *testing.T) { t.Fatalf("json = %q, want indented object", data) } } - -func TestCopyFileAtomic(t *testing.T) { - dir := t.TempDir() - source := filepath.Join(dir, "source.txt") - target := filepath.Join(dir, "nested", "target.txt") - if err := os.WriteFile(source, []byte("copied"), 0o600); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - if err := CopyFileAtomic(source, target); err != nil { - t.Fatalf("CopyFileAtomic() error = %v", err) - } - - data, err := os.ReadFile(target) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - if string(data) != "copied" { - t.Fatalf("data = %q, want copied", data) - } -} diff --git a/internal/promptdebug/debug_writer.go b/internal/promptdebug/debug_writer.go index 5b37c19..5756523 100644 --- a/internal/promptdebug/debug_writer.go +++ b/internal/promptdebug/debug_writer.go @@ -22,8 +22,8 @@ const ( debugFileMode = 0o600 ) -// PromptDebugWriter stores explicitly requested content-rich diagnostics outside -// the managed report workspace. A writer created without a root is disabled. +// PromptDebugWriter stores explicitly requested content-rich diagnostics. A +// writer created without a root is disabled. type PromptDebugWriter struct { root string } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go deleted file mode 100644 index c9e290e..0000000 --- a/internal/state/filesystem.go +++ /dev/null @@ -1,316 +0,0 @@ -package state - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" -) - -type FilesystemStore struct { - root string - snapshotsDir string - reportsDir string - dataPackagesDir string - preflightDir string - notificationsDir string -} - -type ArtifactPaths struct { - ModuleSnapshot string `json:"moduleSnapshot"` - Metadata string `json:"metadata"` - DataPackage string `json:"dataPackage"` - Preparation string `json:"preparation,omitempty"` - Execution string `json:"execution,omitempty"` - Notification string `json:"notification,omitempty"` - RenderedReport string `json:"renderedReport,omitempty"` - GeneratedTextRaw string `json:"generatedTextRaw,omitempty"` - GeneratedText string `json:"generatedText,omitempty"` - RenderContext string `json:"renderContext,omitempty"` -} - -func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) { - if cfg.Root == "" { - return nil, fmt.Errorf("workspace root is required") - } - for name, value := range map[string]string{ - "snapshots_dir": cfg.SnapshotsDir, - "reports_dir": cfg.ReportsDir, - "data_packages_dir": cfg.DataPackagesDir, - "preflight_dir": cfg.PreflightDir, - "notifications_dir": cfg.NotificationsDir, - } { - if err := validateRelativeDir(name, value); err != nil { - return nil, err - } - } - return &FilesystemStore{ - root: filepath.Clean(cfg.Root), - snapshotsDir: filepath.Clean(cfg.SnapshotsDir), - reportsDir: filepath.Clean(cfg.ReportsDir), - dataPackagesDir: filepath.Clean(cfg.DataPackagesDir), - preflightDir: filepath.Clean(cfg.PreflightDir), - notificationsDir: filepath.Clean(cfg.NotificationsDir), - }, nil -} - -func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) { - if s == nil { - return ArtifactPaths{}, fmt.Errorf("state store is required") - } - metadata := resolved.Metadata() - if err := validatePathSegment("run id", metadata.RunID); err != nil { - return ArtifactPaths{}, err - } - group := resolved.Definition.ArtifactGroup - if group == "" { - return ArtifactPaths{}, fmt.Errorf("report %q has no artifact group", resolved.Definition.ID) - } - validDate := resolved.ValidPeriod.Start.Format("2006-01-02") - return ArtifactPaths{ - ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"), - Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"), - DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"), - Preparation: s.join(s.preflightDir, group, validDate, "prompt_preparation."+metadata.RunID+".json"), - Execution: s.join(s.snapshotsDir, group, validDate, "prompt_execution."+metadata.RunID+".json"), - Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"), - RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"), - GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"), - GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"), - RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"), - }, nil -} - -func (s *FilesystemStore) SaveModuleSnapshot(_ context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) { - if err := snapshot.Validate(); err != nil { - return "", err - } - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.ModuleSnapshot - }, snapshot) -} - -func (s *FilesystemStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) { - data, err := promptinput.MarshalYAML(pkg) - if err != nil { - return "", err - } - return s.SaveDataPackageBytes(ctx, resolved, data) -} - -func (s *FilesystemStore) SaveDataPackageBytes(_ context.Context, resolved report.Resolved, data []byte) (string, error) { - paths, err := s.Paths(resolved) - if err != nil { - return "", err - } - if err := fileutil.WriteFileAtomic(paths.DataPackage, data); err != nil { - return "", err - } - return paths.DataPackage, nil -} - -func (s *FilesystemStore) SavePromptPreparation(_ context.Context, resolved report.Resolved, artifact PromptPreparationArtifact) (string, error) { - if artifact.SchemaVersion == "" { - artifact.SchemaVersion = PromptPreparationSchemaVersion - } - if err := artifact.Validate(); err != nil { - return "", err - } - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.Preparation - }, artifact) -} - -func (s *FilesystemStore) SavePromptExecution(_ context.Context, resolved report.Resolved, artifact PromptExecutionArtifact) (string, error) { - if artifact.SchemaVersion == "" { - artifact.SchemaVersion = PromptExecutionSchemaVersion - } - if err := artifact.Validate(); err != nil { - return "", err - } - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.Execution - }, artifact) -} - -func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolved report.Resolved, artifact DistributorNotificationArtifact) (string, error) { - if artifact.SchemaVersion == "" { - artifact.SchemaVersion = DistributorNotificationSchemaVersion - } - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.Notification - }, artifact) -} - -func (s *FilesystemStore) BatchDistributorNotificationPath(ref BatchDistributorNotificationRef) (string, error) { - if s == nil { - return "", fmt.Errorf("state store is required") - } - if err := validateBatchNotificationRef(ref); err != nil { - return "", err - } - localDate := ref.StartedAt.In(ref.Location).Format("2006-01-02") - return s.join(s.notificationsDir, "batches", ref.Batch, localDate, "distributor."+ref.BatchRunID+".json"), nil -} - -func (s *FilesystemStore) SaveBatchDistributorNotification(_ context.Context, ref BatchDistributorNotificationRef, artifact BatchDistributorNotificationArtifact) (string, error) { - path, err := s.BatchDistributorNotificationPath(ref) - if err != nil { - return "", err - } - if artifact.SchemaVersion == "" { - artifact.SchemaVersion = BatchDistributorNotificationSchemaVersion - } - artifact.Batch = ref.Batch - artifact.BatchRunID = ref.BatchRunID - if err := fileutil.WriteJSONAtomic(path, artifact); err != nil { - return "", err - } - return path, nil -} - -func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) { - return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string { - return paths.GeneratedTextRaw - }, data) -} - -func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) { - return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string { - return paths.GeneratedText - }, data) -} - -func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) { - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.RenderContext - }, value) -} - -func (s *FilesystemStore) saveResolvedJSON(resolved report.Resolved, selectPath func(ArtifactPaths) string, value any) (string, error) { - paths, err := s.Paths(resolved) - if err != nil { - return "", err - } - path := selectPath(paths) - if err := fileutil.WriteJSONAtomic(path, value); err != nil { - return "", err - } - return path, nil -} - -func (s *FilesystemStore) saveResolvedBytes(resolved report.Resolved, selectPath func(ArtifactPaths) string, data []byte) (string, error) { - paths, err := s.Paths(resolved) - if err != nil { - return "", err - } - path := selectPath(paths) - if err := fileutil.WriteFileAtomic(path, data); err != nil { - return "", err - } - return path, nil -} - -func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) { - paths, err := s.Paths(resolved) - if err != nil { - return "", err - } - if err := os.MkdirAll(filepath.Dir(paths.RenderedReport), 0o755); err != nil { - return "", fmt.Errorf("create rendered report directory %q: %w", filepath.Dir(paths.RenderedReport), err) - } - return paths.RenderedReport, nil -} - -func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) { - if metadata.SchemaVersion != MetadataSchemaVersion { - return "", fmt.Errorf("new metadata must use schema version %q", MetadataSchemaVersion) - } - if err := metadata.Validate(); err != nil { - return "", err - } - if err := s.validateManagedPath("metadata path", metadata.MetadataPath); err != nil { - return "", err - } - if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil { - return "", err - } - return metadata.MetadataPath, nil -} - -func (s *FilesystemStore) join(parts ...string) string { - all := append([]string{s.root}, parts...) - return filepath.Join(all...) -} - -func (s *FilesystemStore) validateManagedPath(name, path string) error { - if s == nil { - return fmt.Errorf("state store is required") - } - root, err := filepath.Abs(s.root) - if err != nil { - return fmt.Errorf("resolve workspace root: %w", err) - } - target, err := filepath.Abs(path) - if err != nil { - return fmt.Errorf("resolve %s: %w", name, err) - } - relative, err := filepath.Rel(root, target) - if err != nil { - return fmt.Errorf("resolve %s relative to workspace root: %w", name, err) - } - if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return fmt.Errorf("%s must stay within workspace root", name) - } - return nil -} - -func validateRelativeDir(name string, value string) error { - if value == "" { - return fmt.Errorf("%s is required", name) - } - if filepath.IsAbs(value) { - return fmt.Errorf("%s must be relative to workspace root", name) - } - cleaned := filepath.Clean(value) - if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - return fmt.Errorf("%s must stay within workspace root", name) - } - return nil -} - -func validateBatchNotificationRef(ref BatchDistributorNotificationRef) error { - if err := validatePathSegment("batch kind", ref.Batch); err != nil { - return err - } - if err := validatePathSegment("batch run id", ref.BatchRunID); err != nil { - return err - } - if ref.StartedAt.IsZero() { - return fmt.Errorf("batch started time is required") - } - if ref.Location == nil { - return fmt.Errorf("batch location is required") - } - return nil -} - -func validatePathSegment(name string, value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required", name) - } - if strings.ContainsAny(value, `/\`) { - return fmt.Errorf("%s must not contain path separators", name) - } - if value == "." || value == ".." { - return fmt.Errorf("%s must be a safe path segment", name) - } - return nil -} diff --git a/internal/state/metadata.go b/internal/state/metadata.go deleted file mode 100644 index 2a7bc22..0000000 --- a/internal/state/metadata.go +++ /dev/null @@ -1,151 +0,0 @@ -package state - -import ( - "encoding/json" - "fmt" - "strings" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" -) - -const ( - MetadataSchemaVersion = "weatherreporter.metadata.v2" -) - -// Metadata is the durable record used by the transitional state package. -type Metadata struct { - SchemaVersion string `json:"schemaVersion"` - RunID string `json:"runId"` - MetadataPath string `json:"-"` - ReportID report.ID `json:"reportId"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt time.Time `json:"generatedAt"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"validPeriod"` - Location *briefing.LocationContext `json:"location,omitempty"` - SourceLocationID string `json:"sourceLocationId,omitempty"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []briefing.SourceMetadata `json:"sources,omitempty"` - SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` - ModuleSnapshotPath string `json:"moduleSnapshotPath"` - DataPackagePath string `json:"dataPackagePath"` - PreparationPath string `json:"preparationPath,omitempty"` - ExecutionPath string `json:"executionPath,omitempty"` - NotificationPath string `json:"notificationPath,omitempty"` - RenderedReportPath string `json:"renderedReportPath,omitempty"` - GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` - GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` - GeneratedTextPath string `json:"generatedTextPath,omitempty"` - RenderContextPath string `json:"renderContextPath,omitempty"` -} - -type metadataJSON struct { - SchemaVersion string `json:"schemaVersion"` - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"promptId"` - GeneratedAt time.Time `json:"generatedAt"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"validPeriod"` - Location *briefing.LocationContext `json:"location,omitempty"` - SourceLocationID string `json:"sourceLocationId,omitempty"` - SourceLocation string `json:"sourceLocation,omitempty"` - Sources []briefing.SourceMetadata `json:"sources,omitempty"` - SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` - ModuleSnapshotPath string `json:"moduleSnapshotPath"` - DataPackagePath string `json:"dataPackagePath"` - PreparationPath string `json:"preparationPath,omitempty"` - ExecutionPath string `json:"executionPath,omitempty"` - NotificationPath string `json:"notificationPath,omitempty"` - RenderedReportPath string `json:"renderedReportPath,omitempty"` - GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` - GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` - GeneratedTextPath string `json:"generatedTextPath,omitempty"` - RenderContextPath string `json:"renderContextPath,omitempty"` -} - -func (m Metadata) MarshalJSON() ([]byte, error) { - w := metadataJSON{ - SchemaVersion: m.SchemaVersion, RunID: m.RunID, ReportID: m.ReportID, - Variant: m.Variant, PromptID: m.PromptID, GeneratedAt: m.GeneratedAt, - Timezone: m.Timezone, ValidPeriod: m.ValidPeriod, Location: m.Location, - SourceLocationID: m.SourceLocationID, SourceLocation: m.SourceLocation, - Sources: m.Sources, SourceWarnings: m.SourceWarnings, - ModuleSnapshotPath: m.ModuleSnapshotPath, DataPackagePath: m.DataPackagePath, - NotificationPath: m.NotificationPath, RenderedReportPath: m.RenderedReportPath, - GeneratedTextSchemaID: m.GeneratedTextSchemaID, GeneratedTextRawPath: m.GeneratedTextRawPath, - GeneratedTextPath: m.GeneratedTextPath, RenderContextPath: m.RenderContextPath, - } - if m.SchemaVersion != MetadataSchemaVersion { - return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) - } - w.PreparationPath = m.PreparationPath - w.ExecutionPath = m.ExecutionPath - return json.Marshal(w) -} - -func (m *Metadata) UnmarshalJSON(data []byte) error { - type metadataWire Metadata - var decoded metadataWire - if err := json.Unmarshal(data, &decoded); err != nil { - return err - } - if decoded.SchemaVersion != MetadataSchemaVersion { - return fmt.Errorf("unsupported metadata schema version %q", decoded.SchemaVersion) - } - *m = Metadata(decoded) - return nil -} - -func (m Metadata) Validate() error { - if strings.TrimSpace(m.RunID) == "" { - return fmt.Errorf("metadata run id is required") - } - if strings.TrimSpace(m.ModuleSnapshotPath) == "" { - return fmt.Errorf("metadata module snapshot path is required") - } - if strings.TrimSpace(m.DataPackagePath) == "" { - return fmt.Errorf("metadata data package path is required") - } - if strings.TrimSpace(m.MetadataPath) == "" { - return fmt.Errorf("metadata path is required") - } - if m.SchemaVersion != MetadataSchemaVersion { - return fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) - } - if strings.TrimSpace(m.PreparationPath) == "" { - return fmt.Errorf("metadata preparation path is required") - } - return nil -} - -// BuildPromptMetadataFromBriefingMetadata creates the V2 record used by the -// prompt execution workflow. Callers populate preparation and execution paths -// only after their corresponding artifacts have been saved. -func BuildPromptMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { - metadata := resolved.Metadata() - return Metadata{ - SchemaVersion: MetadataSchemaVersion, RunID: metadata.RunID, MetadataPath: paths.Metadata, - ReportID: metadata.ReportID, Variant: briefingMetadata.Variant, PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt, Timezone: metadata.Timezone, ValidPeriod: metadata.ValidPeriod, - Location: copyLocation(briefingMetadata.Location), SourceLocationID: briefingMetadata.SourceLocationID, - SourceLocation: briefingMetadata.SourceLocation, Sources: briefingMetadata.Sources, - SourceWarnings: briefingMetadata.SourceWarnings, ModuleSnapshotPath: paths.ModuleSnapshot, - DataPackagePath: paths.DataPackage, - GeneratedTextSchemaID: resolved.Definition.GeneratedTextSchemaID, - } -} - -func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { - if location == nil { - return nil - } - copied := *location - return &copied -} diff --git a/internal/state/metadata_reached_paths_test.go b/internal/state/metadata_reached_paths_test.go deleted file mode 100644 index b859219..0000000 --- a/internal/state/metadata_reached_paths_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package state - -import ( - "encoding/json" - "testing" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" -) - -func TestBuildPromptMetadataIncludesOnlyExistingArtifacts(t *testing.T) { - resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ - Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), - Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC), Location: time.UTC, - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - metadata := BuildPromptMetadataFromBriefingMetadata(resolved, briefing.Metadata{}, ArtifactPaths{ - ModuleSnapshot: "/saved/modules.json", - Metadata: "/destination/metadata.json", - DataPackage: "/saved/data.yaml", - Preparation: "/future/preparation.json", - Execution: "/future/execution.json", - Notification: "/future/notification.json", - RenderedReport: "/future/report.md", - GeneratedTextRaw: "/future/raw.json", - GeneratedText: "/future/generated.json", - RenderContext: "/future/context.json", - }) - - if metadata.ModuleSnapshotPath != "/saved/modules.json" || metadata.DataPackagePath != "/saved/data.yaml" || metadata.MetadataPath != "/destination/metadata.json" { - t.Fatalf("existing paths = %#v, want module, data package, and metadata destination", metadata) - } - if metadata.PreparationPath != "" || metadata.ExecutionPath != "" || metadata.NotificationPath != "" || metadata.RenderedReportPath != "" || metadata.GeneratedTextRawPath != "" || metadata.GeneratedTextPath != "" || metadata.RenderContextPath != "" { - t.Fatalf("metadata includes paths for unreached artifacts: %#v", metadata) - } -} - -func TestMetadataRejectsRetiredSchema(t *testing.T) { - var metadata Metadata - if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v1"}`), &metadata); err == nil { - t.Fatal("Unmarshal() error = nil, want retired schema rejection") - } -} diff --git a/internal/state/prompt_artifacts.go b/internal/state/prompt_artifacts.go deleted file mode 100644 index f81b6c8..0000000 --- a/internal/state/prompt_artifacts.go +++ /dev/null @@ -1,278 +0,0 @@ -package state - -import ( - "fmt" - "strings" - "time" - "unicode/utf8" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" -) - -const ( - PromptPreparationSchemaVersion = "weatherreporter.prompt_preparation.v1" - PromptExecutionSchemaVersion = "weatherreporter.prompt_execution.v1" - promptArtifactErrorLimit = 2048 -) - -type PromptPreparationStatus string - -const ( - PromptPreparationSucceeded PromptPreparationStatus = "succeeded" - PromptPreparationFailed PromptPreparationStatus = "failed" -) - -// PromptArtifactError is the bounded, classified failure detail retained with a -// prompt execution artifact. It deliberately excludes provider error bodies. -type PromptArtifactError struct { - Category promptexec.ErrorCategory `json:"category"` - Message string `json:"message"` -} - -// NewPromptArtifactError converts an execution error into bounded, durable -// diagnostic information without retaining its underlying cause. -func NewPromptArtifactError(err error) *PromptArtifactError { - if err == nil { - return nil - } - message := strings.ToValidUTF8(err.Error(), "�") - if len(message) > promptArtifactErrorLimit { - message = message[:promptArtifactErrorLimit] - for !utf8.ValidString(message) { - message = message[:len(message)-1] - } - } - return &PromptArtifactError{Category: promptexec.CategoryOf(err), Message: message} -} - -// PromptPreparationArtifact records the safe provenance available before a -// provider is invoked. Preparation debug payloads are never stored here. -type PromptPreparationArtifact struct { - SchemaVersion string `json:"schemaVersion"` - Status PromptPreparationStatus `json:"status"` - ReportID report.ID `json:"reportId"` - RunID string `json:"runId"` - PromptID string `json:"promptId"` - PromptVersion string `json:"promptVersion,omitempty"` - DataPackagePath string `json:"dataPackagePath"` - Preparation *promptexec.Preparation `json:"preparation,omitempty"` - StartedAt time.Time `json:"startedAt"` - EndedAt time.Time `json:"endedAt"` - Duration time.Duration `json:"duration"` - Error *PromptArtifactError `json:"error,omitempty"` -} - -func (a PromptPreparationArtifact) Validate() error { - if a.SchemaVersion != PromptPreparationSchemaVersion { - return fmt.Errorf("unsupported prompt preparation schema version %q", a.SchemaVersion) - } - if err := validatePromptArtifactIdentity("prompt preparation", a.ReportID, a.RunID, a.PromptID, a.PromptVersion); err != nil { - return err - } - if strings.TrimSpace(a.DataPackagePath) == "" { - return fmt.Errorf("prompt preparation data package path is required") - } - if err := validatePromptArtifactTiming("prompt preparation", a.StartedAt, a.EndedAt, a.Duration); err != nil { - return err - } - switch a.Status { - case PromptPreparationSucceeded: - if a.Preparation == nil || a.Error != nil { - return fmt.Errorf("successful prompt preparation requires preparation without an error") - } - if a.Preparation.PromptID != a.PromptID || a.Preparation.PromptVersion != a.PromptVersion { - return fmt.Errorf("successful prompt preparation provenance must match the artifact") - } - case PromptPreparationFailed: - if !validPromptArtifactError(a.Error) { - return fmt.Errorf("failed prompt preparation requires a classified error") - } - if a.Preparation != nil { - return fmt.Errorf("failed prompt preparation must not include preparation provenance") - } - default: - return fmt.Errorf("unsupported prompt preparation status %q", a.Status) - } - return nil -} - -type PromptExecutionStatus string - -const ( - PromptExecutionSucceeded PromptExecutionStatus = "succeeded" - PromptExecutionValidationRejected PromptExecutionStatus = "validation_rejected" - PromptExecutionFailed PromptExecutionStatus = "failed" -) - -// PromptExecutionProvenance is the safe subset of promptexec.Execution. The -// generated content and debug payload are intentionally excluded. -type PromptExecutionProvenance struct { - RunID string `json:"runId"` - PromptID string `json:"promptId"` - PromptVersion string `json:"promptVersion"` - PromptHash string `json:"promptHash"` - RenderedPromptHash string `json:"renderedPromptHash"` - InputHashes map[string]string `json:"inputHashes,omitempty"` - ProfileID string `json:"profileId"` - BackendID string `json:"backendId"` - ModelName string `json:"modelName"` - GeneratedHash string `json:"generatedHash,omitempty"` - Usage promptexec.TokenUsage `json:"usage"` - StartedAt time.Time `json:"startedAt"` - EndedAt time.Time `json:"endedAt"` - Duration time.Duration `json:"duration"` -} - -// PromptExecutionPaths records only destinations reached by a completed run. -// It contains paths, never generated content or debug information. -type PromptExecutionPaths struct { - RawOutputPath string `json:"rawOutputPath,omitempty"` - GeneratedTextPath string `json:"generatedTextPath,omitempty"` - RenderContextPath string `json:"renderContextPath,omitempty"` - RenderedReportPath string `json:"renderedReportPath,omitempty"` - OutputPath string `json:"outputPath,omitempty"` - NotificationPath string `json:"notificationPath,omitempty"` -} - -// PromptExecutionArtifact records safe execution provenance and its validation -// outcome. It never embeds generated output or content-rich debug data. -type PromptExecutionArtifact struct { - SchemaVersion string `json:"schemaVersion"` - Status PromptExecutionStatus `json:"status"` - ReportID report.ID `json:"reportId"` - RunID string `json:"runId"` - PromptID string `json:"promptId"` - PromptVersion string `json:"promptVersion,omitempty"` - Provenance *PromptExecutionProvenance `json:"provenance,omitempty"` - Validation *promptexec.Validation `json:"validation,omitempty"` - Paths PromptExecutionPaths `json:"paths,omitempty"` - StartedAt time.Time `json:"startedAt"` - EndedAt time.Time `json:"endedAt"` - Duration time.Duration `json:"duration"` - Error *PromptArtifactError `json:"error,omitempty"` -} - -func PromptExecutionProvenanceFrom(value promptexec.Execution) PromptExecutionProvenance { - inputHashes := make(map[string]string, len(value.InputHashes)) - for key, item := range value.InputHashes { - inputHashes[key] = item - } - return PromptExecutionProvenance{ - RunID: value.RunID, PromptID: value.PromptID, PromptVersion: value.PromptVersion, - PromptHash: value.PromptHash, RenderedPromptHash: value.RenderedPromptHash, - InputHashes: inputHashes, ProfileID: value.ProfileID, BackendID: value.BackendID, - ModelName: value.ModelName, GeneratedHash: value.GeneratedHash, Usage: value.Usage, - StartedAt: value.StartedAt, EndedAt: value.EndedAt, Duration: value.Duration, - } -} - -func (a PromptExecutionArtifact) Validate() error { - if a.SchemaVersion != PromptExecutionSchemaVersion { - return fmt.Errorf("unsupported prompt execution schema version %q", a.SchemaVersion) - } - if err := validatePromptArtifactIdentity("prompt execution", a.ReportID, a.RunID, a.PromptID, a.PromptVersion); err != nil { - return err - } - if err := validatePromptArtifactTiming("prompt execution", a.StartedAt, a.EndedAt, a.Duration); err != nil { - return err - } - switch a.Status { - case PromptExecutionSucceeded: - if a.Provenance == nil || a.Validation == nil || a.Validation.Status != promptexec.ValidationPassed || a.Error != nil { - return fmt.Errorf("successful prompt execution requires passed validation without an error") - } - if err := validatePromptExecutionProvenance(a); err != nil { - return err - } - case PromptExecutionValidationRejected: - if a.Provenance == nil || a.Validation == nil || a.Validation.Status != promptexec.ValidationFailed || a.Error != nil { - return fmt.Errorf("validation-rejected prompt execution requires failed validation without an error") - } - if err := validatePromptExecutionProvenance(a); err != nil { - return err - } - case PromptExecutionFailed: - if !validPromptArtifactError(a.Error) { - return fmt.Errorf("failed prompt execution requires a classified error") - } - if a.Provenance != nil || a.Validation != nil { - return fmt.Errorf("failed prompt execution must not include completed provenance or validation") - } - default: - return fmt.Errorf("unsupported prompt execution status %q", a.Status) - } - return nil -} - -func validPromptArtifactError(value *PromptArtifactError) bool { - return value != nil && validPromptErrorCategory(value.Category) && strings.TrimSpace(value.Message) != "" && len(value.Message) <= promptArtifactErrorLimit && utf8.ValidString(value.Message) -} - -func validatePromptArtifactIdentity(kind string, reportID report.ID, runID, promptID, promptVersion string) error { - if reportID == "" || strings.TrimSpace(runID) == "" || strings.TrimSpace(promptID) == "" { - return fmt.Errorf("%s identity is required", kind) - } - definition, err := report.DefaultRegistry().Lookup(reportID) - if err != nil { - return fmt.Errorf("%s report id is unsupported: %w", kind, err) - } - if promptID != definition.PromptID { - return fmt.Errorf("%s prompt id must match report %q", kind, reportID) - } - if promptVersion != definition.PromptVersion { - return fmt.Errorf("%s prompt version must be %q", kind, definition.PromptVersion) - } - return nil -} - -func validatePromptArtifactTiming(kind string, startedAt, endedAt time.Time, duration time.Duration) error { - if startedAt.IsZero() || endedAt.IsZero() { - return fmt.Errorf("%s start and end times are required", kind) - } - if duration < 0 { - return fmt.Errorf("%s duration must not be negative", kind) - } - if endedAt.Before(startedAt) { - return fmt.Errorf("%s end time must not be earlier than its start time", kind) - } - return nil -} - -func validatePromptExecutionProvenance(artifact PromptExecutionArtifact) error { - value := artifact.Provenance - if value == nil { - return fmt.Errorf("completed prompt execution provenance is required") - } - if value.PromptID != artifact.PromptID || value.PromptVersion != artifact.PromptVersion { - return fmt.Errorf("completed prompt execution provenance must match the artifact") - } - for _, required := range []struct { - name string - value string - }{ - {"run id", value.RunID}, {"prompt hash", value.PromptHash}, {"rendered prompt hash", value.RenderedPromptHash}, - {"profile id", value.ProfileID}, {"backend id", value.BackendID}, {"model name", value.ModelName}, - } { - if strings.TrimSpace(required.value) == "" { - return fmt.Errorf("completed prompt execution provenance %s is required", required.name) - } - } - if err := validatePromptArtifactTiming("completed prompt execution provenance", value.StartedAt, value.EndedAt, value.Duration); err != nil { - return err - } - return nil -} - -func validPromptErrorCategory(category promptexec.ErrorCategory) bool { - switch category { - case promptexec.InvalidConfiguration, promptexec.InvalidRequest, promptexec.PromptNotFound, - promptexec.PromptLoad, promptexec.ProfileNotFound, promptexec.ProfileLoad, - promptexec.MissingCredential, promptexec.ArtifactLoad, promptexec.PromptRender, - promptexec.Capacity, promptexec.Generation, promptexec.OperationalValidation, - promptexec.ValidationRejected, promptexec.Canceled, promptexec.DeadlineExceeded: - return true - default: - return false - } -} diff --git a/internal/state/store.go b/internal/state/store.go deleted file mode 100644 index f94ebdc..0000000 --- a/internal/state/store.go +++ /dev/null @@ -1,98 +0,0 @@ -// Package state persists report artifacts and metadata. -package state - -import ( - "context" - "encoding/json" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" -) - -type Store interface { - Paths(report.Resolved) (ArtifactPaths, error) - SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error) - SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) - SaveDataPackageBytes(context.Context, report.Resolved, []byte) (string, error) - SavePromptPreparation(context.Context, report.Resolved, PromptPreparationArtifact) (string, error) - SavePromptExecution(context.Context, report.Resolved, PromptExecutionArtifact) (string, error) - SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error) - SaveBatchDistributorNotification(context.Context, BatchDistributorNotificationRef, BatchDistributorNotificationArtifact) (string, error) - SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error) - SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error) - SaveRenderContext(context.Context, report.Resolved, any) (string, error) - PrepareRenderedReport(context.Context, report.Resolved) (string, error) - SaveMetadata(context.Context, Metadata) (string, error) -} - -const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1" -const BatchDistributorNotificationSchemaVersion = "weatherreporter.batch_distributor_notification.v1" - -type BatchDistributorNotificationRef struct { - Batch string - BatchRunID string - StartedAt time.Time - Location *time.Location -} - -type DistributorNotificationArtifact struct { - SchemaVersion string `json:"schemaVersion"` - RunID string `json:"runId"` - ReportID report.ID `json:"reportId"` - AttemptedAt time.Time `json:"attemptedAt"` - Endpoint string `json:"endpoint"` - PipelineID string `json:"pipelineId,omitempty"` - BundleID string `json:"bundleId,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - SourcePath string `json:"sourcePath,omitempty"` - BundlePaths []string `json:"bundlePaths,omitempty"` - BundleCreated time.Time `json:"bundleCreated,omitempty"` - Status string `json:"status"` - Upload *DistributorUploadResult `json:"upload,omitempty"` - RunStatus *DistributorRunStatus `json:"runStatus,omitempty"` - StatusError string `json:"statusError,omitempty"` - Error string `json:"error,omitempty"` -} - -type DistributorUploadResult struct { - RunID string `json:"runId,omitempty"` - Status string `json:"status,omitempty"` -} - -type DistributorRunStatus struct { - RunID string `json:"runId,omitempty"` - PipelineID string `json:"pipelineId,omitempty"` - Status string `json:"status,omitempty"` - AcceptedAt time.Time `json:"acceptedAt,omitempty"` - StartedAt *time.Time `json:"startedAt,omitempty"` - FinishedAt *time.Time `json:"finishedAt,omitempty"` - Report json.RawMessage `json:"report,omitempty"` - Error string `json:"error,omitempty"` -} - -type BatchDistributorNotificationArtifact struct { - SchemaVersion string `json:"schemaVersion"` - Batch string `json:"batch"` - BatchRunID string `json:"batchRunId"` - AttemptedAt time.Time `json:"attemptedAt"` - Endpoint string `json:"endpoint"` - PipelineID string `json:"pipelineId,omitempty"` - BundleID string `json:"bundleId,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - BundleCreated time.Time `json:"bundleCreated,omitempty"` - Reports []BatchDistributorNotificationReportArtifact `json:"includedReports,omitempty"` - Status string `json:"status"` - Upload *DistributorUploadResult `json:"upload,omitempty"` - RunStatus *DistributorRunStatus `json:"runStatus,omitempty"` - StatusError string `json:"statusError,omitempty"` - Error string `json:"error,omitempty"` -} - -type BatchDistributorNotificationReportArtifact struct { - ReportID report.ID `json:"reportId"` - RunID string `json:"runId"` - SourcePath string `json:"sourcePath"` - BundlePaths []string `json:"bundlePaths"` -}