Unify previous source resolution
This commit is contained in:
@@ -1099,6 +1099,36 @@ func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStatusDetectsMissingRequiredPreviousArtifact(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||
replaceInFileOrFatal(t, pipelinePath, "source: narratio.artifact.session_recap", "source: narratio.previous_session.artifact.session_recap")
|
||||
replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n")
|
||||
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
fake := &storage.FakeBackend{}
|
||||
seedRestorePreviousCurrentManifestOnly(t, fake, cfg)
|
||||
var storeInitCalls int
|
||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{
|
||||
"session", "status", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `Previous-session artifacts: unavailable: remote required previous-session artifact "session_recap" object missing`) {
|
||||
t.Fatalf("stdout = %q, want missing required previous artifact", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
|
||||
)
|
||||
|
||||
type stableInputCheck struct {
|
||||
@@ -34,9 +36,9 @@ type remoteAudioCheck struct {
|
||||
}
|
||||
|
||||
type previousArtifactReadiness struct {
|
||||
Requirements []artifacts.PreviousArtifactRequirement
|
||||
MissingID bool
|
||||
Err error
|
||||
Requirements []artifacts.PreviousArtifactRequirement
|
||||
SkippedMissing []string
|
||||
Err error
|
||||
}
|
||||
|
||||
type remoteCurrentStateCheck struct {
|
||||
@@ -152,23 +154,27 @@ func inspectPreviousArtifactReadiness(
|
||||
if len(requirements) == 0 {
|
||||
return out
|
||||
}
|
||||
if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" {
|
||||
out.MissingID = true
|
||||
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||
out.Err = fmt.Errorf("resolved config with pipeline/session is required")
|
||||
return out
|
||||
}
|
||||
if store == nil {
|
||||
out.Err = fmt.Errorf("previous-session artifacts cannot be checked because storage is unavailable")
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
plan, err := previouscache.Resolve(ctx, cfg, paths, requirements, store)
|
||||
if err != nil {
|
||||
var pointerMissing *artifacts.CurrentRunPointerMissingError
|
||||
var manifestMissing *artifacts.CurrentManifestMissingError
|
||||
if errors.As(err, &pointerMissing) {
|
||||
out.Err = fmt.Errorf("remote %w", pointerMissing)
|
||||
return out
|
||||
}
|
||||
if errors.As(err, &manifestMissing) {
|
||||
out.Err = fmt.Errorf("remote %w", manifestMissing)
|
||||
return out
|
||||
}
|
||||
out.Err = fmt.Errorf("remote %w", err)
|
||||
return out
|
||||
}
|
||||
|
||||
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
if _, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{
|
||||
ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID),
|
||||
ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign),
|
||||
ValidateRunID: true,
|
||||
}); err != nil {
|
||||
out.Err = fmt.Errorf("remote %v", err)
|
||||
}
|
||||
out.SkippedMissing = append([]string(nil), plan.SkippedMissing...)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -57,12 +57,14 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
||||
previous := inspectPreviousArtifactReadiness(ctx, cfg, store, requirements)
|
||||
if len(previous.Requirements) == 0 {
|
||||
findings = append(findings, okFinding("previous", "no previous-session artifacts required"))
|
||||
} else if previous.MissingID {
|
||||
findings = append(findings, errorFinding("previous", "previous_session_id is required by configured previous-session artifacts"))
|
||||
} else if previous.Err != nil {
|
||||
findings = append(findings, errorFinding("previous", previous.Err.Error()))
|
||||
} else {
|
||||
for _, req := range previous.Requirements {
|
||||
if !req.Required && previousRequirementSkipped(previous.SkippedMissing, req.Name) {
|
||||
findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=false unavailable", req.Name)))
|
||||
continue
|
||||
}
|
||||
findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required)))
|
||||
}
|
||||
}
|
||||
@@ -82,3 +84,12 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings)
|
||||
}
|
||||
|
||||
func previousRequirementSkipped(values []string, name string) bool {
|
||||
for _, value := range values {
|
||||
if value == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -154,10 +154,6 @@ func writeStatusPreviousArtifacts(out io.Writer, readiness previousArtifactReadi
|
||||
fmt.Fprintln(out, "Previous-session artifacts: not required")
|
||||
return
|
||||
}
|
||||
if readiness.MissingID {
|
||||
fmt.Fprintln(out, "Previous-session artifacts: unavailable: previous_session_id is required by configured previous-session artifacts")
|
||||
return
|
||||
}
|
||||
if readiness.Err != nil {
|
||||
fmt.Fprintf(out, "Previous-session artifacts: unavailable: %v\n", readiness.Err)
|
||||
return
|
||||
@@ -167,5 +163,9 @@ func writeStatusPreviousArtifacts(out io.Writer, readiness previousArtifactReadi
|
||||
names = append(names, fmt.Sprintf("%s(required=%t)", req.Name, req.Required))
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(readiness.SkippedMissing) > 0 {
|
||||
fmt.Fprintf(out, "Previous-session artifacts: ready: %s; optional unavailable: %s\n", strings.Join(names, ", "), strings.Join(readiness.SkippedMissing, ", "))
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "Previous-session artifacts: ready: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -42,6 +43,45 @@ func TestCommittedRestorePlanUsesOnlyDeclaredObjects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedRestoreReusesVerifiedManifestCandidate(t *testing.T) {
|
||||
cfg := restorePlanConfig(t)
|
||||
fake := &storage.FakeBackend{}
|
||||
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", nil)
|
||||
|
||||
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||
}
|
||||
if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err != nil {
|
||||
t.Fatalf("executeRestorePlan() error = %v", err)
|
||||
}
|
||||
if got := fakeDownloadCount(fake, current.CurrentManifestKey); got != 1 {
|
||||
t.Fatalf("manifest downloads = %d, want one discovery transfer reused by restore", got)
|
||||
}
|
||||
if got := fakeDownloadBytes(fake, current.CurrentManifestKey); got != int64(len(current.ManifestData)) {
|
||||
t.Fatalf("manifest bytes transferred = %d, want one verified candidate (%d)", got, len(current.ManifestData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedRestoreRejectsChangedGenerationForVerifiedManifestCandidate(t *testing.T) {
|
||||
cfg := restorePlanConfig(t)
|
||||
fake := &storage.FakeBackend{}
|
||||
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", nil)
|
||||
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||
}
|
||||
fake.SeedObject(storage.FakeObject{Key: current.CurrentManifestKey, Data: []byte("changed manifest"), ETag: "changed-generation"})
|
||||
|
||||
_, err = executeRestorePlan(context.Background(), cfg, current, plan, nil, fake)
|
||||
if err == nil || !strings.Contains(err.Error(), "generation mismatch") {
|
||||
t.Fatalf("executeRestorePlan() error = %v, want generation mismatch", err)
|
||||
}
|
||||
if got := fakeDownloadCount(fake, current.CurrentManifestKey); got != 1 {
|
||||
t.Fatalf("manifest downloads = %d, want no second transfer for rejected candidate", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommittedStatusReportsOnlyDeclaredPublishedOutputs(t *testing.T) {
|
||||
cfg := restorePlanConfig(t)
|
||||
cfg.Pipeline.Publish = &config.PublishConfig{Outputs: []config.PublishOutputRule{{
|
||||
@@ -252,6 +292,16 @@ func remoteRestoreArtifact(fake *storage.FakeBackend, artifactType artifacts.Rem
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDownloadBytes(fake *storage.FakeBackend, key string) int64 {
|
||||
var count int64
|
||||
for _, call := range fake.Downloads {
|
||||
if call.Key == key {
|
||||
count += call.Bytes
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func restoreCommitSHA256(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
|
||||
@@ -21,6 +21,7 @@ type RemoteCurrentState struct {
|
||||
SessionID string
|
||||
Campaign string
|
||||
Manifest *manifest.Manifest
|
||||
ManifestData []byte
|
||||
Commit *artifacts.RemoteCommitManifest
|
||||
}
|
||||
|
||||
@@ -60,6 +61,7 @@ func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store s
|
||||
SessionID: strings.TrimSpace(current.Manifest.SessionID),
|
||||
Campaign: strings.TrimSpace(current.Manifest.Campaign),
|
||||
Manifest: current.Manifest,
|
||||
ManifestData: append([]byte(nil), current.ManifestData...),
|
||||
Commit: current.Commit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -104,6 +105,10 @@ func executeRestoreDownloadAction(
|
||||
return fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
temporary, err := fileops.DownloadToSiblingTemp(safeLocalPath, func(destination io.Writer) error {
|
||||
if len(action.VerifiedContent) > 0 {
|
||||
_, err := io.Copy(destination, bytes.NewReader(action.VerifiedContent))
|
||||
return err
|
||||
}
|
||||
return storage.DownloadTo(ctx, store, action.RemoteKey, destination)
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -235,6 +235,9 @@ func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
|
||||
if !strings.Contains(stdout.String(), "previous/artifacts/session_recap.md") {
|
||||
t.Fatalf("stdout = %q, want planned previous-cache artifact", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "may read remote data; no session files will be written") {
|
||||
t.Fatalf("stdout = %q, want dry-run remote-read notice", stdout.String())
|
||||
}
|
||||
|
||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
if _, err := os.Stat(filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md")); !os.IsNotExist(err) {
|
||||
|
||||
@@ -51,6 +51,7 @@ type RestoreAction struct {
|
||||
Conflict bool
|
||||
ConflictKind RestoreConflictKind
|
||||
Reason string
|
||||
VerifiedContent []byte
|
||||
}
|
||||
|
||||
// RestorePlan is the deterministic output of restore planning.
|
||||
@@ -159,6 +160,9 @@ func buildCommittedRestoreActions(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify committed object %q: %w", artifact.DestinationKey, err)
|
||||
}
|
||||
if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest && artifact.DestinationKey == current.CurrentManifestKey {
|
||||
action.VerifiedContent = append([]byte(nil), current.ManifestData...)
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
return actions, nil
|
||||
@@ -343,7 +347,7 @@ func buildPreviousCacheRestoreActions(
|
||||
if len(requirements) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
plan, err := previouscache.BuildPlan(ctx, cfg, sessionPaths, requirements, store)
|
||||
plan, err := previouscache.Resolve(ctx, cfg, sessionPaths, requirements, store)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan previous-session cache restore: %w", err)
|
||||
}
|
||||
@@ -355,6 +359,7 @@ func buildPreviousCacheRestoreActions(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
|
||||
}
|
||||
action.VerifiedContent = append([]byte(nil), record.VerifiedContent...)
|
||||
actions = append(actions, action)
|
||||
}
|
||||
return actions, nil
|
||||
|
||||
@@ -176,6 +176,9 @@ func writeRestoreDryRunSummary(out io.Writer, report *RestoreReport) error {
|
||||
if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, "Remote current-state and object-identity checks may read remote data; no session files will be written."); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, action := range report.Actions {
|
||||
line := ""
|
||||
switch action.Status {
|
||||
|
||||
Reference in New Issue
Block a user