From a52a6ed22a8ad384c81baa28c6c96777ba884366 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 31 Jul 2026 04:13:00 +0000 Subject: [PATCH] Add durable prompt execution state records --- internal/app/app.go | 15 ++- internal/cli/result.go | 52 ++++---- internal/cli/result_test.go | 29 +++-- internal/cli/root_test.go | 4 +- internal/state/filesystem.go | 91 ++++++++++---- internal/state/filesystem_test.go | 145 ++++++++++++++++++++++ internal/state/metadata.go | 153 ++++++++++++++++++++++- internal/state/prompt_artifacts.go | 191 +++++++++++++++++++++++++++++ internal/state/store.go | 4 + 9 files changed, 619 insertions(+), 65 deletions(-) create mode 100644 internal/state/prompt_artifacts.go diff --git a/internal/app/app.go b/internal/app/app.go index 24f3010..37a239d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -94,6 +94,9 @@ type ReportResult struct { ModuleSnapshotPath string DataPackage promptinput.Package DataPackagePath string + PreparationPath string + ExecutionPath string + LLMDebugPath string PreflightPath string ReportPath string OutputPath string @@ -156,7 +159,9 @@ type BatchReportResult struct { GeneratedAt time.Time `json:"generatedAt"` ValidPeriod timeutil.Period `json:"validPeriod"` DataPackagePath string `json:"dataPackagePath,omitempty"` - PreflightPath string `json:"preflightPath,omitempty"` + PreparationPath string `json:"preparationPath,omitempty"` + ExecutionPath string `json:"executionPath,omitempty"` + LLMDebugPath string `json:"llmDebugPath,omitempty"` ReportPath string `json:"reportPath,omitempty"` OutputPath string `json:"outputPath,omitempty"` MetadataPath string `json:"metadataPath,omitempty"` @@ -330,7 +335,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro item := batchReportResult(planned) if paths, err := store.Paths(resolved); err == nil { item.DataPackagePath = paths.DataPackage - item.PreflightPath = paths.Preflight + item.PreparationPath = paths.Preflight item.ReportPath = paths.RenderedReport item.MetadataPath = paths.Metadata } @@ -361,7 +366,9 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro } else { item.Status = "succeeded" item.DataPackagePath = reportResult.DataPackagePath - item.PreflightPath = reportResult.PreflightPath + item.PreparationPath = reportResult.PreparationPath + item.ExecutionPath = reportResult.ExecutionPath + item.LLMDebugPath = reportResult.LLMDebugPath item.ReportPath = reportResult.ReportPath item.OutputPath = reportResult.OutputPath item.MetadataPath = reportResult.MetadataPath @@ -786,6 +793,8 @@ func renderedReportResult(req reportResultRequest) *ReportResult { ModuleSnapshotPath: req.moduleSnapshotPath, DataPackage: req.dataPackage, DataPackagePath: req.dataPackagePath, + PreparationPath: req.preflightPath, + ExecutionPath: req.generatedTextResultPath, PreflightPath: req.preflightPath, ReportPath: req.reportPath, OutputPath: req.finalized.OutputPath, diff --git a/internal/cli/result.go b/internal/cli/result.go index 141cc4b..e7ead63 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -17,26 +17,27 @@ const ( ) type generateSummary struct { - Command string `json:"command"` - ReportID report.ID `json:"reportId"` - ReportName string `json:"reportName"` - PromptID string `json:"promptId"` - RunID string `json:"runId"` - Status string `json:"status"` - GeneratedAt time.Time `json:"generatedAt"` - ValidPeriod timeutil.Period `json:"validPeriod"` - ReportPath string `json:"reportPath,omitempty"` - OutputPath string `json:"outputPath,omitempty"` - MetadataPath string `json:"metadataPath,omitempty"` - DataPackagePath string `json:"dataPackagePath,omitempty"` - PreflightPath string `json:"preflightPath,omitempty"` - GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` - GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"` - GeneratedTextPath string `json:"generatedTextPath,omitempty"` - RenderContextPath string `json:"renderContextPath,omitempty"` - NotificationPath string `json:"notificationPath,omitempty"` - Notification *generateNotificationSummary `json:"notification,omitempty"` - Error string `json:"error,omitempty"` + Command string `json:"command"` + ReportID report.ID `json:"reportId"` + ReportName string `json:"reportName"` + PromptID string `json:"promptId"` + RunID string `json:"runId"` + Status string `json:"status"` + GeneratedAt time.Time `json:"generatedAt"` + ValidPeriod timeutil.Period `json:"validPeriod"` + ReportPath string `json:"reportPath,omitempty"` + OutputPath string `json:"outputPath,omitempty"` + MetadataPath string `json:"metadataPath,omitempty"` + DataPackagePath string `json:"dataPackagePath,omitempty"` + PreparationPath string `json:"preparationPath,omitempty"` + ExecutionPath string `json:"executionPath,omitempty"` + LLMDebugPath string `json:"llmDebugPath,omitempty"` + GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` + GeneratedTextPath string `json:"generatedTextPath,omitempty"` + RenderContextPath string `json:"renderContextPath,omitempty"` + NotificationPath string `json:"notificationPath,omitempty"` + Notification *generateNotificationSummary `json:"notification,omitempty"` + Error string `json:"error,omitempty"` } type generateNotificationSummary struct { @@ -85,9 +86,16 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary { summary.OutputPath = result.OutputPath summary.MetadataPath = result.MetadataPath summary.DataPackagePath = result.DataPackagePath - summary.PreflightPath = result.PreflightPath + summary.PreparationPath = result.PreparationPath + if summary.PreparationPath == "" { + summary.PreparationPath = result.PreflightPath + } + summary.ExecutionPath = result.ExecutionPath + if summary.ExecutionPath == "" { + summary.ExecutionPath = result.GeneratedTextResultPath + } + summary.LLMDebugPath = result.LLMDebugPath summary.GeneratedTextRawPath = result.GeneratedTextRawPath - summary.GeneratedTextResultPath = result.GeneratedTextResultPath summary.GeneratedTextPath = result.GeneratedTextPath summary.RenderContextPath = result.RenderContextPath summary.NotificationPath = result.NotificationPath diff --git a/internal/cli/result_test.go b/internal/cli/result_test.go index 8ef2726..cd2a19a 100644 --- a/internal/cli/result_test.go +++ b/internal/cli/result_test.go @@ -19,16 +19,16 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) { startedAt := acceptedAt.Add(time.Minute) finishedAt := startedAt.Add(time.Minute) result := &app.ReportResult{ - DataPackagePath: "/runs/hourly/data_package.yaml", - PreflightPath: "/runs/hourly/preflight.json", - ReportPath: "/runs/hourly/report.md", - OutputPath: "/copies/hourly.md", - MetadataPath: "/runs/hourly/metadata.json", - GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json", - GeneratedTextResultPath: "/runs/hourly/generated_text_result.json", - GeneratedTextPath: "/runs/hourly/generated_text.json", - RenderContextPath: "/runs/hourly/render_context.json", - NotificationPath: "/runs/hourly/notification.json", + DataPackagePath: "/runs/hourly/data_package.yaml", + PreparationPath: "/runs/hourly/preparation.json", + ExecutionPath: "/runs/hourly/execution.json", + ReportPath: "/runs/hourly/report.md", + OutputPath: "/copies/hourly.md", + MetadataPath: "/runs/hourly/metadata.json", + GeneratedTextRawPath: "/runs/hourly/generated_text_raw.json", + GeneratedTextPath: "/runs/hourly/generated_text.json", + RenderContextPath: "/runs/hourly/render_context.json", + NotificationPath: "/runs/hourly/notification.json", Metadata: state.Metadata{ ReportID: report.Hourly, PromptID: "weather.hourly_generated_text", @@ -58,7 +58,7 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) { if summary.ReportID != report.Hourly || summary.ReportName != "Hourly Report" || summary.PromptID != "weather.hourly_generated_text" || summary.RunID != "20260529T133000Z_hourly" { t.Fatalf("summary identity = %#v, want hourly report identity", summary) } - if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { + if summary.PreparationPath == "" || summary.ExecutionPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) } if summary.Notification == nil || summary.Notification.RunID != "distributor-run" || summary.Notification.AcceptedAt == nil || !summary.Notification.AcceptedAt.Equal(acceptedAt) { @@ -71,13 +71,16 @@ func TestNewGenerateSummaryForGeneratedTextReport(t *testing.T) { if strings.Contains(string(data), "replace_older") || strings.Contains(string(data), "actions") { t.Fatalf("summary JSON includes raw distributor report payload:\n%s", string(data)) } + if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") || !strings.Contains(string(data), "preparationPath") || !strings.Contains(string(data), "executionPath") { + t.Fatalf("summary JSON does not use prompt artifact path names:\n%s", string(data)) + } } func TestNewGenerateSummaryOmitsNotificationWhenNotAttempted(t *testing.T) { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) result := &app.ReportResult{ DataPackagePath: "/runs/daily/data_package.yaml", - PreflightPath: "/runs/daily/preflight.json", + PreparationPath: "/runs/daily/preparation.json", ReportPath: "/runs/daily/report.md", OutputPath: "/copies/daily.md", MetadataPath: "/runs/daily/metadata.json", @@ -113,7 +116,7 @@ func TestNewGenerateSummaryForNotificationFailure(t *testing.T) { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) result := &app.ReportResult{ DataPackagePath: "/runs/hourly/data_package.yaml", - PreflightPath: "/runs/hourly/preflight.json", + PreparationPath: "/runs/hourly/preparation.json", ReportPath: "/runs/hourly/report.md", OutputPath: "/copies/hourly.md", MetadataPath: "/runs/hourly/metadata.json", diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 24b2cdf..2056a70 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -596,10 +596,10 @@ func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today { t.Fatalf("generate summary = %#v, want successful Today summary", summary) } - if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreflightPath == "" { + if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreparationPath == "" { t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary) } - if summary.GeneratedTextRawPath == "" || summary.GeneratedTextResultPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { + if summary.ExecutionPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) } if summary.OutputPath != outPath { diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 552c6d6..5a0cc3f 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -27,13 +27,19 @@ type FilesystemStore struct { } type ArtifactPaths struct { - ModuleSnapshot string `json:"moduleSnapshot"` - Metadata string `json:"metadata"` - DataPackage string `json:"dataPackage"` - Preflight string `json:"preflight"` - Notification string `json:"notification,omitempty"` - RenderedReport string `json:"renderedReport,omitempty"` - GeneratedTextRaw string `json:"generatedTextRaw,omitempty"` + ModuleSnapshot string `json:"moduleSnapshot"` + Metadata string `json:"metadata"` + DataPackage string `json:"dataPackage"` + Preparation string `json:"preparation,omitempty"` + Execution string `json:"execution,omitempty"` + // Preflight is retained for the temporary Scriptorium write path. Remove it + // with that integration's cutover. + Preflight string `json:"preflight"` + Notification string `json:"notification,omitempty"` + RenderedReport string `json:"renderedReport,omitempty"` + GeneratedTextRaw string `json:"generatedTextRaw,omitempty"` + // GeneratedTextResult is retained for the temporary Scriptorium write path. + // Remove it with that integration's cutover. GeneratedTextResult string `json:"generatedTextResult,omitempty"` GeneratedText string `json:"generatedText,omitempty"` RenderContext string `json:"renderContext,omitempty"` @@ -95,6 +101,8 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) 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"), Preflight: s.join(s.preflightDir, group, validDate, "render."+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"), @@ -131,6 +139,30 @@ func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resol }, artifact) } +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 @@ -227,20 +259,8 @@ func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved repo } func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) { - if metadata.RunID == "" { - return "", fmt.Errorf("metadata run id is required") - } - if metadata.ModuleSnapshotPath == "" { - return "", fmt.Errorf("metadata module snapshot path is required") - } - if metadata.DataPackagePath == "" { - return "", fmt.Errorf("metadata data package path is required") - } - if metadata.PreflightPath == "" { - return "", fmt.Errorf("metadata preflight path is required") - } - if metadata.MetadataPath == "" { - return "", fmt.Errorf("metadata path is required") + if err := metadata.Validate(); err != nil { + return "", err } if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil { return "", err @@ -413,6 +433,34 @@ func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string return readJSON(path, target) } +func (s *FilesystemStore) LoadPromptPreparation(_ context.Context, path string) (PromptPreparationArtifact, error) { + if path == "" { + return PromptPreparationArtifact{}, fmt.Errorf("prompt preparation path is required") + } + var artifact PromptPreparationArtifact + if err := readJSON(path, &artifact); err != nil { + return PromptPreparationArtifact{}, err + } + if err := artifact.Validate(); err != nil { + return PromptPreparationArtifact{}, err + } + return artifact, nil +} + +func (s *FilesystemStore) LoadPromptExecution(_ context.Context, path string) (PromptExecutionArtifact, error) { + if path == "" { + return PromptExecutionArtifact{}, fmt.Errorf("prompt execution path is required") + } + var artifact PromptExecutionArtifact + if err := readJSON(path, &artifact); err != nil { + return PromptExecutionArtifact{}, err + } + if err := artifact.Validate(); err != nil { + return PromptExecutionArtifact{}, err + } + return artifact, nil +} + func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error { if path == "" { return fmt.Errorf("render context path is required") @@ -428,6 +476,7 @@ func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) { if err := readJSON(path, &metadata); err != nil { return ReportRecord{}, err } + metadata.MetadataPath = path return ReportRecord{ RunID: metadata.RunID, ReportID: metadata.ReportID, diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index d5a9e3a..99b4da4 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -12,6 +12,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -30,6 +31,8 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) { filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"), filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"), filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"), + filepath.Join("preflight", "daily", "2026-05-29", "prompt_preparation.20260529T100000.000000000Z_daily_2026-05-29.json"), + filepath.Join("snapshots", "daily", "2026-05-29", "prompt_execution.20260529T100000.000000000Z_daily_2026-05-29.json"), filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"), filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"), filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"), @@ -40,6 +43,146 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) { } } +func TestPromptArtifactsAndV2MetadataRoundTrip(t *testing.T) { + store := newTestStore(t) + resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") + paths, err := store.Paths(resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + metadata := resolved.Metadata() + preparationPath, err := store.SavePromptPreparation(context.Background(), resolved, PromptPreparationArtifact{ + Status: PromptPreparationSucceeded, + ReportID: metadata.ReportID, + RunID: metadata.RunID, + PromptID: metadata.PromptID, + PromptVersion: "v1", + DataPackagePath: paths.DataPackage, + Preparation: &promptexec.Preparation{ + PromptID: metadata.PromptID, PromptVersion: "v1", PromptHash: "prompt-hash", + DataPackagePath: paths.DataPackage, + }, + }) + if err != nil { + t.Fatalf("SavePromptPreparation() error = %v", err) + } + executionPath, err := store.SavePromptExecution(context.Background(), resolved, PromptExecutionArtifact{ + Status: PromptExecutionSucceeded, + ReportID: metadata.ReportID, + RunID: metadata.RunID, + PromptID: metadata.PromptID, + PromptVersion: "v1", + Provenance: &PromptExecutionProvenance{ + RunID: metadata.RunID, PromptID: metadata.PromptID, PromptVersion: "v1", + PromptHash: "prompt-hash", DataPackagePath: paths.DataPackage, + }, + Validation: func() *promptexec.Validation { + value := promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "schemas/report.json", nil) + return &value + }(), + Paths: PromptExecutionPaths{RawOutputPath: paths.GeneratedTextRaw, RenderedReportPath: paths.RenderedReport}, + }) + if err != nil { + t.Fatalf("SavePromptExecution() error = %v", err) + } + loadedPreparation, err := store.LoadPromptPreparation(context.Background(), preparationPath) + if err != nil || loadedPreparation.Preparation == nil || loadedPreparation.Preparation.PromptHash != "prompt-hash" { + t.Fatalf("LoadPromptPreparation() = %#v, %v", loadedPreparation, err) + } + loadedExecution, err := store.LoadPromptExecution(context.Background(), executionPath) + if err != nil || loadedExecution.Provenance == nil || loadedExecution.Validation == nil { + t.Fatalf("LoadPromptExecution() = %#v, %v", loadedExecution, err) + } + executionData, err := os.ReadFile(executionPath) + if err != nil { + t.Fatalf("read execution artifact: %v", err) + } + if strings.Contains(string(executionData), `"RawOutput"`) || strings.Contains(string(executionData), `"Debug"`) { + t.Fatalf("execution artifact contains sensitive content fields: %s", executionData) + } + + v2 := BuildPromptMetadataFromBriefingMetadata(resolved, stateBriefingMetadata(resolved), paths) + v2.PreparationPath = preparationPath + v2.ExecutionPath = executionPath + metadataPath, err := store.SaveMetadata(context.Background(), v2) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + data, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("read v2 metadata: %v", err) + } + if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") { + t.Fatalf("v2 metadata contains deprecated aliases: %s", data) + } + loadedMetadata, _, err := store.LoadMetadataByRunID(context.Background(), metadata.RunID) + if err != nil || loadedMetadata.PreparationPath != preparationPath || loadedMetadata.ExecutionPath != executionPath { + t.Fatalf("LoadMetadataByRunID() = %#v, %v", loadedMetadata, err) + } +} + +func TestMetadataV1CompatibilityAndUnknownVersion(t *testing.T) { + legacy := Metadata{ + SchemaVersion: MetadataSchemaVersionV1, + RunID: "legacy-run", + MetadataPath: "/tmp/metadata.legacy-run.json", + ReportID: report.Daily, + PromptID: "weather.daily", + ModuleSnapshotPath: "/tmp/modules.json", + DataPackagePath: "/tmp/data.yaml", + PreflightPath: "/tmp/render.json", + GeneratedTextResultPath: "/tmp/generated-result.json", + } + data, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + if !strings.Contains(string(data), "preflightPath") || !strings.Contains(string(data), "generatedTextResultPath") || strings.Contains(string(data), "preparationPath") || strings.Contains(string(data), "executionPath") { + t.Fatalf("legacy metadata JSON = %s", data) + } + var decoded Metadata + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if decoded.PreparationPath != legacy.PreflightPath || decoded.ExecutionPath != legacy.GeneratedTextResultPath { + t.Fatalf("decoded compatibility paths = %#v", decoded) + } + remarshaled, err := json.Marshal(decoded) + if err != nil || !strings.Contains(string(remarshaled), "preflightPath") || strings.Contains(string(remarshaled), "preparationPath") { + t.Fatalf("remarshaled legacy metadata = %s, %v", remarshaled, err) + } + if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v99"}`), &decoded); err == nil { + t.Fatal("Unmarshal() error = nil, want unsupported schema version") + } +} + +func TestPromptArtifactRequiredFieldsAreRejected(t *testing.T) { + store := newTestStore(t) + resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") + if _, err := store.SavePromptPreparation(context.Background(), resolved, PromptPreparationArtifact{}); err == nil { + t.Fatal("SavePromptPreparation() error = nil, want required-field error") + } + if _, err := store.SavePromptExecution(context.Background(), resolved, PromptExecutionArtifact{}); err == nil { + t.Fatal("SavePromptExecution() error = nil, want required-field error") + } + paths, err := store.Paths(resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + metadata := resolved.Metadata() + if _, err := store.SaveMetadata(context.Background(), Metadata{ + SchemaVersion: MetadataSchemaVersion, + RunID: metadata.RunID, + MetadataPath: paths.Metadata, + ReportID: metadata.ReportID, + PromptID: metadata.PromptID, + ModuleSnapshotPath: paths.ModuleSnapshot, + DataPackagePath: paths.DataPackage, + }); err == nil { + t.Fatal("SaveMetadata() error = nil, want v2 preparation-path error") + } +} + func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) { store := newTestStore(t) first := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-05-31T12:00:00-05:00") @@ -960,6 +1103,8 @@ func pathsString(paths ArtifactPaths) string { paths.Metadata, paths.ModuleSnapshot, paths.DataPackage, + paths.Preparation, + paths.Execution, paths.Preflight, paths.Notification, paths.RenderedReport, diff --git a/internal/state/metadata.go b/internal/state/metadata.go index 7ffaa5c..8b91dc2 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -1,6 +1,9 @@ package state import ( + "encoding/json" + "fmt" + "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" @@ -9,12 +12,49 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) -const MetadataSchemaVersion = "weatherreporter.metadata.v1" +const ( + MetadataSchemaVersionV1 = "weatherreporter.metadata.v1" + MetadataSchemaVersion = "weatherreporter.metadata.v2" +) +// Metadata is the durable record used for report discovery and inspection. +// V1 fields remain internal compatibility values and are emitted only for V1 +// records; V2 records have no legacy aliases in their JSON representation. 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"` + + // These paths are retained only to preserve records written by the temporary + // Scriptorium flow. They are never emitted in V2 metadata. + PreflightPath string `json:"-"` + GeneratedTextResultPath string `json:"-"` +} + +type metadataJSON 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"` @@ -28,7 +68,9 @@ type Metadata struct { SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` ModuleSnapshotPath string `json:"moduleSnapshotPath"` DataPackagePath string `json:"dataPackagePath"` - PreflightPath string `json:"preflightPath"` + PreparationPath string `json:"preparationPath,omitempty"` + ExecutionPath string `json:"executionPath,omitempty"` + PreflightPath string `json:"preflightPath,omitempty"` NotificationPath string `json:"notificationPath,omitempty"` RenderedReportPath string `json:"renderedReportPath,omitempty"` GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` @@ -38,10 +80,100 @@ type Metadata struct { 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, + } + switch m.SchemaVersion { + case MetadataSchemaVersionV1: + w.PreflightPath = m.PreflightPath + w.GeneratedTextResultPath = m.GeneratedTextResultPath + case MetadataSchemaVersion: + w.PreparationPath = m.PreparationPath + w.ExecutionPath = m.ExecutionPath + default: + return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) + } + return json.Marshal(w) +} + +func (m *Metadata) UnmarshalJSON(data []byte) error { + var header struct { + SchemaVersion string `json:"schemaVersion"` + } + if err := json.Unmarshal(data, &header); err != nil { + return err + } + if header.SchemaVersion != MetadataSchemaVersionV1 && header.SchemaVersion != MetadataSchemaVersion { + return fmt.Errorf("unsupported metadata schema version %q", header.SchemaVersion) + } + var w metadataJSON + if err := json.Unmarshal(data, &w); err != nil { + return err + } + *m = Metadata{ + SchemaVersion: w.SchemaVersion, RunID: w.RunID, ReportID: w.ReportID, + Variant: w.Variant, PromptID: w.PromptID, GeneratedAt: w.GeneratedAt, + Timezone: w.Timezone, ValidPeriod: w.ValidPeriod, Location: w.Location, + SourceLocationID: w.SourceLocationID, SourceLocation: w.SourceLocation, + Sources: w.Sources, SourceWarnings: w.SourceWarnings, + ModuleSnapshotPath: w.ModuleSnapshotPath, DataPackagePath: w.DataPackagePath, + NotificationPath: w.NotificationPath, RenderedReportPath: w.RenderedReportPath, + GeneratedTextSchemaID: w.GeneratedTextSchemaID, GeneratedTextRawPath: w.GeneratedTextRawPath, + GeneratedTextPath: w.GeneratedTextPath, RenderContextPath: w.RenderContextPath, + } + if w.SchemaVersion == MetadataSchemaVersionV1 { + m.PreflightPath = w.PreflightPath + m.GeneratedTextResultPath = w.GeneratedTextResultPath + m.PreparationPath = w.PreflightPath + m.ExecutionPath = w.GeneratedTextResultPath + } else { + m.PreparationPath = w.PreparationPath + m.ExecutionPath = w.ExecutionPath + } + 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") + } + switch m.SchemaVersion { + case MetadataSchemaVersionV1: + if strings.TrimSpace(m.PreflightPath) == "" { + return fmt.Errorf("metadata preflight path is required") + } + case MetadataSchemaVersion: + if strings.TrimSpace(m.PreparationPath) == "" { + return fmt.Errorf("metadata preparation path is required") + } + default: + return fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) + } + return nil +} + func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { metadata := resolved.Metadata() out := Metadata{ - SchemaVersion: MetadataSchemaVersion, + SchemaVersion: MetadataSchemaVersionV1, RunID: metadata.RunID, MetadataPath: paths.Metadata, ReportID: metadata.ReportID, @@ -68,6 +200,19 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat return out } +// 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 { + legacy := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, paths) + legacy.SchemaVersion = MetadataSchemaVersion + legacy.PreparationPath = "" + legacy.ExecutionPath = "" + legacy.PreflightPath = "" + legacy.GeneratedTextResultPath = "" + return legacy +} + func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { if location == nil { return nil diff --git a/internal/state/prompt_artifacts.go b/internal/state/prompt_artifacts.go new file mode 100644 index 0000000..b54b19a --- /dev/null +++ b/internal/state/prompt_artifacts.go @@ -0,0 +1,191 @@ +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 strings.TrimSpace(a.RunID) == "" || a.ReportID == "" || strings.TrimSpace(a.PromptID) == "" { + return fmt.Errorf("prompt preparation identity is required") + } + if strings.TrimSpace(a.DataPackagePath) == "" { + return fmt.Errorf("prompt preparation data package path is required") + } + switch a.Status { + case PromptPreparationSucceeded: + if a.Preparation == nil || a.Error != nil { + return fmt.Errorf("successful prompt preparation requires preparation without an error") + } + case PromptPreparationFailed: + if !validPromptArtifactError(a.Error) { + return fmt.Errorf("failed prompt preparation requires a classified error") + } + 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"` + DataPackagePath string `json:"dataPackagePath"` +} + +// 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, + DataPackagePath: value.DataPackagePath, + } +} + +func (a PromptExecutionArtifact) Validate() error { + if a.SchemaVersion != PromptExecutionSchemaVersion { + return fmt.Errorf("unsupported prompt execution schema version %q", a.SchemaVersion) + } + if strings.TrimSpace(a.RunID) == "" || a.ReportID == "" || strings.TrimSpace(a.PromptID) == "" { + return fmt.Errorf("prompt execution identity is required") + } + 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") + } + 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") + } + case PromptExecutionFailed: + if !validPromptArtifactError(a.Error) { + return fmt.Errorf("failed prompt execution requires a classified error") + } + default: + return fmt.Errorf("unsupported prompt execution status %q", a.Status) + } + return nil +} + +func validPromptArtifactError(value *PromptArtifactError) bool { + return value != nil && value.Category != "" && strings.TrimSpace(value.Message) != "" && len(value.Message) <= promptArtifactErrorLimit && utf8.ValidString(value.Message) +} diff --git a/internal/state/store.go b/internal/state/store.go index 403f239..88cc1ee 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -16,6 +16,8 @@ type Store interface { SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (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) @@ -28,6 +30,8 @@ type Store interface { LoadModuleSnapshot(context.Context, string) (module.Snapshot, error) LoadGeneratedText(context.Context, string) ([]byte, error) LoadGeneratedTextResult(context.Context, string, any) error + LoadPromptPreparation(context.Context, string) (PromptPreparationArtifact, error) + LoadPromptExecution(context.Context, string) (PromptExecutionArtifact, error) LoadRenderContext(context.Context, string, any) error }