Close out the repository audit
This commit is contained in:
@@ -359,7 +359,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
|
||||
var cancellation error
|
||||
for index, planned := range plannedReports {
|
||||
if cancellation = batchCancellationCause(ctx, nil); cancellation != nil {
|
||||
if cancellation = batchContextCancellationCause(ctx); cancellation != nil {
|
||||
appendCanceledBatchReports(result, plannedReports[index:])
|
||||
break
|
||||
}
|
||||
@@ -382,7 +382,8 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
copyBatchReportDetails(&item, reportResult)
|
||||
}
|
||||
if err != nil {
|
||||
if cancellation = batchCancellationCause(ctx, err); cancellation != nil {
|
||||
if reportCancellation := batchReportCancellationCause(err); reportCancellation != nil {
|
||||
cancellation = reportCancellation
|
||||
item.Status = "canceled"
|
||||
result.Canceled++
|
||||
} else {
|
||||
@@ -396,7 +397,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
}
|
||||
result.Reports = append(result.Reports, item)
|
||||
if cancellation == nil {
|
||||
cancellation = batchCancellationCause(ctx, nil)
|
||||
cancellation = batchContextCancellationCause(ctx)
|
||||
}
|
||||
if cancellation != nil {
|
||||
appendCanceledBatchReports(result, plannedReports[index+1:])
|
||||
@@ -404,7 +405,11 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
}
|
||||
}
|
||||
result.Total = len(result.Reports)
|
||||
batchNotification := notifyBatch(ctx, req.Config, req.Batch, batchRunID(startedAt, req.Batch), startedAt, result, plannedReports, req.Notifier)
|
||||
batchNotification := notifyBatch(batchNotificationInput{
|
||||
ctx: ctx, cancellation: cancellation, cfg: req.Config, batch: req.Batch,
|
||||
runID: batchRunID(startedAt, req.Batch), startedAt: startedAt,
|
||||
result: result, planned: plannedReports, notifier: req.Notifier,
|
||||
})
|
||||
if batchNotification != nil {
|
||||
result.Notification = batchNotification
|
||||
}
|
||||
@@ -414,12 +419,7 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro
|
||||
return nil, fmt.Errorf("run is not implemented")
|
||||
}
|
||||
|
||||
func batchCancellationCause(ctx context.Context, err error) error {
|
||||
if ctx != nil {
|
||||
if contextErr := ctx.Err(); contextErr != nil {
|
||||
return contextErr
|
||||
}
|
||||
}
|
||||
func batchReportCancellationCause(err error) error {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return context.Canceled
|
||||
}
|
||||
@@ -429,6 +429,13 @@ func batchCancellationCause(ctx context.Context, err error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func batchContextCancellationCause(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func appendCanceledBatchReports(result *BatchResult, plannedReports []plannedBatchReport) {
|
||||
if result == nil {
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
)
|
||||
|
||||
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
|
||||
@@ -54,6 +55,48 @@ func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
bundle := generationBundle(t)
|
||||
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
|
||||
notifier := &generationNotifier{}
|
||||
executor := &generationExecutor{
|
||||
executeErr: errors.New("independent report failure"),
|
||||
beforeExecute: func(promptexec.ExecuteRequest) {
|
||||
cancel()
|
||||
},
|
||||
}
|
||||
|
||||
result, err := RunBatchDetailed(ctx, BatchRequest{
|
||||
Config: generationDistributorConfig(), Batch: BatchMorning,
|
||||
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
|
||||
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
|
||||
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
|
||||
}
|
||||
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
|
||||
t.Fatalf("report results = %#v", result.Reports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
notifier := &generationNotifier{}
|
||||
|
||||
result := notifyBatch(batchNotificationInput{
|
||||
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
|
||||
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
|
||||
notifier: notifier,
|
||||
})
|
||||
|
||||
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
|
||||
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
|
||||
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
|
||||
t.Run(cause.Error(), func(t *testing.T) {
|
||||
|
||||
@@ -42,47 +42,59 @@ type batchNotifier interface {
|
||||
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
|
||||
}
|
||||
|
||||
type batchNotificationInput struct {
|
||||
ctx context.Context
|
||||
cancellation error
|
||||
cfg config.Config
|
||||
batch BatchKind
|
||||
runID string
|
||||
startedAt time.Time
|
||||
result *BatchResult
|
||||
planned []plannedBatchReport
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func batchRunID(startedAt time.Time, batch BatchKind) string {
|
||||
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
|
||||
}
|
||||
|
||||
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) *BatchNotificationResult {
|
||||
if !cfg.Notify.Distributor.Enabled {
|
||||
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
|
||||
if !input.cfg.Notify.Distributor.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Notify.Distributor.Batch.Enabled {
|
||||
if !input.cfg.Notify.Distributor.Batch.Enabled {
|
||||
return nil
|
||||
}
|
||||
if result == nil {
|
||||
if input.result == nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
|
||||
}
|
||||
if result.Canceled > 0 {
|
||||
if input.cancellation != nil || batchContextCancellationCause(input.ctx) != nil || input.result.Canceled > 0 {
|
||||
return &BatchNotificationResult{
|
||||
Status: "skipped",
|
||||
Reason: "batch canceled",
|
||||
}
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
if input.result.Failed > 0 {
|
||||
return &BatchNotificationResult{
|
||||
Status: "skipped",
|
||||
Reason: "one or more reports failed",
|
||||
}
|
||||
}
|
||||
|
||||
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
|
||||
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(batchNotificationRequest{}, err)
|
||||
}
|
||||
|
||||
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
|
||||
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
|
||||
if err != nil {
|
||||
return failedBatchNotificationResult(req, err)
|
||||
}
|
||||
|
||||
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
|
||||
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
|
||||
wrappedErr := notifyErr
|
||||
if notifyErr != nil {
|
||||
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
|
||||
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", input.batch, input.runID, req.BundleID, notifyErr)
|
||||
}
|
||||
batchResult := batchNotificationResult(req, notification)
|
||||
if wrappedErr != nil {
|
||||
|
||||
@@ -101,6 +101,9 @@ func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *te
|
||||
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
|
||||
}
|
||||
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
@@ -510,7 +512,7 @@ func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outputPath := filepath.Join(dir, "daily.md")
|
||||
requireSymlink(t, backing, outputPath)
|
||||
testutil.RequireSymlink(t, backing, outputPath)
|
||||
bundle := generationBundle(t)
|
||||
collector := &generationCollector{bundle: &bundle}
|
||||
executor := &generationExecutor{}
|
||||
@@ -531,6 +533,9 @@ func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
})
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err != nil || result == nil || result.LLMDebugPath == "" {
|
||||
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
|
||||
)
|
||||
|
||||
func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||
@@ -46,7 +48,7 @@ func TestResolveComparisonOutputDirectory(t *testing.T) {
|
||||
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
dangling := filepath.Join(workingDir, "dangling")
|
||||
requireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
|
||||
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
|
||||
|
||||
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
|
||||
t.Run(filepath.Base(directory), func(t *testing.T) {
|
||||
@@ -61,7 +63,7 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
|
||||
workingDir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
link := filepath.Join(workingDir, "linked")
|
||||
requireSymlink(t, target, link)
|
||||
testutil.RequireSymlink(t, target, link)
|
||||
|
||||
directory := filepath.Join(link, "reports")
|
||||
got, err := resolveOutputDir(workingDir, directory)
|
||||
@@ -69,10 +71,3 @@ func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
|
||||
t.Fatalf("resolveOutputDir() = %q, %v, want %q, nil", got, err, directory)
|
||||
}
|
||||
}
|
||||
|
||||
func requireSymlink(t *testing.T, target string, link string) {
|
||||
t.Helper()
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink support is unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
|
||||
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
|
||||
prepared, inspection := preparedDailyProfile(t)
|
||||
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
|
||||
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
|
||||
t.Skipf("secure prompt debug capture is unavailable: %v", err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NewPromptDebugWriter() error = %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user