picoclaw/pkg/evolution/apply.go

189 lines
4.8 KiB
Go

package evolution
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills"
)
type Applier struct {
paths Paths
now func() time.Time
}
func NewApplier(paths Paths, now func() time.Time) *Applier {
if now == nil {
now = time.Now
}
return &Applier{
paths: paths,
now: now,
}
}
func (a *Applier) ApplyDraft(ctx context.Context, workspace string, draft SkillDraft) error {
rollback, err := a.applyDraftWithRollback(ctx, workspace, draft)
if err != nil {
return err
}
_ = rollback
return nil
}
func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string, draft SkillDraft) (func() error, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil {
return nil, err
}
existingBody, backupPath, hadOriginal, err := a.backupCurrentSkill(workspace, draft.TargetSkillName)
if err != nil {
return nil, err
}
skillDir := filepath.Join(workspace, "skills", draft.TargetSkillName)
if err := os.MkdirAll(skillDir, 0o755); err != nil {
return nil, err
}
renderedBody, err := renderAppliedBody(draft, existingBody, hadOriginal)
if err != nil {
return nil, err
}
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := fileutil.WriteFileAtomic(skillPath, []byte(renderedBody), 0o644); err != nil {
return nil, err
}
if err := validateAppliedSkillBody(renderedBody); err != nil {
if rollbackErr := a.rollbackSkill(skillPath, backupPath, hadOriginal); rollbackErr != nil {
return nil, errorsJoin(err, rollbackErr)
}
return nil, err
}
return func() error {
return a.rollbackSkill(skillPath, backupPath, hadOriginal)
}, nil
}
func (a *Applier) backupCurrentSkill(workspace, skillName string) (currentBody, backupPath string, hadOriginal bool, err error) {
if err := skills.ValidateSkillName(skillName); err != nil {
return "", "", false, err
}
skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md")
data, err := os.ReadFile(skillPath)
if os.IsNotExist(err) {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
backupDir := filepath.Join(a.paths.BackupsDir, skillName, a.now().Format("20060102-150405"))
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", "", false, err
}
backupPath = filepath.Join(backupDir, "SKILL.md")
if err := fileutil.WriteFileAtomic(backupPath, data, 0o644); err != nil {
return "", "", false, err
}
return string(data), backupPath, true, nil
}
func (a *Applier) rollbackSkill(skillPath, backupPath string, hadOriginal bool) error {
if hadOriginal {
data, err := os.ReadFile(backupPath)
if err != nil {
return err
}
return fileutil.WriteFileAtomic(skillPath, data, 0o644)
}
if err := os.Remove(skillPath); err != nil && !os.IsNotExist(err) {
return err
}
skillDir := filepath.Dir(skillPath)
if err := os.Remove(skillDir); err != nil && !os.IsNotExist(err) && !isDirNotEmptyError(err) {
return err
}
return nil
}
func isDirNotEmptyError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "directory not empty")
}
func validateAppliedSkillBody(body string) error {
body = strings.TrimSpace(body)
if !strings.HasPrefix(body, "---\n") {
return fmt.Errorf("skill frontmatter is required")
}
if !strings.Contains(body, "\n# ") {
return fmt.Errorf("skill heading is required")
}
return nil
}
func renderAppliedBody(draft SkillDraft, existingBody string, hadOriginal bool) (string, error) {
switch draft.ChangeKind {
case ChangeKindCreate:
if hadOriginal {
return "", fmt.Errorf("cannot create skill %q: skill already exists", draft.TargetSkillName)
}
return draft.BodyOrPatch, nil
case ChangeKindReplace:
if !hadOriginal {
return "", fmt.Errorf("cannot replace skill %q: skill does not exist", draft.TargetSkillName)
}
return draft.BodyOrPatch, nil
case ChangeKindAppend:
if !hadOriginal || strings.TrimSpace(existingBody) == "" {
return draft.BodyOrPatch, nil
}
return strings.TrimRight(existingBody, "\n") + "\n\n" + strings.TrimLeft(draft.BodyOrPatch, "\n"), nil
case ChangeKindMerge:
if !hadOriginal || strings.TrimSpace(existingBody) == "" {
return draft.BodyOrPatch, nil
}
mergedSection := strings.Join([]string{
"",
"## Merged Knowledge",
strings.TrimSpace(draft.BodyOrPatch),
"",
}, "\n")
return strings.TrimRight(existingBody, "\n") + mergedSection, nil
default:
return "", fmt.Errorf("unsupported change_kind %q", draft.ChangeKind)
}
}
func errorsJoin(errs ...error) error {
var first error
for _, err := range errs {
if err == nil {
continue
}
if first == nil {
first = err
continue
}
first = fmt.Errorf("%w; %v", first, err)
}
return first
}