Add secure prompt debug storage

This commit is contained in:
2026-07-31 04:20:30 +00:00
parent a52a6ed22a
commit 6064af2295
3 changed files with 681 additions and 10 deletions

View File

@@ -19,6 +19,8 @@ the paths safe to use below the configured workspace.
| Module snapshot | `snapshots/<group>/<date>/modules.<run-id>.json` |
| Metadata | `snapshots/<group>/<date>/metadata.<run-id>.json` |
| Data package | `data-packages/<group>/<date>/data_package.<run-id>.yaml` |
| Prompt preparation | `preflight/<group>/<date>/prompt_preparation.<run-id>.json` |
| Prompt execution | `snapshots/<group>/<date>/prompt_execution.<run-id>.json` |
| Render preflight | `preflight/<group>/<date>/render.<run-id>.json` |
| Notification record | `notifications/<group>/<date>/distributor.<run-id>.json` |
| Managed report | `reports/<group>/<date>/report.<run-id>.md` |
@@ -40,17 +42,37 @@ single-report artifact identifiers.
## Metadata and durable writes
`Metadata` is the durable inventory for a run. It records its schema version,
run identity, generated and valid timestamps, artifact group, source
content and provenance, and the module snapshot, data-package, preflight,
report, generated-artifact, and notification locations when present.
run identity, generated and valid timestamps, artifact group, source content
and provenance, and the module snapshot, data-package, prompt preparation,
prompt execution, report, generated-artifact, and notification locations when
present. New prompt records use `weatherreporter.metadata.v2`; historic
`weatherreporter.metadata.v1` records remain readable and retain their legacy
JSON field names when inspected.
`BuildMetadataFromBriefingMetadata` establishes the common fields; the
application adds locations as artifacts are produced. `SaveMetadata` requires
the run ID and the module snapshot, data-package, preflight, and metadata
paths. The package also saves module snapshots, data packages, preflight
records, generated-text artifacts, render contexts, and notifications. JSON
writes use `fileutil.WriteJSONAtomic`, so readers do not observe a partially
written state file.
`BuildMetadataFromBriefingMetadata` establishes legacy common fields, while
`BuildPromptMetadataFromBriefingMetadata` establishes the V2 record. The
application adds locations only after the corresponding artifacts are
produced. `SaveMetadata` requires the run ID, module snapshot, data package,
metadata path, and the matching preparation reference for its schema. The
package also saves module snapshots, data packages, prompt preparation and
execution records, legacy preflight records, generated-text artifacts, render
contexts, and notifications. JSON writes use atomic replacement, so readers do
not observe a partially written state file.
## Explicit prompt debug storage
`PromptDebugWriter` is a separate, opt-in boundary for content-rich prompt
diagnostics. It is constructed with an explicit absolute operator root, rather
than a workspace-derived path. A blank root produces a disabled writer that
does not access the filesystem.
Enabled debug captures are grouped as
`<root>/<report-id>/<valid-date>/<run-id>/` and contain `preparation.json` and
`execution.json`. The writer rejects symlinks, unsafe path segments, path
escape, and non-directory roots; it creates its directories with `0700` and
writes files atomically with `0600`. Normal state discovery and inspection do
not read this root. Its wire records map only approved project-owned fields;
credentials and dependency objects are not persisted.
The data package itself follows the shared
[prompt-input contract](prompt-input.md). Report text, templates, and external

View File

