Implement continuity handoffs and pickup validation
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// Package continuity implements the validated, artifact-backed rotation contract.
|
||||
package continuity
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Dirty struct {
|
||||
Path string `json:"path"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
type Completed struct {
|
||||
What string `json:"what"`
|
||||
Evidence string `json:"evidence"`
|
||||
}
|
||||
type DeadEnd struct {
|
||||
Tried string `json:"tried"`
|
||||
WhyFailed string `json:"why_failed"`
|
||||
}
|
||||
type Anchor struct {
|
||||
GitSHA string `json:"git_sha"`
|
||||
Branch string `json:"branch"`
|
||||
Dirty []Dirty `json:"dirty,omitempty"`
|
||||
}
|
||||
type Meta struct {
|
||||
ID string `json:"id"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
RotationIndex int `json:"rotation_index"`
|
||||
}
|
||||
type Handoff struct {
|
||||
Meta Meta `json:"meta"`
|
||||
Anchor Anchor `json:"anchor"`
|
||||
Goal string `json:"goal"`
|
||||
DoneWhen []string `json:"done_when"`
|
||||
Completed []Completed `json:"completed"`
|
||||
Remaining []string `json:"remaining"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
Files []string `json:"files"`
|
||||
Invariants []string `json:"invariants"`
|
||||
DeadEnds []DeadEnd `json:"dead_ends"`
|
||||
OpenQuestions []string `json:"open_questions"`
|
||||
Build string `json:"build"`
|
||||
Test string `json:"test"`
|
||||
LastResult Result `json:"last_result"`
|
||||
}
|
||||
type Result struct {
|
||||
Command string `json:"command"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
AtSHA string `json:"at_sha"`
|
||||
}
|
||||
|
||||
var reasons = map[string]bool{"threshold": true, "milestone": true, "thrash": true, "manual": true}
|
||||
|
||||
func (h Handoff) Validate() error {
|
||||
if strings.TrimSpace(h.Meta.ID) == "" || !reasons[h.Meta.Reason] || h.Meta.RotationIndex < 0 {
|
||||
return errors.New("invalid handoff meta")
|
||||
}
|
||||
if len(h.Anchor.GitSHA) != 40 || h.Anchor.Branch == "" || strings.TrimSpace(h.Goal) == "" || len(h.DoneWhen) == 0 || strings.TrimSpace(h.Action) == "" || strings.TrimSpace(h.Command) == "" {
|
||||
return errors.New("invalid handoff required fields")
|
||||
}
|
||||
for _, d := range h.Anchor.Dirty {
|
||||
if filepath.IsAbs(d.Path) || d.Path == "" || len(d.SHA256) != 64 {
|
||||
return errors.New("invalid dirty anchor")
|
||||
}
|
||||
}
|
||||
for _, d := range h.DeadEnds {
|
||||
if strings.TrimSpace(d.Tried) == "" || strings.TrimSpace(d.WhyFailed) == "" {
|
||||
return errors.New("invalid dead end")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func Encode(h Handoff) ([]byte, error) {
|
||||
if err := h.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(h)
|
||||
}
|
||||
func Decode(b []byte) (Handoff, error) {
|
||||
var h Handoff
|
||||
d := json.NewDecoder(strings.NewReader(string(b)))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&h); err != nil {
|
||||
return h, err
|
||||
}
|
||||
return h, h.Validate()
|
||||
}
|
||||
|
||||
func ValidatePickup(root string, h Handoff, taskFileSHA string) error {
|
||||
if err := h.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
||||
if err != nil || strings.TrimSpace(string(out)) != h.Anchor.GitSHA {
|
||||
return errors.New("handoff anchor HEAD mismatch")
|
||||
}
|
||||
for _, d := range h.Anchor.Dirty {
|
||||
b, e := os.ReadFile(filepath.Join(root, d.Path))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
if hex.EncodeToString(sum[:]) != d.SHA256 {
|
||||
return fmt.Errorf("handoff dirty file changed: %s", d.Path)
|
||||
}
|
||||
}
|
||||
if taskFileSHA != "" {
|
||||
b, e := os.ReadFile(filepath.Join(root, "TASK.md"))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
if hex.EncodeToString(sum[:]) != taskFileSHA {
|
||||
return errors.New("TASK.md changed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CAS interface {
|
||||
PutArtifact([]byte) (string, error)
|
||||
Artifact(string) ([]byte, error)
|
||||
}
|
||||
|
||||
func Save(h Handoff, cas CAS) (string, error) {
|
||||
b, e := Encode(h)
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
return cas.PutArtifact(b)
|
||||
}
|
||||
func Load(ref string, cas CAS) (Handoff, error) {
|
||||
b, e := cas.Artifact(ref)
|
||||
if e != nil {
|
||||
return Handoff{}, e
|
||||
}
|
||||
return Decode(b)
|
||||
}
|
||||
|
||||
type Notice struct {
|
||||
Path string
|
||||
SHA256 string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
func MarkdownChanges(root string, paths []string) ([]Notice, error) {
|
||||
out := []Notice{}
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(strings.ToLower(p), ".md") {
|
||||
continue
|
||||
}
|
||||
b, e := os.ReadFile(filepath.Join(root, p))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
s := sha256.Sum256(b)
|
||||
out = append(out, Notice{p, hex.EncodeToString(s[:]), time.Now().UTC()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ScratchCommit records WIP atomically on a dedicated branch before rotation.
|
||||
func ScratchCommit(root, branch, message string) error {
|
||||
if branch == "" || strings.ContainsAny(branch, " \t\n") {
|
||||
return errors.New("invalid scratch branch")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package continuity
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
func TestHandoffCASAndPickup(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("original"), 0644)
|
||||
run("init")
|
||||
run("add", "TASK.md")
|
||||
run("commit", "-m", "init")
|
||||
head := runOut(t, root, "rev-parse", "HEAD")
|
||||
task := sha256.Sum256([]byte("original"))
|
||||
s, _ := store.Open(t.TempDir())
|
||||
h := Handoff{Meta: Meta{ID: "h1", Reason: "manual"}, Anchor: Anchor{GitSHA: head, Branch: "main"}, Goal: "ship", DoneWhen: []string{"tests pass"}, Action: "test", Command: "go test ./...", LastResult: Result{AtSHA: head}}
|
||||
ref, e := Save(h, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
got, e := Load(ref, s)
|
||||
if e != nil || got.Meta.ID != "h1" {
|
||||
t.Fatalf("load: %v", e)
|
||||
}
|
||||
if e = ValidatePickup(root, got, hex.EncodeToString(task[:])); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func runOut(t *testing.T, root string, a ...string) string {
|
||||
b, e := exec.Command("git", append([]string{"-C", root}, a...)...).Output()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return string(b[:len(b)-1])
|
||||
}
|
||||
|
||||
func TestDecodeRejectsUnknownKnowledgeFields(t *testing.T) {
|
||||
_, e := Decode([]byte(`{"meta":{"id":"x","reason":"manual","rotation_index":0},"unknown":1}`))
|
||||
if e == nil {
|
||||
t.Fatal("expected strict schema error")
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,21 @@ func (s *Store) PutArtifact(b []byte) (string, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Artifact returns a CAS artifact after verifying its content address.
|
||||
func (s *Store) Artifact(ref string) ([]byte, error) {
|
||||
if len(ref) != 64 {
|
||||
return nil, fmt.Errorf("%w: invalid artifact reference", domain.ErrInvalid)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(s.cas, ref))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if domain.Hash(b) != ref {
|
||||
return nil, fmt.Errorf("%w: corrupt artifact", domain.ErrInvalid)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *Store) Task(id string) (domain.Task, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
+6
-6
@@ -36,12 +36,11 @@ This is the implementation-oriented breakdown of the specification. It is a proj
|
||||
- Done: native session usage readers and bounded current-turn occupancy calculation.
|
||||
- Done: anchor validation primitive for split-then-close rotation safety.
|
||||
|
||||
6. **Continuity** — **not started**
|
||||
- Handoff schema and validator
|
||||
- CAS-backed handoffs/reports
|
||||
- Immutable `TASK.md` handling
|
||||
- Scratch-branch pickup contract
|
||||
- Shared Markdown change notifications
|
||||
6. **Continuity** — **complete**
|
||||
- Done: strict JSON handoff schema/validator, including framed knowledge fields and size-safe typed fields.
|
||||
- Done: CAS-backed handoff save/load with content-address verification.
|
||||
- Done: pickup validation against repository HEAD, dirty-file hashes, and immutable `TASK.md` hash.
|
||||
- Done: scratch-branch WIP commit helper and Markdown change notices.
|
||||
|
||||
7. **Authorization and surfaces** — **minimal groundwork only**
|
||||
- Done: a basic notify-only guard for Telegram/ntfy-style requests.
|
||||
@@ -73,6 +72,7 @@ This is the implementation-oriented breakdown of the specification. It is a proj
|
||||
- Unit tests pass with `go test ./...`.
|
||||
- Implemented item 4 router assignment, lease-expiry polling, and retry policy.
|
||||
- Implemented item 5 herdr socket integration, harness adapters, native occupancy readers, bootstrap, and anchor validation.
|
||||
- Implemented item 6 continuity: validated CAS handoffs, pickup anchors/TASK.md, scratch-branch commits, and shared Markdown change notices.
|
||||
|
||||
## Current API additions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user