From f748be194ae763a40767aa99c39ddf17b98bf3a5 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 26 Jul 2026 20:45:22 +0400 Subject: [PATCH] enforce lifecycle contracts and scratch transport --- internal/continuity/continuity.go | 27 +++++++++++++++++++ internal/continuity/continuity_test.go | 25 +++++++++++++++++ internal/domain/domain.go | 37 +++++++++++++++++++++----- internal/store/store.go | 5 +++- internal/store/store_test.go | 4 +-- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/internal/continuity/continuity.go b/internal/continuity/continuity.go index ebeb4df..3be246b 100644 --- a/internal/continuity/continuity.go +++ b/internal/continuity/continuity.go @@ -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 { diff --git a/internal/continuity/continuity_test.go b/internal/continuity/continuity_test.go index 14c941b..4380a05 100644 --- a/internal/continuity/continuity_test.go +++ b/internal/continuity/continuity_test.go @@ -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 { diff --git a/internal/domain/domain.go b/internal/domain/domain.go index a41f12e..78c22c8 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index 7f64398..cda911c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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) } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a701ab8..2588c91 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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)