feat: advance svglide run stages

This commit is contained in:
songtianyi.theo
2026-07-03 01:56:23 +08:00
parent f043ee61d8
commit 15e7ab8b66
2 changed files with 180 additions and 0 deletions

89
internal/svglide/stage.go Normal file
View File

@@ -0,0 +1,89 @@
package svglide
import (
"fmt"
"path/filepath"
"strings"
"time"
)
type StageReceipt struct {
Stage string `json:"stage"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Artifacts []string `json:"artifacts,omitempty"`
}
func CompleteCurrentStage(root string) (StatusReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return StatusReport{}, err
}
index, stage, err := currentStageWithIndex(run)
if err != nil {
return StatusReport{}, err
}
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
if err != nil {
return StatusReport{}, err
}
if len(missingOutputs) > 0 {
return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", "))
}
if err := writeStageReceipt(safeRoot, StageReceipt{
Stage: stage.Name,
Status: StatusDone,
Artifacts: stage.Outputs,
}); err != nil {
return StatusReport{}, err
}
run.Stages[index].Status = StatusDone
if index < len(run.Stages)-1 {
nextStage := &run.Stages[index+1]
run.CurrentStage = nextStage.Name
if nextStage.Status == "" {
nextStage.Status = StatusPending
}
} else {
run.CurrentStage = stage.Name
}
run.UpdatedAt = time.Now().Format(time.RFC3339)
if err := writeRunFile(safeRoot, run); err != nil {
return StatusReport{}, err
}
return InspectStatus(root)
}
func currentStageWithIndex(run Run) (int, Stage, error) {
for i, stage := range run.Stages {
if stage.Name == run.CurrentStage {
return i, stage, nil
}
}
return -1, Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
}
func writeRunFile(safeRoot string, run Run) error {
target, err := ensureRunFileTargetForWrite(safeRoot, "run.json")
if err != nil {
return err
}
return writeJSON(target, run)
}
func writeStageReceipt(safeRoot string, receipt StageReceipt) error {
if strings.TrimSpace(receipt.Stage) == "" {
return fmt.Errorf("stage receipt stage must not be empty")
}
if strings.ContainsAny(receipt.Stage, `/\`) || receipt.Stage == "." || receipt.Stage == ".." {
return fmt.Errorf("stage receipt stage %q must be a file name", receipt.Stage)
}
target, err := ensureRunFileTargetForWrite(safeRoot, filepath.Join("receipts", receipt.Stage+".json"))
if err != nil {
return err
}
return writeJSON(target, receipt)
}

View File

@@ -0,0 +1,91 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestCompleteCurrentStageAdvancesToNextStage(t *testing.T) {
initStatusTestRun(t)
status, err := CompleteCurrentStage("demo")
if err != nil {
t.Fatal(err)
}
if status.CurrentStage != StageResearch {
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageResearch)
}
run := readStatusTestRunFile(t)
if run.CurrentStage != StageResearch {
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageResearch)
}
if got := stageStatus(t, run, StageRequest); got != StatusDone {
t.Fatalf("request stage status = %q, want %q", got, StatusDone)
}
if got := stageStatus(t, run, StageResearch); got != StatusPending {
t.Fatalf("research stage status = %q, want %q", got, StatusPending)
}
raw, err := os.ReadFile(filepath.Join("demo", "receipts", "request.json"))
if err != nil {
t.Fatalf("missing request receipt: %v", err)
}
var receipt StageReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatalf("invalid request receipt: %v", err)
}
if receipt.Stage != StageRequest || receipt.Status != StatusDone {
t.Fatalf("receipt = %+v, want request done", receipt)
}
}
func TestCompleteCurrentStageRejectsMissingOutput(t *testing.T) {
initStatusTestRun(t)
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
t.Fatal(err)
}
_, err := CompleteCurrentStage("demo")
if err == nil {
t.Fatal("expected missing output error")
}
run := readStatusTestRunFile(t)
if run.CurrentStage != StageRequest {
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
}
}
func TestCompleteCurrentStageDoesNotAdvanceRunWhenReceiptWriteFails(t *testing.T) {
initStatusTestRun(t)
if err := os.Mkdir(filepath.Join("demo", "receipts", "request.json"), 0o755); err != nil {
t.Fatal(err)
}
_, err := CompleteCurrentStage("demo")
if err == nil {
t.Fatal("expected receipt write error")
}
run := readStatusTestRunFile(t)
if run.CurrentStage != StageRequest {
t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest)
}
if got := stageStatus(t, run, StageRequest); got == StatusDone {
t.Fatalf("request stage status = %q, want not %q", got, StatusDone)
}
}
func stageStatus(t *testing.T, run Run, name string) string {
t.Helper()
for _, stage := range run.Stages {
if stage.Name == name {
return stage.Status
}
}
t.Fatalf("missing stage %q", name)
return ""
}