Add durable prompt execution state records

This commit is contained in:
2026-07-31 04:13:00 +00:00
parent b0b703eab4
commit a52a6ed22a
9 changed files with 619 additions and 65 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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(), "<22>")
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)
}

View File

@@ -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
}