enforce lifecycle contracts and scratch transport
This commit is contained in:
@@ -194,6 +194,16 @@ func ScratchCommit(root, branch, message string) error {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return errors.New("scratch commit message required")
|
||||
}
|
||||
status, err := exec.Command("git", "-C", root, "status", "--porcelain", "--", "TASK.md").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(status) != 0 {
|
||||
return errors.New("TASK.md is immutable")
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return errors.New("scratch commit message required")
|
||||
}
|
||||
for _, args := range [][]string{{"switch", "-c", branch}, {"add", "-A"}, {"commit", "-m", message}} {
|
||||
if err := exec.Command("git", append([]string{"-C", root}, args...)...).Run(); err != nil {
|
||||
return err
|
||||
@@ -202,6 +212,23 @@ func ScratchCommit(root, branch, message string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ScratchPush(root, branch, remote string) error {
|
||||
if branch == "" || remote == "" {
|
||||
return errors.New("scratch branch and remote required")
|
||||
}
|
||||
return exec.Command("git", "-C", root, "push", remote, branch).Run()
|
||||
}
|
||||
|
||||
func ScratchPull(root, branch, remote string) error {
|
||||
if branch == "" || remote == "" {
|
||||
return errors.New("scratch branch and remote required")
|
||||
}
|
||||
if err := exec.Command("git", "-C", root, "fetch", remote, branch).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
return exec.Command("git", "-C", root, "merge", "--ff-only", "FETCH_HEAD").Run()
|
||||
}
|
||||
|
||||
// ScratchSync pushes/pulls a scratch branch. Pull uses fast-forward-only to
|
||||
// avoid silently merging independent WIP histories.
|
||||
func ScratchSync(root, branch, remote string, push bool) error {
|
||||
|
||||
@@ -55,6 +55,31 @@ func TestDecodeRejectsUnknownKnowledgeFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScratchCommitProtectsTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
run := func(a ...string) {
|
||||
c := exec.Command("git", append([]string{"-C", root}, a...)...)
|
||||
c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example")
|
||||
if b, e := c.CombinedOutput(); e != nil {
|
||||
t.Fatalf("git: %s %v", b, e)
|
||||
}
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, "TASK.md"), []byte("fixed"), 0644)
|
||||
run("init")
|
||||
run("add", ".")
|
||||
run("commit", "-m", "init")
|
||||
os.WriteFile(filepath.Join(root, "wip.txt"), []byte("wip"), 0644)
|
||||
if err := ScratchCommit(root, "scratch/task", "wip"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "TASK.md"), []byte("changed"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ScratchCommit(root, "scratch/other", "bad"); err == nil {
|
||||
t.Fatal("expected immutable TASK.md rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTaskFileRejectsMutation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "TASK.md"), []byte("task"), 0644); err != nil {
|
||||
|
||||
@@ -131,20 +131,27 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
return fmt.Errorf("%w: expected_version invalid", ErrInvalid)
|
||||
}
|
||||
case "TaskReleased":
|
||||
if v, ok := p["anchor_sha"].(string); ok && (len(v) != 40 || strings.TrimSpace(v) != v) {
|
||||
return fmt.Errorf("%w: anchor_sha invalid", ErrInvalid)
|
||||
}
|
||||
if err := requiredString("handoff_ref"); err != nil && p["reason"] == nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := p["handoff_ref"]; ok {
|
||||
if err := requiredHash(p, "handoff_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
v, ok := p["anchor_sha"].(string)
|
||||
if !ok || len(v) != 40 || strings.TrimSpace(v) != v {
|
||||
return fmt.Errorf("%w: anchor_sha invalid", ErrInvalid)
|
||||
}
|
||||
}
|
||||
case "TaskCompleted":
|
||||
if err := requiredString("report_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
if receipt, ok := p["receipt"]; ok {
|
||||
if m, ok := receipt.(map[string]any); !ok || len(m) == 0 {
|
||||
return fmt.Errorf("%w: receipt invalid", ErrInvalid)
|
||||
}
|
||||
if err := requiredHash(p, "report_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
if receipt, ok := p["receipt"].(map[string]any); !ok || len(receipt) == 0 {
|
||||
return fmt.Errorf("%w: receipt required", ErrInvalid)
|
||||
}
|
||||
case "TaskFailed":
|
||||
if err := requiredString("reason"); err != nil {
|
||||
@@ -154,6 +161,11 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
if err := requiredString("blocker"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := p["handoff_ref"]; ok {
|
||||
if err := requiredHash(p, "handoff_ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "TaskAmended":
|
||||
if len(p) == 0 {
|
||||
return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid)
|
||||
@@ -182,3 +194,14 @@ func ValidatePayload(typ string, p map[string]any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiredHash(p map[string]any, key string) error {
|
||||
v, ok := p[key].(string)
|
||||
if !ok || len(v) != 64 {
|
||||
return fmt.Errorf("%w: %s must be sha256", ErrInvalid, key)
|
||||
}
|
||||
if _, err := hex.DecodeString(v); err != nil {
|
||||
return fmt.Errorf("%w: %s must be sha256", ErrInvalid, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -316,6 +316,9 @@ func (s *Store) Task(id string) (domain.Task, bool) {
|
||||
}
|
||||
|
||||
func (s *Store) Lease(id, harness string, ttl time.Duration) (domain.Event, error) {
|
||||
if ttl <= 0 {
|
||||
return domain.Event{}, fmt.Errorf("%w: ttl must be positive", domain.ErrInvalid)
|
||||
}
|
||||
t, ok := s.Task(id)
|
||||
if !ok {
|
||||
return domain.Event{}, domain.ErrNotFound
|
||||
@@ -323,7 +326,7 @@ func (s *Store) Lease(id, harness string, ttl time.Duration) (domain.Event, erro
|
||||
if t.State != domain.StateQueued {
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": harness, "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version})
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": harness, "ttl": ttl.Seconds(), "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version})
|
||||
e := domain.Event{ID: id, Type: "TaskLeased", TaskID: id, Version: t.Version + 1, Payload: p}
|
||||
return e, s.Append(e)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ func TestAppendReplayAndDeduplicate(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
completion, _ := json.Marshal(map[string]string{"report_ref": ref})
|
||||
completion, _ := json.Marshal(map[string]any{"report_ref": ref, "receipt": map[string]any{"harness_id": "h", "consumed": 1}})
|
||||
if err := s.Append(domain.Event{Type: "TaskCompleted", TaskID: "task-1", Version: 2, Payload: completion}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{Type: "TaskReleased", TaskID: "task-1", Version: 2, Payload: json.RawMessage(`{"handoff_ref":"x"}`)}); err != domain.ErrConflict {
|
||||
if err := s.Append(domain.Event{Type: "TaskReleased", TaskID: "task-1", Version: 2, Payload: json.RawMessage(`{"handoff_ref":"` + ref + `","anchor_sha":"0123456789012345678901234567890123456789"}`)}); err != domain.ErrConflict {
|
||||
t.Fatalf("expected conflict, got %v", err)
|
||||
}
|
||||
s2, err := Open(dir)
|
||||
|
||||
Reference in New Issue
Block a user