Remove workspace state subsystem
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(), "<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 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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user