@@ -0,0 +1,442 @@
package state
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
const (
promptPreparationDebugSchemaVersion = "weatherreporter.prompt_preparation_debug.v1"
promptExecutionDebugSchemaVersion = "weatherreporter.prompt_execution_debug.v1"
debugDirectoryMode = 0o700
debugFileMode = 0o600
)
// PromptDebugWriter stores explicitly requested content-rich diagnostics outside
// the managed report workspace. A writer created without a root is disabled.
type PromptDebugWriter struct {
root string
}
// PromptDebugRef identifies one debug capture directory.
type PromptDebugRef struct {
ReportID report.ID
ValidDate string
RunID string
}
// PromptDebugMessage is one rendered prompt message retained only in the
// explicitly enabled debug store.
type PromptDebugMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// PromptDebugOutput records the declared structured-output contract.
type PromptDebugOutput struct {
Format string `json:"format"`
ValidationMode string `json:"validationMode"`
SchemaPath string `json:"schemaPath"`
}
// PromptDebugPreparation is the explicit, content-safe mapping of preparation
// provenance. It intentionally has no fields for credentials or dependencies.
type PromptDebugPreparation struct {
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"`
Output PromptDebugOutput `json:"output"`
StartedAt time.Time `json:"startedAt"`
EndedAt time.Time `json:"endedAt"`
Duration time.Duration `json:"duration"`
DataPackagePath string `json:"dataPackagePath"`
}
// PromptPreparationDebugArtifact is the on-disk preparation debug record.
type PromptPreparationDebugArtifact struct {
SchemaVersion string `json:"schemaVersion"`
ReportID report.ID `json:"reportId"`
ValidDate string `json:"validDate"`
RunID string `json:"runId"`
Preparation PromptDebugPreparation `json:"preparation"`
RenderedMessages []PromptDebugMessage `json:"renderedMessages,omitempty"`
StructuredSchema json.RawMessage `json:"structuredSchema,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
// PromptDebugUsage is the explicit mapping of provider token accounting.
type PromptDebugUsage struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CachedTokens int `json:"cachedTokens"`
CacheWriteTokens int `json:"cacheWriteTokens"`
}
// PromptDebugValidation is the completed validation detail retained in the
// explicitly enabled debug store.
type PromptDebugValidation struct {
Status string `json:"status"`
Mode string `json:"mode"`
SchemaPath string `json:"schemaPath"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// PromptDebugExecution is the explicit mapping of execution provenance.
type PromptDebugExecution 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 PromptDebugUsage `json:"usage"`
StartedAt time.Time `json:"startedAt"`
EndedAt time.Time `json:"endedAt"`
Duration time.Duration `json:"duration"`
DataPackagePath string `json:"dataPackagePath"`
}
// PromptExecutionDebugArtifact is the on-disk execution debug record.
type PromptExecutionDebugArtifact struct {
SchemaVersion string `json:"schemaVersion"`
ReportID report.ID `json:"reportId"`
ValidDate string `json:"validDate"`
RunID string `json:"runId"`
Execution PromptDebugExecution `json:"execution"`
Validation PromptDebugValidation `json:"validation"`
RawOutput string `json:"rawOutput,omitempty"`
DebugValidationDetails []string `json:"debugValidationDetails,omitempty"`
}
// NewPromptDebugWriter initializes an explicitly rooted writer. An empty root
// creates a disabled writer without touching the filesystem.
func NewPromptDebugWriter(root string) (*PromptDebugWriter, error) {
if strings.TrimSpace(root) == "" {
return &PromptDebugWriter{}, nil
}
if !filepath.IsAbs(root) {
return nil, fmt.Errorf("prompt debug root must be absolute")
}
cleaned := filepath.Clean(root)
if cleaned == string(filepath.Separator) {
return nil, fmt.Errorf("prompt debug root must not be the filesystem root")
}
if err := ensureSecureDirectory(cleaned); err != nil {
return nil, fmt.Errorf("initialize prompt debug root %q: %w", cleaned, err)
}
return &PromptDebugWriter{root: cleaned}, nil
}
func (w *PromptDebugWriter) Enabled() bool {
return w != nil && w.root != ""
}
// WritePreparation stores the explicitly captured preparation details and
// returns the per-run debug directory. Disabled writers do no filesystem work.
func (w *PromptDebugWriter) WritePreparation(ref PromptDebugRef, preparation promptexec.Preparation, debug *promptexec.PreparationDebug) (string, error) {
if !w.Enabled() {
return "", nil
}
directory, err := w.runDirectory(ref)
if err != nil {
return "", err
}
artifact := PromptPreparationDebugArtifact{
SchemaVersion: promptPreparationDebugSchemaVersion,
ReportID: ref.ReportID,
ValidDate: ref.ValidDate,
RunID: ref.RunID,
Preparation: promptDebugPreparation(preparation),
}
if debug != nil {
artifact.RenderedMessages = promptDebugMessages(debug.RenderedMessages)
artifact.StructuredSchema = copyRawJSON(debug.StructuredSchema)
artifact.Endpoint = safePromptDebugEndpoint(debug.Endpoint)
parameters, err := redactPromptDebugParameters(debug.ParametersJSON)
if err != nil {
return "", err
}
artifact.Parameters = parameters
}
if err := writeSecureJSON(filepath.Join(directory, "preparation.json"), artifact); err != nil {
return "", err
}
return directory, nil
}
// WriteExecution stores the explicitly captured execution details and returns
// the per-run debug directory. Disabled writers do no filesystem work.
func (w *PromptDebugWriter) WriteExecution(ref PromptDebugRef, execution promptexec.Execution) (string, error) {
if !w.Enabled() {
return "", nil
}
directory, err := w.runDirectory(ref)
if err != nil {
return "", err
}
artifact := PromptExecutionDebugArtifact{
SchemaVersion: promptExecutionDebugSchemaVersion,
ReportID: ref.ReportID,
ValidDate: ref.ValidDate,
RunID: ref.RunID,
Execution: promptDebugExecution(execution),
Validation: promptDebugValidation(execution.Validation),
RawOutput: string(execution.RawOutput),
}
if execution.Debug != nil {
if len(execution.Debug.RawOutput) > 0 {
artifact.RawOutput = string(execution.Debug.RawOutput)
}
artifact.DebugValidationDetails = append([]string(nil), execution.Debug.ValidationDiagnostics...)
}
if err := writeSecureJSON(filepath.Join(directory, "execution.json"), artifact); err != nil {
return "", err
}
return directory, nil
}
func (w *PromptDebugWriter) runDirectory(ref PromptDebugRef) (string, error) {
if err := validatePromptDebugRef(ref); err != nil {
return "", err
}
if err := ensureSecureDirectory(w.root); err != nil {
return "", fmt.Errorf("validate prompt debug root %q: %w", w.root, err)
}
directory := filepath.Join(w.root, string(ref.ReportID), ref.ValidDate, ref.RunID)
if !isWithinDirectory(w.root, directory) {
return "", fmt.Errorf("prompt debug path escapes root")
}
if err := ensureSecureDirectory(directory); err != nil {
return "", fmt.Errorf("create prompt debug directory %q: %w", directory, err)
}
return directory, nil
}
func validatePromptDebugRef(ref PromptDebugRef) error {
if err := validatePromptDebugSegment("report id", string(ref.ReportID)); err != nil {
return err
}
if err := validatePromptDebugSegment("valid date", ref.ValidDate); err != nil {
return err
}
if parsed, err := time.Parse("2006-01-02", ref.ValidDate); err != nil || parsed.Format("2006-01-02") != ref.ValidDate {
return fmt.Errorf("valid date must use YYYY-MM-DD")
}
return validatePromptDebugSegment("run id", ref.RunID)
}
func validatePromptDebugSegment(name string, value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("prompt debug %s is required", name)
}
if filepath.IsAbs(value) || strings.ContainsAny(value, `/\\`) || value == "." || value == ".." {
return fmt.Errorf("prompt debug %s must be a safe path segment", name)
}
return nil
}
func ensureSecureDirectory(path string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("directory must be absolute")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
current := volume + string(filepath.Separator)
for _, component := range strings.Split(strings.TrimPrefix(cleaned, current), string(filepath.Separator)) {
if component == "" {
continue
}
current = filepath.Join(current, component)
info, err := os.Lstat(current)
if os.IsNotExist(err) {
if err := os.Mkdir(current, debugDirectoryMode); err != nil {
return err
}
if err := os.Chmod(current, debugDirectoryMode); err != nil {
return err
}
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("directory component %q must not be a symlink", current)
}
if !info.IsDir() {
return fmt.Errorf("directory component %q is not a directory", current)
}
}
if err := os.Chmod(cleaned, debugDirectoryMode); err != nil {
return err
}
return nil
}
func isWithinDirectory(root string, path string) bool {
relative, err := filepath.Rel(root, path)
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func writeSecureJSON(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
if info, err := os.Lstat(path); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("prompt debug file %q is not a regular file", path)
}
} else if !os.IsNotExist(err) {
return err
}
temporary, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(debugFileMode); err != nil {
temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
return nil
}
func promptDebugPreparation(value promptexec.Preparation) PromptDebugPreparation {
return PromptDebugPreparation{
PromptID: value.PromptID, PromptVersion: value.PromptVersion, PromptHash: value.PromptHash,
RenderedPromptHash: value.RenderedPromptHash, InputHashes: copyPromptDebugMap(value.InputHashes),
ProfileID: value.ProfileID, BackendID: value.BackendID, ModelName: value.ModelName,
Output: PromptDebugOutput{Format: value.Output.Format, ValidationMode: value.Output.ValidationMode, SchemaPath: value.Output.SchemaPath},
StartedAt: value.StartedAt, EndedAt: value.EndedAt, Duration: value.Duration, DataPackagePath: value.DataPackagePath,
}
}
func promptDebugExecution(value promptexec.Execution) PromptDebugExecution {
return PromptDebugExecution{
RunID: value.RunID, PromptID: value.PromptID, PromptVersion: value.PromptVersion,
PromptHash: value.PromptHash, RenderedPromptHash: value.RenderedPromptHash,
InputHashes: copyPromptDebugMap(value.InputHashes), ProfileID: value.ProfileID,
BackendID: value.BackendID, ModelName: value.ModelName, GeneratedHash: value.GeneratedHash,
Usage: PromptDebugUsage{PromptTokens: value.Usage.PromptTokens, CompletionTokens: value.Usage.CompletionTokens, TotalTokens: value.Usage.TotalTokens, CachedTokens: value.Usage.CachedTokens, CacheWriteTokens: value.Usage.CacheWriteTokens},
StartedAt: value.StartedAt, EndedAt: value.EndedAt, Duration: value.Duration, DataPackagePath: value.DataPackagePath,
}
}
func promptDebugValidation(value promptexec.Validation) PromptDebugValidation {
return PromptDebugValidation{Status: string(value.Status), Mode: value.Mode, SchemaPath: value.SchemaPath, Diagnostics: append([]string(nil), value.Diagnostics...)}
}
func promptDebugMessages(values []promptexec.RenderedMessage) []PromptDebugMessage {
if len(values) == 0 {
return nil
}
result := make([]PromptDebugMessage, len(values))
for index, value := range values {
result[index] = PromptDebugMessage{Role: value.Role, Content: value.Content}
}
return result
}
func copyPromptDebugMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
result := make(map[string]string, len(values))
for key, value := range values {
result[key] = value
}
return result
}
func copyRawJSON(value []byte) json.RawMessage {
if len(value) == 0 {
return nil
}
return append(json.RawMessage(nil), value...)
}
func safePromptDebugEndpoint(value string) string {
parsed, err := url.Parse(value)
if err != nil {
return ""
}
parsed.User = nil
parameters := parsed.Query()
for key := range parameters {
if isPromptDebugSecretKey(key) {
parameters[key] = []string{"[redacted]"}
}
}
parsed.RawQuery = parameters.Encode()
parsed.Fragment = ""
return parsed.String()
}
func redactPromptDebugParameters(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return nil, nil
}
var decoded any
if err := json.Unmarshal(value, &decoded); err != nil {
return nil, fmt.Errorf("decode prompt debug parameters: %w", err)
}
redactPromptDebugValue(decoded)
encoded, err := json.Marshal(decoded)
if err != nil {
return nil, fmt.Errorf("encode prompt debug parameters: %w", err)
}
return encoded, nil
}
func redactPromptDebugValue(value any) {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
if isPromptDebugSecretKey(key) {
typed[key] = "[redacted]"
continue
}
redactPromptDebugValue(item)
}
case []any:
for _, item := range typed {
redactPromptDebugValue(item)
}
}
}
func isPromptDebugSecretKey(key string) bool {
normalized := strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.ToLower(key))
return strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") || strings.Contains(normalized, "password") || strings.Contains(normalized, "token") || strings.Contains(normalized, "apikey") || strings.Contains(normalized, "authorization")
}

View File

@@ -0,0 +1,207 @@
package state
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestPromptDebugWriterWritesIsolatedArtifacts(t *testing.T) {
root := filepath.Join(t.TempDir(), "operator-debug")
writer, err := NewPromptDebugWriter(root)
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
ref := promptDebugRef()
preparationDir, err := writer.WritePreparation(ref, promptDebugPreparationFixture(), &promptexec.PreparationDebug{
RenderedMessages: []promptexec.RenderedMessage{{Role: "system", Content: "Use the supplied weather facts."}},
StructuredSchema: []byte(`{"type":"object","required":["summary"]}`),
Endpoint: "https://direct:resolved-secret-value@llm.example.test/v1/chat?api_key=resolved-secret-value",
ParametersJSON: []byte(`{"temperature":0.2,"api_key":"resolved-secret-value"}`),
})
if err != nil {
t.Fatalf("WritePreparation() error = %v", err)
}
wantDirectory := filepath.Join(root, "daily", "2026-05-29", "run-123")
if preparationDir != wantDirectory {
t.Fatalf("WritePreparation() directory = %q, want %q", preparationDir, wantDirectory)
}
executionDir, err := writer.WriteExecution(ref, promptDebugExecutionFixture())
if err != nil {
t.Fatalf("WriteExecution() error = %v", err)
}
if executionDir != preparationDir {
t.Fatalf("WriteExecution() directory = %q, want %q", executionDir, preparationDir)
}
preparationData := readPromptDebugFile(t, filepath.Join(preparationDir, "preparation.json"))
for _, want := range []string{"Use the supplied weather facts.", `"type": "object"`, "https://llm.example.test/v1/chat?api_key=%5Bredacted%5D", `"temperature": 0.2`, `"api_key": "[redacted]"`} {
if !strings.Contains(string(preparationData), want) {
t.Fatalf("preparation debug artifact missing %q:\n%s", want, preparationData)
}
}
executionData := readPromptDebugFile(t, filepath.Join(executionDir, "execution.json"))
for _, want := range []string{"Generated forecast prose.", "validation details", `"status": "passed"`} {
if !strings.Contains(string(executionData), want) {
t.Fatalf("execution debug artifact missing %q:\n%s", want, executionData)
}
}
for _, data := range [][]byte{preparationData, executionData} {
if strings.Contains(string(data), "credential") || strings.Contains(string(data), "resolved-secret-value") {
t.Fatalf("debug artifact contains credentials:\n%s", data)
}
}
if runtime.GOOS != "windows" {
assertPromptDebugMode(t, root, debugDirectoryMode)
assertPromptDebugMode(t, preparationDir, debugDirectoryMode)
assertPromptDebugMode(t, filepath.Join(preparationDir, "preparation.json"), debugFileMode)
assertPromptDebugMode(t, filepath.Join(executionDir, "execution.json"), debugFileMode)
}
}
func TestPromptDebugWriterAtomicallyReplacesArtifacts(t *testing.T) {
writer, err := NewPromptDebugWriter(filepath.Join(t.TempDir(), "debug"))
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
ref := promptDebugRef()
first := promptDebugPreparationFixture()
first.PromptHash = "old-hash"
if _, err := writer.WritePreparation(ref, first, nil); err != nil {
t.Fatalf("first WritePreparation() error = %v", err)
}
second := promptDebugPreparationFixture()
second.PromptHash = "new-hash"
directory, err := writer.WritePreparation(ref, second, nil)
if err != nil {
t.Fatalf("second WritePreparation() error = %v", err)
}
data := readPromptDebugFile(t, filepath.Join(directory, "preparation.json"))
if strings.Contains(string(data), "old-hash") || !strings.Contains(string(data), "new-hash") {
t.Fatalf("replacement artifact = %s", data)
}
temporary, err := filepath.Glob(filepath.Join(directory, ".preparation.json.*.tmp"))
if err != nil || len(temporary) != 0 {
t.Fatalf("temporary files = %v, %v", temporary, err)
}
}
func TestPromptDebugWriterDisabledDoesNotAccessFilesystem(t *testing.T) {
writer, err := NewPromptDebugWriter("")
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
if writer.Enabled() {
t.Fatal("disabled writer reports enabled")
}
directory, err := writer.WritePreparation(PromptDebugRef{ReportID: report.ID("../unsafe")}, promptDebugPreparationFixture(), nil)
if err != nil || directory != "" {
t.Fatalf("disabled WritePreparation() = %q, %v", directory, err)
}
}
func TestPromptDebugWriterRejectsUnsafeRootsAndReferences(t *testing.T) {
root := t.TempDir()
if _, err := NewPromptDebugWriter("relative-debug"); err == nil {
t.Fatal("NewPromptDebugWriter(relative) error = nil")
}
nonDirectory := filepath.Join(root, "not-a-directory")
if err := os.WriteFile(nonDirectory, []byte("x"), 0o600); err != nil {
t.Fatalf("write non-directory root: %v", err)
}
if _, err := NewPromptDebugWriter(nonDirectory); err == nil {
t.Fatal("NewPromptDebugWriter(file) error = nil")
}
if runtime.GOOS != "windows" {
target := filepath.Join(root, "target")
link := filepath.Join(root, "root-link")
if err := os.Mkdir(target, debugDirectoryMode); err != nil {
t.Fatalf("create root symlink target: %v", err)
}
if err := os.Symlink(target, link); err != nil {
t.Fatalf("create root symlink: %v", err)
}
if _, err := NewPromptDebugWriter(link); err == nil {
t.Fatal("NewPromptDebugWriter(symlink) error = nil")
}
}
writer, err := NewPromptDebugWriter(filepath.Join(root, "debug"))
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
for _, ref := range []PromptDebugRef{
{ReportID: report.ID("../daily"), ValidDate: "2026-05-29", RunID: "run-123"},
{ReportID: report.Daily, ValidDate: "2026/05/29", RunID: "run-123"},
{ReportID: report.Daily, ValidDate: "2026-05-29", RunID: "/run-123"},
{ReportID: report.Daily, ValidDate: "not-a-date", RunID: "run-123"},
} {
if _, err := writer.WritePreparation(ref, promptDebugPreparationFixture(), nil); err == nil {
t.Fatalf("WritePreparation(%#v) error = nil", ref)
}
}
if runtime.GOOS != "windows" {
outside := filepath.Join(root, "outside")
if err := os.Mkdir(outside, debugDirectoryMode); err != nil {
t.Fatalf("create symlink component target: %v", err)
}
if err := os.Symlink(outside, filepath.Join(root, "debug", "daily")); err != nil {
t.Fatalf("create symlink component: %v", err)
}
if _, err := writer.WritePreparation(promptDebugRef(), promptDebugPreparationFixture(), nil); err == nil {
t.Fatal("WritePreparation(symlink component) error = nil")
}
}
}
func promptDebugRef() PromptDebugRef {
return PromptDebugRef{ReportID: report.Daily, ValidDate: "2026-05-29", RunID: "run-123"}
}
func promptDebugPreparationFixture() promptexec.Preparation {
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
return promptexec.Preparation{
PromptID: "weather.daily", PromptVersion: "v1", PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash",
InputHashes: map[string]string{"data_package": "input-hash"}, ProfileID: "local", BackendID: "local", ModelName: "weather-model",
Output: promptexec.OutputContract{Format: "json_schema", ValidationMode: "strict", SchemaPath: "schemas/daily.json"},
StartedAt: startedAt, EndedAt: startedAt.Add(time.Second), Duration: time.Second, DataPackagePath: "/packages/daily.yaml",
}
}
func promptDebugExecutionFixture() promptexec.Execution {
startedAt := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
return promptexec.Execution{
RunID: "run-123", PromptID: "weather.daily", PromptVersion: "v1", PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash",
InputHashes: map[string]string{"data_package": "input-hash"}, ProfileID: "local", BackendID: "local", ModelName: "weather-model",
GeneratedHash: "generated-hash", Usage: promptexec.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
StartedAt: startedAt, EndedAt: startedAt.Add(time.Second), Duration: time.Second,
Validation: promptexec.NewValidation(promptexec.ValidationPassed, "strict", "schemas/daily.json", []string{"validation details"}),
DataPackagePath: "/packages/daily.yaml", RawOutput: []byte("Generated forecast prose."),
Debug: &promptexec.ExecutionDebug{ValidationDiagnostics: []string{"validation details"}},
}
}
func readPromptDebugFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %q: %v", path, err)
}
return data
}
func assertPromptDebugMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %q: %v", path, err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("mode for %q = %#o, want %#o", path, got, want)
}
}