Harden lease lifecycle durability
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/buildinfo"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/provider"
|
||||
"orchestra/internal/store"
|
||||
@@ -51,6 +52,7 @@ type ProbeFunc func() (bool, string)
|
||||
type Server struct {
|
||||
Store *store.Store
|
||||
RouterReady bool
|
||||
Build buildinfo.Info
|
||||
Probes map[string]ProbeFunc
|
||||
Providers map[string]*provider.Supervisor
|
||||
}
|
||||
@@ -95,7 +97,7 @@ func (s *Server) Diagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tasks := s.Store.Tasks()
|
||||
events := s.Store.Events(0)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tasks": len(tasks), "events": len(events), "last_seq": func() uint64 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"build": s.Build, "tasks": len(tasks), "events": len(events), "last_seq": func() uint64 {
|
||||
if len(events) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
+51
-25
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Surface string
|
||||
@@ -72,18 +74,44 @@ func AuthorizeEvent(s Surface, typ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionCookie carries a browser's proof of the Web-surface token. A
|
||||
// top-level document load cannot set an Authorization header, so a
|
||||
// bearer-only gate forces operators to run the UI with no token at all
|
||||
// (AUDIT.md B18). The cookie is the browser-presentable equivalent; it is
|
||||
// never a second credential, only a receipt for the same token.
|
||||
// SessionCookie carries a browser's proof of a successful Web login. A
|
||||
// top-level document load cannot set an Authorization header, so browser
|
||||
// credentials are exchanged once for this HttpOnly receipt.
|
||||
const SessionCookie = "orchestra_session"
|
||||
|
||||
// SessionPath is the one Web-surface endpoint exempt from the token gate,
|
||||
// because it *is* the token check: it verifies the Web token itself and
|
||||
// exchanges it for a cookie. Gating it would make login unreachable.
|
||||
// SessionPath is the one Web-surface endpoint exempt from the session gate,
|
||||
// because it verifies login credentials and exchanges them for a cookie.
|
||||
const SessionPath = "/v1/ui/session"
|
||||
|
||||
// WebCredentials is the single configured browser operator identity. Only a
|
||||
// bcrypt password hash is accepted; Orchestra has no self-service account
|
||||
// creation or password-reset surface.
|
||||
type WebCredentials struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (c WebCredentials) Validate() error {
|
||||
if strings.TrimSpace(c.Username) == "" {
|
||||
return fmt.Errorf("web username is required")
|
||||
}
|
||||
if c.PasswordHash == "" {
|
||||
return fmt.Errorf("web password hash is required")
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(c.PasswordHash)); err != nil {
|
||||
return fmt.Errorf("web password hash must be bcrypt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate always performs bcrypt, even for an unknown username, so the
|
||||
// response does not reveal whether the configured username was correct.
|
||||
func (c WebCredentials) Authenticate(username, password string) bool {
|
||||
passwordOK := bcrypt.CompareHashAndPassword([]byte(c.PasswordHash), []byte(password)) == nil
|
||||
usernameOK := subtle.ConstantTimeCompare([]byte(username), []byte(c.Username)) == 1
|
||||
return passwordOK && usernameOK
|
||||
}
|
||||
|
||||
// Sessions issues and validates those receipts. Values are random and stored
|
||||
// hashed, so a leaked snapshot of this map does not yield a usable cookie.
|
||||
type Sessions struct {
|
||||
@@ -161,9 +189,9 @@ func HTTP(tokens map[Surface]string, next http.Handler) http.Handler {
|
||||
return HTTPWithSessions(tokens, nil, next)
|
||||
}
|
||||
|
||||
// HTTPWithSessions additionally accepts a valid session cookie in place of a
|
||||
// Bearer token, but only for the Web surface — every non-browser surface
|
||||
// still has to present the token directly.
|
||||
// HTTPWithSessions accepts a valid browser session cookie only for the Web
|
||||
// surface. Supplying Sessions makes Web authentication mandatory even when
|
||||
// the legacy Web bearer-token slot is empty.
|
||||
func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Federation has per-worker credentials, not one shared surface token.
|
||||
@@ -195,26 +223,24 @@ func HTTPWithSessions(tokens map[Surface]string, sessions *Sessions, next http.H
|
||||
if s == System {
|
||||
s = Web
|
||||
}
|
||||
if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
|
||||
if s == Web && sessions != nil {
|
||||
ok := false
|
||||
if s == Web && sessions != nil {
|
||||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||||
ok = sessions.Valid(c.Value)
|
||||
}
|
||||
// The login endpoint authenticates itself, and the SPA shell
|
||||
// must load before a browser can present anything. Static
|
||||
// assets are not secrets; every /v1/ control path stays gated.
|
||||
if r.URL.Path == SessionPath {
|
||||
ok = true
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
|
||||
ok = true
|
||||
}
|
||||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||||
ok = sessions.Valid(c.Value)
|
||||
}
|
||||
// The login endpoint authenticates itself, and the SPA shell must
|
||||
// load before a browser can present a session. Static assets are not
|
||||
// secrets; every other /v1/ control path remains session-gated.
|
||||
if r.URL.Path == SessionPath || (!strings.HasPrefix(r.URL.Path, "/v1/") && (r.Method == http.MethodGet || r.Method == http.MethodHead)) {
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else if expected := tokens[s]; expected != "" && subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) != 1 {
|
||||
http.Error(w, "unauthorized surface", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if (s == Telegram || s == Ntfy) && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "notify-only surface", http.StatusForbidden)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestSurfaceCapabilities(t *testing.T) {
|
||||
@@ -51,7 +53,9 @@ func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
|
||||
// B18: the web UI is a full control plane. A session cookie must be an
|
||||
// alternative *presentation* of the Web token, never a widening of it.
|
||||
func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
|
||||
tokens := map[Surface]string{Web: "secret"}
|
||||
// An empty legacy Web bearer-token slot must not open the browser surface:
|
||||
// providing Sessions means the caller needs a session cookie.
|
||||
tokens := map[Surface]string{}
|
||||
sessions := &Sessions{}
|
||||
h := HTTPWithSessions(tokens, sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -96,6 +100,28 @@ func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebCredentialsAuthenticate(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := WebCredentials{Username: "operator", PasswordHash: string(hash)}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !c.Authenticate("operator", "correct horse battery staple") {
|
||||
t.Fatal("correct credentials rejected")
|
||||
}
|
||||
for _, attempt := range []struct{ username, password string }{{"operator", "wrong"}, {"other", "correct horse battery staple"}} {
|
||||
if c.Authenticate(attempt.username, attempt.password) {
|
||||
t.Fatalf("invalid credentials accepted: %+v", attempt)
|
||||
}
|
||||
}
|
||||
if err := (WebCredentials{Username: "operator", PasswordHash: "not-a-bcrypt-hash"}).Validate(); err == nil {
|
||||
t.Fatal("invalid bcrypt hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRequestsUseTheirOwnCredentials(t *testing.T) {
|
||||
tokens := map[Surface]string{Web: "web-secret"}
|
||||
h := HTTPWithSessions(tokens, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package buildinfo exposes the provenance injected into Orchestra binaries.
|
||||
// Build systems should set Revision, Time, and Dirty with -ldflags. Keeping
|
||||
// the defaults explicit makes development binaries honest rather than
|
||||
// pretending to be a deployable revision.
|
||||
package buildinfo
|
||||
|
||||
var (
|
||||
Revision = "devel"
|
||||
Time = "unknown"
|
||||
Dirty = "unknown"
|
||||
)
|
||||
|
||||
type Info struct {
|
||||
Revision string `json:"revision"`
|
||||
Time string `json:"time"`
|
||||
Dirty string `json:"dirty"`
|
||||
}
|
||||
|
||||
func Current() Info { return Info{Revision: Revision, Time: Time, Dirty: Dirty} }
|
||||
@@ -22,11 +22,11 @@ var ErrInvalid = errors.New("invalid event")
|
||||
// task for this content; nothing was appended.
|
||||
var ErrDuplicate = errors.New("duplicate task ingestion")
|
||||
|
||||
// CurrentEventSchema is 2: schema 2 requires every event to declare its
|
||||
// authorizing Surface (see ValidateEvent), enforced at the store append
|
||||
// boundary. Schema 1 events already on disk replay unchanged — tolerant
|
||||
// reader, not upcast (spec open question #2).
|
||||
const CurrentEventSchema = 2
|
||||
// CurrentEventSchema is 3: schema 2 requires every event to declare its
|
||||
// authorizing Surface; schema 3 adds lease fencing epochs. Older events stay
|
||||
// readable so a deployment can recover its existing log before new writes
|
||||
// are emitted (the store derives a non-renewable legacy epoch on replay).
|
||||
const CurrentEventSchema = 3
|
||||
|
||||
type TaskState string
|
||||
|
||||
@@ -102,8 +102,13 @@ type SessionEvidence struct {
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
}
|
||||
type Lease struct {
|
||||
HarnessID string `json:"harness_id"`
|
||||
Until time.Time `json:"until"`
|
||||
HarnessID string `json:"harness_id"`
|
||||
// Epoch is an opaque fencing token minted for every assignment. Versions
|
||||
// change for ordinary lifecycle events; an epoch changes only when
|
||||
// ownership changes, so an old pane can never become current again after
|
||||
// a release/re-lease cycle.
|
||||
Epoch string `json:"epoch"`
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
@@ -191,7 +196,18 @@ func ValidateEvent(e Event) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("%w: payload must be an object", ErrInvalid)
|
||||
}
|
||||
return ValidatePayload(e.Type, p)
|
||||
if err := ValidatePayload(e.Type, p); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.SchemaVersion >= 3 {
|
||||
switch e.Type {
|
||||
case "TaskLeased", "TaskLeaseRenewed", "TaskPickupValidated":
|
||||
if v, ok := p["lease_epoch"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("%w: lease_epoch required", ErrInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func ValidateCreated(p map[string]any) error {
|
||||
for _, k := range []string{"source", "external_id", "project"} {
|
||||
|
||||
@@ -131,8 +131,8 @@ func (c Client) Heartbeat(ctx context.Context, health WorkerHealth) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (c Client) Renew(ctx context.Context, taskID string, expectedVersion, ttlSeconds int) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/renew", map[string]any{"task_id": taskID, "expected_version": expectedVersion, "ttl_seconds": ttlSeconds})
|
||||
func (c Client) Renew(ctx context.Context, taskID, epoch string, expectedVersion, ttlSeconds int) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/renew", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "ttl_seconds": ttlSeconds})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -176,22 +176,22 @@ func (c Client) PutArtifact(ctx context.Context, b []byte) (string, error) {
|
||||
}
|
||||
return out.Ref, nil
|
||||
}
|
||||
func (c Client) Release(ctx context.Context, taskID, ref, anchor, transactionID string, expectedVersion int, evidence domain.SessionEvidence) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/handoff", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "expected_version": expectedVersion, "session_evidence": evidence})
|
||||
func (c Client) Release(ctx context.Context, taskID, ref, anchor, transactionID, epoch string, expectedVersion int, evidence domain.SessionEvidence) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/handoff", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_epoch": epoch, "expected_version": expectedVersion, "session_evidence": evidence})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (c Client) Pickup(ctx context.Context, taskID, ref, anchor, transactionID string, leaseVersion int, evidence domain.SessionEvidence) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/pickup", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_version": leaseVersion, "session_evidence": evidence})
|
||||
func (c Client) Pickup(ctx context.Context, taskID, ref, anchor, transactionID, epoch string, leaseVersion int, evidence domain.SessionEvidence) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/pickup", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_epoch": epoch, "lease_version": leaseVersion, "session_evidence": evidence})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
func (c Client) Complete(ctx context.Context, taskID, reportRef, resultSHA, branch, remote string, expectedVersion int, receipt map[string]any, evidence domain.SessionEvidence) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/complete", map[string]any{"task_id": taskID, "handoff_ref": reportRef, "result_sha": resultSHA, "branch": branch, "remote": remote, "expected_version": expectedVersion, "receipt": receipt, "session_evidence": evidence})
|
||||
func (c Client) Complete(ctx context.Context, taskID, reportRef, resultSHA, branch, remote, epoch string, expectedVersion int, receipt map[string]any, evidence domain.SessionEvidence) error {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/complete", map[string]any{"task_id": taskID, "handoff_ref": reportRef, "result_sha": resultSHA, "branch": branch, "remote": remote, "lease_epoch": epoch, "expected_version": expectedVersion, "receipt": receipt, "session_evidence": evidence})
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"orchestra/internal/buildinfo"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -15,13 +16,15 @@ var ErrUnknownWorker = errors.New("unknown worker")
|
||||
var ErrUnauthorized = errors.New("worker authentication failed")
|
||||
|
||||
type Worker struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
Health WorkerHealth `json:"health"`
|
||||
Token string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
SupportedProjects []string `json:"supported_projects"`
|
||||
Build buildinfo.Info `json:"build"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Online bool `json:"online"`
|
||||
Health WorkerHealth `json:"health"`
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
// WorkerHealth is reported by the worker that owns the local herdr socket.
|
||||
@@ -86,10 +89,12 @@ type persistedState struct {
|
||||
// is mode 0600, and retaining this binding prevents an arbitrary process from
|
||||
// registering a recovered worker ID and executing its pending approval.
|
||||
type persistedWorker struct {
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
Token string `json:"token"`
|
||||
ID string `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Capacity int `json:"capacity"`
|
||||
SupportedProjects []string `json:"supported_projects"`
|
||||
Build buildinfo.Info `json:"build"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// Load restores durable capture/command state. Call this before accepting
|
||||
@@ -123,7 +128,7 @@ func (r *Registry) Load() error {
|
||||
if id == "" || w.ID != id || w.Token == "" {
|
||||
return fmt.Errorf("invalid federation worker %q", id)
|
||||
}
|
||||
r.workers[id] = Worker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, Token: w.Token}
|
||||
r.workers[id] = Worker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, SupportedProjects: w.SupportedProjects, Build: w.Build, Token: w.Token}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -135,7 +140,7 @@ func (r *Registry) persistLocked() error {
|
||||
}
|
||||
workers := make(map[string]persistedWorker, len(r.workers))
|
||||
for id, w := range r.workers {
|
||||
workers[id] = persistedWorker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, Token: w.Token}
|
||||
workers[id] = persistedWorker{ID: w.ID, Address: w.Address, Capacity: w.Capacity, SupportedProjects: w.SupportedProjects, Build: w.Build, Token: w.Token}
|
||||
}
|
||||
b, err := json.Marshal(persistedState{Captures: r.captures, Commands: r.commands, Workers: workers})
|
||||
if err != nil {
|
||||
@@ -145,12 +150,31 @@ func (r *Registry) persistLocked() error {
|
||||
return err
|
||||
}
|
||||
tmp := r.StatePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0600); err != nil {
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = f.Write(b); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, r.StatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(r.StatePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
if err := dir.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(r.StatePath, 0600)
|
||||
}
|
||||
|
||||
@@ -396,10 +420,32 @@ func (r *Registry) Available(id string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
w.Online = time.Since(w.LastSeen) <= r.TTL
|
||||
// A heartbeat merely proves the worker process can reach the coordinator.
|
||||
// Lease admission additionally requires a fresh probe of the worker's
|
||||
// local herdr; otherwise a partitioned/down herdr still attracts work.
|
||||
w.Online = time.Since(w.LastSeen) <= r.TTL && w.Health.HerdrStatus == "reachable" && !w.Health.CheckedAt.IsZero() && time.Since(w.Health.CheckedAt) <= r.TTL
|
||||
r.workers[id] = w
|
||||
return w.Online
|
||||
}
|
||||
|
||||
// Supports reports whether an online worker explicitly declared the project.
|
||||
// An omitted declaration is deliberately not treated as a wildcard: workers
|
||||
// must never receive a project for which they have no local checkout.
|
||||
func (r *Registry) Supports(id, project string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.init()
|
||||
w, ok := r.workers[id]
|
||||
if !ok || time.Since(w.LastSeen) > r.TTL {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range w.SupportedProjects {
|
||||
if candidate == project {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (r *Registry) Snapshot() []Worker {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -29,6 +29,49 @@ func TestCursorIsMonotonicAndAuthenticationIsRequired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedProjectsPersistAndGateAvailability(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
r := &Registry{StatePath: path}
|
||||
if err := r.Register(Worker{ID: "w", Token: "t", SupportedProjects: []string{"test-e2e"}}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Supports("w", "test-e2e") || r.Supports("w", "correx") {
|
||||
t.Fatalf("unexpected project support")
|
||||
}
|
||||
restarted := &Registry{StatePath: path}
|
||||
if err := restarted.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restarted.Register(Worker{ID: "w", Token: "t", SupportedProjects: []string{"test-e2e"}}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !restarted.Supports("w", "test-e2e") || restarted.Supports("w", "correx") {
|
||||
t.Fatalf("project support did not survive restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableRequiresFreshReachableLocalHerdrHealth(t *testing.T) {
|
||||
r := &Registry{}
|
||||
if err := r.Register(Worker{ID: "w", Token: "t"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Available("w") {
|
||||
t.Fatal("registration without local herdr probe admitted a worker")
|
||||
}
|
||||
if err := r.Heartbeat("w", WorkerHealth{HerdrStatus: "reachable", CheckedAt: time.Now().UTC()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Available("w") {
|
||||
t.Fatal("fresh reachable local herdr was not admitted")
|
||||
}
|
||||
if err := r.Heartbeat("w", WorkerHealth{HerdrStatus: "unreachable", CheckedAt: time.Now().UTC()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Available("w") {
|
||||
t.Fatal("unreachable local herdr was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingApprovalSurvivesRegistryRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "federation-state.json")
|
||||
r := &Registry{StatePath: path}
|
||||
|
||||
@@ -129,9 +129,12 @@ func TestEndToEndIngestRouteLeaseRotateAndComplete(t *testing.T) {
|
||||
}
|
||||
got, _ := s.Task(task.ID)
|
||||
if got.State == domain.StateLeased && h.releases == 1 {
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: got.Version + 1, Surface: string(authz.System), Payload: mustJSON(map[string]string{
|
||||
"handoff_ref": h.ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: got.Version + 1, Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"handoff_ref": h.ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
"harness_id": got.Lease.HarnessID,
|
||||
"lease_epoch": got.Lease.Epoch,
|
||||
"expected_version": got.Version,
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -186,9 +189,13 @@ func TestRestartReplayAndReconcileKillsOrphan(t *testing.T) {
|
||||
}
|
||||
// A fresh coordinator sees the durable session, then drops it once the lease is gone.
|
||||
ref, _ := s.PutArtifact([]byte("handoff"))
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: 3, Surface: string(authz.System), Payload: mustJSON(map[string]string{
|
||||
"handoff_ref": ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
leased, _ := s.Task(task.ID)
|
||||
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: task.ID, Version: 3, Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"handoff_ref": ref,
|
||||
"anchor_sha": "0123456789012345678901234567890123456789",
|
||||
"harness_id": leased.Lease.HarnessID,
|
||||
"lease_epoch": leased.Lease.Epoch,
|
||||
"expected_version": leased.Version,
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -230,9 +237,13 @@ func TestProviderRetryReflectionQuotaAndVersionConflict(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reflector := &fakeReflector{}
|
||||
leased, _ := s.Task(task.ID)
|
||||
if err := (provider.ReflectingSink{Sink: s, Tasks: s, Reflector: reflector}).Append(domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: task.ID, Version: 3, Surface: string(authz.System), Payload: mustJSON(map[string]any{
|
||||
"report_ref": ref,
|
||||
"receipt": map[string]any{"harness_id": "h1", "consumed": 1},
|
||||
"report_ref": ref,
|
||||
"receipt": map[string]any{"harness_id": "h1", "consumed": 1},
|
||||
"harness_id": leased.Lease.HarnessID,
|
||||
"lease_epoch": leased.Lease.Epoch,
|
||||
"expected_version": leased.Version,
|
||||
})}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/continuity"
|
||||
@@ -412,10 +413,29 @@ func (c *Coordinator) saveSessionsLocked() error {
|
||||
return err
|
||||
}
|
||||
tmp := c.StatePath + ".tmp"
|
||||
if err = os.WriteFile(tmp, b, 0600); err != nil {
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, c.StatePath)
|
||||
if _, err = f.Write(b); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(tmp, c.StatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(c.StatePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
|
||||
// Reconcile drops mappings whose task lease did not survive restart and kills
|
||||
@@ -573,7 +593,7 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
|
||||
if a, ae := c.adapterFor(taskID, s); ae == nil {
|
||||
if p, ok := a.(herdr.PaneExit); ok {
|
||||
if exited, ee := p.PaneExited(ctx, s); ee == nil && exited {
|
||||
b, _ := json.Marshal(map[string]string{"reason": "pane_exited", "harness_id": s.Harness})
|
||||
b, _ := json.Marshal(map[string]any{"reason": "pane_exited", "harness_id": s.Harness, "lease_epoch": t.Lease.Epoch, "expected_version": t.Version})
|
||||
_ = c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
|
||||
}
|
||||
}
|
||||
@@ -581,24 +601,47 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
events, err := c.Store.ExpireLeases(time.Now())
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
for _, e := range events {
|
||||
c.loadSessions()
|
||||
c.mu.Lock()
|
||||
s, ok := c.sessions[e.TaskID]
|
||||
delete(c.sessions, e.TaskID)
|
||||
if ok {
|
||||
if a, ae := c.adapterFor(e.TaskID, s); ae == nil {
|
||||
_ = a.Kill(ctx, s)
|
||||
}
|
||||
var events []domain.Event
|
||||
var firstErr error
|
||||
for _, task := range c.Store.Tasks() {
|
||||
if task.State != domain.StateLeased || task.Lease == nil || task.Lease.Until.After(time.Now()) {
|
||||
continue
|
||||
}
|
||||
_ = c.saveSessionsLocked()
|
||||
// Stop a local predecessor before making its lease eligible for a
|
||||
// successor. If this cannot be done, keep both the mapping and the
|
||||
// lease: safety beats reclaim speed.
|
||||
c.mu.Lock()
|
||||
s, local := c.sessions[task.ID]
|
||||
c.mu.Unlock()
|
||||
if local {
|
||||
a, adapterErr := c.adapterFor(task.ID, s)
|
||||
if adapterErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("expire %s: resolve old pane: %w", task.ID, adapterErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if killErr := a.Kill(ctx, s); killErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("expire %s: quarantine old pane: %w", task.ID, killErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, task.ID)
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
e, expireErr := c.Store.ExpireLease(task.ID, time.Now())
|
||||
if expireErr != nil {
|
||||
if !errors.Is(expireErr, domain.ErrConflict) && firstErr == nil {
|
||||
firstErr = expireErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
return events, nil
|
||||
return events, firstErr
|
||||
}
|
||||
|
||||
// handoffReason reads HandoffFile from the worktree, if present, and returns
|
||||
@@ -722,13 +765,17 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
|
||||
// tick or TTL expiry to reclaim.
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA})
|
||||
b, _ := json.Marshal(map[string]any{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA, "harness_id": task.Lease.HarnessID, "lease_epoch": task.Lease.Epoch, "expected_version": task.Version})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b, Surface: string(authz.System)}
|
||||
if c.Store.Append(e) == nil {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, taskID)
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
// A release only transfers the lease; this local coordinator owns
|
||||
// the predecessor pane until it has actually stopped it.
|
||||
if err := a.Kill(ctx, session); err == nil {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, taskID)
|
||||
_ = c.saveSessionsLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -842,11 +889,14 @@ func (c *Coordinator) finishRelease(ctx context.Context, taskID string, task dom
|
||||
// invalid TaskReleased payload, same as rotate()'s bare continue.
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA})
|
||||
b, _ := json.Marshal(map[string]any{"handoff_ref": ref, "reason": reason, "anchor_sha": anchorSHA, "harness_id": task.Lease.HarnessID, "lease_epoch": task.Lease.Epoch, "expected_version": task.Version})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: taskID, Version: task.Version + 1, Payload: b, Surface: string(authz.System)}
|
||||
if err := c.Store.Append(e); err != nil {
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
if err := a.Kill(ctx, session); err != nil {
|
||||
return TurnRefuse, nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, taskID)
|
||||
_ = c.saveSessionsLocked()
|
||||
@@ -978,6 +1028,12 @@ func (c *Coordinator) block(t domain.Task, reason string) error {
|
||||
p["session_evidence"] = domain.SessionEvidence{PaneID: s.PaneID, HarnessID: s.HerdrID, PaneState: "open", Source: "coordinator", CheckedAt: time.Now().UTC()}
|
||||
}
|
||||
b, _ := json.Marshal(p)
|
||||
if t.Lease != nil {
|
||||
p["harness_id"] = t.Lease.HarnessID
|
||||
p["lease_epoch"] = t.Lease.Epoch
|
||||
p["expected_version"] = t.Version
|
||||
b, _ = json.Marshal(p)
|
||||
}
|
||||
return c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -73,7 +74,7 @@ func TestGitWorktreesCommitsTaskFile(t *testing.T) {
|
||||
initRepo(t, repo)
|
||||
|
||||
w := orchestrator.GitWorktrees{Root: filepath.Join(base, "wt"), Repo: repo}
|
||||
task := domain.Task{ID: "t1", Project: "p", Source: "jsonl", ExternalID: "1", Title: "do the thing"}
|
||||
task := domain.Task{ID: "t1", Project: "p", Source: "jsonl", ExternalID: "1", Title: "do the thing", Acceptance: []string{"tests pass"}, QualityGate: "go test ./..."}
|
||||
|
||||
path, err := w.Create(context.Background(), task)
|
||||
if err != nil {
|
||||
@@ -88,6 +89,9 @@ func TestGitWorktreesCommitsTaskFile(t *testing.T) {
|
||||
if string(got) != string(want) {
|
||||
t.Fatalf("TASK.md content mismatch:\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
if !strings.Contains(string(got), "## Acceptance criteria") || !strings.Contains(string(got), ".orchestra/done") {
|
||||
t.Fatalf("TASK.md is missing deterministic completion contract: %s", got)
|
||||
}
|
||||
|
||||
status, err := exec.Command("git", "-C", path, "status", "--porcelain", "--", "TASK.md").Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -28,6 +28,7 @@ type Project struct {
|
||||
// wires (single-repo deployments keep working unchanged).
|
||||
Repo string `json:"repo,omitempty"`
|
||||
WorktreeRoot string `json:"worktree_root,omitempty"`
|
||||
QualityGate string `json:"quality_gate,omitempty"`
|
||||
}
|
||||
type Machine struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
)
|
||||
|
||||
type Availability interface{ Available(h registry.Herdr) bool }
|
||||
|
||||
// ProjectAvailability is an optional stricter availability contract used by
|
||||
// federated workers, whose local checkout configuration is authoritative.
|
||||
type ProjectAvailability interface {
|
||||
Supports(h registry.Herdr, project string) bool
|
||||
}
|
||||
type AlwaysAvailable struct{}
|
||||
|
||||
func (AlwaysAvailable) Available(registry.Herdr) bool { return true }
|
||||
@@ -170,7 +176,11 @@ func (r *Router) AssignPending() ([]domain.Event, error) {
|
||||
continue
|
||||
}
|
||||
for _, h := range cs {
|
||||
if !matches(t.Capability, h.Capabilities) || !r.Availability.Available(h) || occupied(r.Store, h.ID, h.Concurrency) {
|
||||
projectOK := true
|
||||
if projects, ok := r.Availability.(ProjectAvailability); ok {
|
||||
projectOK = projects.Supports(h, t.Project)
|
||||
}
|
||||
if !projectOK || !matches(t.Capability, h.Capabilities) || !r.Availability.Available(h) || occupied(r.Store, h.ID, h.Concurrency) {
|
||||
continue
|
||||
}
|
||||
e, err := r.Store.Lease(t.ID, h.ID, 30*time.Minute)
|
||||
|
||||
@@ -14,6 +14,13 @@ type reachable struct{}
|
||||
|
||||
func (reachable) Reachable(string, time.Duration) bool { return true }
|
||||
|
||||
type projectAvailability struct{ projects map[string]bool }
|
||||
|
||||
func (p projectAvailability) Available(registry.Herdr) bool { return true }
|
||||
func (p projectAvailability) Supports(h registry.Herdr, project string) bool {
|
||||
return p.projects[h.ID+"/"+project]
|
||||
}
|
||||
|
||||
func TestAssignsByAffinityCapabilityAndConcurrency(t *testing.T) {
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
@@ -56,6 +63,32 @@ func TestAssignsByAffinityCapabilityAndConcurrency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignPendingRequiresWorkerProjectSupport(t *testing.T) {
|
||||
s, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := registry.New(registry.Config{Projects: []registry.Project{{ID: "p", MachineAffinity: []string{"m"}}}, Machines: []registry.Machine{{ID: "m", Address: "unused"}}, Herdrs: []registry.Herdr{{ID: "h", MachineID: "m", Concurrency: 1}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{"source": "test", "external_id": "project-check", "project": "p"})
|
||||
if err := s.Append(domain.Event{ID: "create", TaskID: "t", Type: "TaskCreated", Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := Router{Store: s, Registry: r, Reachability: reachable{}, Availability: projectAvailability{projects: map[string]bool{}}}
|
||||
if got, err := rt.AssignPending(); err != nil || len(got) != 0 {
|
||||
t.Fatalf("unsupported project lease = %#v, %v", got, err)
|
||||
}
|
||||
if got, _ := s.Task("t"); got.State != domain.StateQueued {
|
||||
t.Fatalf("unsupported project state=%s", got.State)
|
||||
}
|
||||
rt.Availability = projectAvailability{projects: map[string]bool{"h/p": true}}
|
||||
if got, err := rt.AssignPending(); err != nil || len(got) != 1 {
|
||||
t.Fatalf("supported project lease = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationDoesNotCountAgainstRetryLimit guards B4: rotation is
|
||||
// TaskReleased carrying a valid handoff_ref (spec §5.3: "rotation =
|
||||
// intra-task lease transfer"), never a failure. A task healthy enough to
|
||||
@@ -103,10 +136,13 @@ func TestRotationDoesNotCountAgainstRetryLimit(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
rb, _ := json.Marshal(map[string]string{
|
||||
"handoff_ref": handoffRef,
|
||||
"reason": "threshold",
|
||||
"anchor_sha": "0123456789abcdef0123456789abcdef01234567",
|
||||
rb, _ := json.Marshal(map[string]any{
|
||||
"handoff_ref": handoffRef,
|
||||
"reason": "threshold",
|
||||
"anchor_sha": "0123456789abcdef0123456789abcdef01234567",
|
||||
"harness_id": task.Lease.HarnessID,
|
||||
"lease_epoch": task.Lease.Epoch,
|
||||
"expected_version": task.Version,
|
||||
})
|
||||
release := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: "a", Version: task.Version + 1, Payload: rb, Surface: string(authz.System)}
|
||||
if err := s.Append(release); err != nil {
|
||||
|
||||
+148
-43
@@ -32,27 +32,10 @@ func Open(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(s.cas, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var snapshotSeq uint64
|
||||
if b, readErr := os.ReadFile(s.snapshot); readErr == nil {
|
||||
var snap struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Tasks []domain.Task `json:"tasks"`
|
||||
}
|
||||
if json.Unmarshal(b, &snap) != nil {
|
||||
return nil, fmt.Errorf("invalid snapshot")
|
||||
}
|
||||
for _, t := range snap.Tasks {
|
||||
s.tasks[t.ID] = t
|
||||
s.external[t.Source+"\x00"+t.ExternalID] = t.ID
|
||||
}
|
||||
snapshotSeq = snap.Seq
|
||||
// Continue event numbering after the snapshot. Without restoring this
|
||||
// cursor, the first append after a restart reused sequence 1 and made
|
||||
// the append-only log unreplayable.
|
||||
s.seq = snapshotSeq
|
||||
} else if !errors.Is(readErr, os.ErrNotExist) {
|
||||
return nil, readErr
|
||||
}
|
||||
// A snapshot is a disposable read cache, never recovery authority. Loading
|
||||
// it before the log let a partially-written snapshot become a different
|
||||
// history than events.jsonl after a crash. Rebuild every projection from
|
||||
// the append-only, fsynced log instead.
|
||||
f, err := os.Open(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
@@ -62,16 +45,13 @@ func Open(dir string) (*Store, error) {
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
var expected uint64 = snapshotSeq + 1
|
||||
var expected uint64 = 1
|
||||
for sc.Scan() {
|
||||
var e domain.Event
|
||||
if err := json.Unmarshal(sc.Bytes(), &e); err == nil {
|
||||
if err := domain.ValidateEvent(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.Seq < expected {
|
||||
continue
|
||||
}
|
||||
if e.Seq != expected {
|
||||
return nil, fmt.Errorf("event sequence gap: got %d, want %d", e.Seq, expected)
|
||||
}
|
||||
@@ -151,9 +131,20 @@ func (s *Store) apply(e domain.Event) error {
|
||||
s.external[t.Source+"\x00"+t.ExternalID] = t.ID
|
||||
case "TaskLeased":
|
||||
t.State = domain.StateLeased
|
||||
t.Lease = &domain.Lease{HarnessID: p["harness_id"].(string), Until: time.Unix(0, int64(p["until_ns"].(float64)))}
|
||||
epoch, _ := p["lease_epoch"].(string)
|
||||
if epoch == "" {
|
||||
// A pre-fencing event cannot safely be renewed by an old worker.
|
||||
// Deriving a stable token from the durable event identity makes the
|
||||
// recovered lease observable but non-renewable until it expires.
|
||||
epoch = "legacy:" + e.ID
|
||||
}
|
||||
t.Lease = &domain.Lease{HarnessID: p["harness_id"].(string), Epoch: epoch, Until: time.Unix(0, int64(p["until_ns"].(float64)))}
|
||||
case "TaskLeaseRenewed":
|
||||
t.Lease = &domain.Lease{HarnessID: p["harness_id"].(string), Until: time.Unix(0, int64(p["until_ns"].(float64)))}
|
||||
epoch, _ := p["lease_epoch"].(string)
|
||||
if epoch == "" && t.Lease != nil {
|
||||
epoch = t.Lease.Epoch
|
||||
}
|
||||
t.Lease = &domain.Lease{HarnessID: p["harness_id"].(string), Epoch: epoch, Until: time.Unix(0, int64(p["until_ns"].(float64)))}
|
||||
case "TaskReleased":
|
||||
t.State = domain.StateQueued
|
||||
t.Lease = nil
|
||||
@@ -292,6 +283,9 @@ func (s *Store) Append(e domain.Event) error {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
}
|
||||
if err := s.validateTransition(e, t, taskExists, contract); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.Type == "TaskLeased" {
|
||||
var p struct {
|
||||
ExpectedVersion *int `json:"expected_version"`
|
||||
@@ -333,9 +327,6 @@ func (s *Store) Append(e domain.Event) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.apply(e); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -348,10 +339,60 @@ func (s *Store) Append(e domain.Event) error {
|
||||
if err = f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The event is the commit record. Do not expose a projection that cannot
|
||||
// be recovered from it after a power loss.
|
||||
if err := s.apply(e); err != nil {
|
||||
return err
|
||||
}
|
||||
s.events = append(s.events, e)
|
||||
s.seq = e.Seq
|
||||
if err := s.writeSnapshot(); err != nil {
|
||||
return err
|
||||
// Snapshot failure does not roll back a committed event. Open always
|
||||
// rebuilds from the log, so leaving a stale cache is safe.
|
||||
_ = s.writeSnapshot()
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTransition keeps lifecycle authority at the durable append
|
||||
// boundary. A task may be completed/failed while queued by an external
|
||||
// provider, but once a lease exists its owner and fencing epoch are required
|
||||
// for every lifecycle mutation.
|
||||
func (s *Store) validateTransition(e domain.Event, t domain.Task, exists bool, p map[string]any) error {
|
||||
if !exists {
|
||||
if e.Type != "TaskCreated" && e.Type != "QuotaReported" && e.Type != "StandupAdvisory" && e.Type != "ApprovalGranted" && e.Type != "ApprovalDenied" {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if e.Type == "TaskLeased" && t.State != domain.StateQueued {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
if t.State != domain.StateLeased || t.Lease == nil {
|
||||
return nil
|
||||
}
|
||||
switch e.Type {
|
||||
case "TaskLeaseRenewed", "TaskReleased", "TaskPickupValidated", "TaskCompleted", "TaskBlocked", "TaskFailed":
|
||||
owner, _ := p["harness_id"].(string)
|
||||
epoch, _ := p["lease_epoch"].(string)
|
||||
// Expiry is the one coordinator-owned relinquish path. It still binds
|
||||
// the exact epoch that was observed when the timer fired.
|
||||
if e.Type == "TaskReleased" {
|
||||
if reason, _ := p["reason"].(string); (reason == "lease_expired" || reason == "pane_exited") && owner == t.Lease.HarnessID && epoch == t.Lease.Epoch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if owner != t.Lease.HarnessID || epoch == "" || epoch != t.Lease.Epoch {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
case "TaskCorrected":
|
||||
// Corrections may repair metadata while a task is leased, but cannot
|
||||
// smuggle in a lifecycle transition around the current fenced owner.
|
||||
if _, changesState := p["state"]; changesState {
|
||||
owner, _ := p["harness_id"].(string)
|
||||
epoch, _ := p["lease_epoch"].(string)
|
||||
if owner != t.Lease.HarnessID || epoch == "" || epoch != t.Lease.Epoch {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -368,10 +409,28 @@ func (s *Store) writeSnapshot() error {
|
||||
return err
|
||||
}
|
||||
tmp := s.snapshot + ".tmp"
|
||||
if err = os.WriteFile(tmp, b, 0644); err != nil {
|
||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.snapshot)
|
||||
if _, err = f.Write(b); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(tmp, s.snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.Open(filepath.Dir(s.snapshot))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
return dir.Sync()
|
||||
}
|
||||
func (s *Store) Tasks() []domain.Task {
|
||||
s.mu.Lock()
|
||||
@@ -397,9 +456,39 @@ func (s *Store) PutArtifact(b []byte) (string, error) {
|
||||
h := domain.Hash(b)
|
||||
p := filepath.Join(s.cas, h)
|
||||
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
|
||||
if err = os.WriteFile(p, b, 0644); err != nil {
|
||||
tmp := p + ".tmp-" + domain.NewID()
|
||||
f, openErr := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||
if openErr != nil {
|
||||
return "", openErr
|
||||
}
|
||||
if _, err = f.Write(b); err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
if closeErr := f.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
if err = os.Rename(tmp, p); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
dir, openErr := os.Open(s.cas)
|
||||
if openErr != nil {
|
||||
return "", openErr
|
||||
}
|
||||
syncErr := dir.Sync()
|
||||
closeErr := dir.Close()
|
||||
if syncErr != nil {
|
||||
return "", syncErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", closeErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
@@ -450,7 +539,7 @@ func (s *Store) Lease(id, harness string, ttl time.Duration) (domain.Event, erro
|
||||
if t.State != domain.StateQueued {
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
payload := map[string]any{"harness_id": harness, "ttl": ttl.Seconds(), "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version}
|
||||
payload := map[string]any{"harness_id": harness, "lease_epoch": domain.NewID(), "ttl": ttl.Seconds(), "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": t.Version}
|
||||
if t.HandoffRef != "" {
|
||||
payload["handoff_ref"] = t.HandoffRef
|
||||
payload["transaction_id"] = t.ReleaseTransaction
|
||||
@@ -464,7 +553,7 @@ func (s *Store) Lease(id, harness string, ttl time.Duration) (domain.Event, erro
|
||||
// RenewLease atomically extends the current owner's lease. The observed task
|
||||
// version is part of the request so an old worker can never renew a lease
|
||||
// after release/reassignment.
|
||||
func (s *Store) RenewLease(id, harness string, expectedVersion int, ttl time.Duration) (domain.Event, error) {
|
||||
func (s *Store) RenewLease(id, harness, epoch string, expectedVersion int, ttl time.Duration) (domain.Event, error) {
|
||||
if ttl <= 0 {
|
||||
return domain.Event{}, fmt.Errorf("%w: ttl must be positive", domain.ErrInvalid)
|
||||
}
|
||||
@@ -472,10 +561,10 @@ func (s *Store) RenewLease(id, harness string, expectedVersion int, ttl time.Dur
|
||||
if !ok {
|
||||
return domain.Event{}, domain.ErrNotFound
|
||||
}
|
||||
if t.State != domain.StateLeased || t.Lease == nil || t.Lease.HarnessID != harness || t.Version != expectedVersion {
|
||||
if t.State != domain.StateLeased || t.Lease == nil || t.Lease.HarnessID != harness || t.Lease.Epoch != epoch || t.Version != expectedVersion {
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": harness, "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": expectedVersion})
|
||||
p, _ := json.Marshal(map[string]any{"harness_id": harness, "lease_epoch": epoch, "until_ns": time.Now().Add(ttl).UnixNano(), "expected_version": expectedVersion})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskLeaseRenewed", TaskID: id, Version: t.Version + 1, Payload: p, Surface: string(authz.System)}
|
||||
return e, s.Append(e)
|
||||
}
|
||||
@@ -483,14 +572,30 @@ func (s *Store) RenewLease(id, harness string, expectedVersion int, ttl time.Dur
|
||||
func (s *Store) ExpireLeases(now time.Time) ([]domain.Event, error) {
|
||||
var out []domain.Event
|
||||
for _, t := range s.Tasks() {
|
||||
if t.State == domain.StateLeased && t.Lease != nil && !t.Lease.Until.After(now) {
|
||||
p, _ := json.Marshal(map[string]any{"reason": "lease_expired", "harness_id": t.Lease.HarnessID})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: t.ID, Version: t.Version + 1, Payload: p, Surface: string(authz.System)}
|
||||
if err := s.Append(e); err != nil {
|
||||
if e, err := s.ExpireLease(t.ID, now); err != nil {
|
||||
if !errors.Is(err, domain.ErrConflict) {
|
||||
return out, err
|
||||
}
|
||||
} else if e.ID != "" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExpireLease releases exactly the observed lease if, and only if, its TTL
|
||||
// has elapsed. Coordinators use this one-task form to stop their local pane
|
||||
// before publishing the release event; the batch helper remains for
|
||||
// deployments without a local coordinator.
|
||||
func (s *Store) ExpireLease(id string, now time.Time) (domain.Event, error) {
|
||||
t, ok := s.Task(id)
|
||||
if !ok {
|
||||
return domain.Event{}, domain.ErrNotFound
|
||||
}
|
||||
if t.State != domain.StateLeased || t.Lease == nil || t.Lease.Until.After(now) {
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
p, _ := json.Marshal(map[string]any{"reason": "lease_expired", "harness_id": t.Lease.HarnessID, "lease_epoch": t.Lease.Epoch, "expected_version": t.Version})
|
||||
e := domain.Event{ID: domain.NewID(), Type: "TaskReleased", TaskID: t.ID, Version: t.Version + 1, Payload: p, Surface: string(authz.System)}
|
||||
return e, s.Append(e)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -35,7 +36,7 @@ func TestLeaseCarriesReleasedHandoffRef(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ := s.Task("t")
|
||||
p, _ := json.Marshal(map[string]string{"handoff_ref": ref, "anchor_sha": "0123456789012345678901234567890123456789"})
|
||||
p, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": "0123456789012345678901234567890123456789", "harness_id": task.Lease.HarnessID, "lease_epoch": task.Lease.Epoch, "expected_version": task.Version})
|
||||
if err := s.Append(domain.Event{ID: "release", Type: "TaskReleased", TaskID: "t", Version: task.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,13 +65,13 @@ func TestRenewLeaseRequiresCurrentOwnerAndVersion(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := s.Task("t")
|
||||
if _, err := s.RenewLease("t", "worker-b", before.Version, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
if _, err := s.RenewLease("t", "worker-b", before.Lease.Epoch, before.Version, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("other worker renewal = %v, want conflict", err)
|
||||
}
|
||||
if _, err := s.RenewLease("t", "worker-a", before.Version-1, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
if _, err := s.RenewLease("t", "worker-a", before.Lease.Epoch, before.Version-1, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("stale renewal = %v, want conflict", err)
|
||||
}
|
||||
e, err := s.RenewLease("t", "worker-a", before.Version, time.Hour)
|
||||
e, err := s.RenewLease("t", "worker-a", before.Lease.Epoch, before.Version, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -78,11 +79,107 @@ func TestRenewLeaseRequiresCurrentOwnerAndVersion(t *testing.T) {
|
||||
if e.Type != "TaskLeaseRenewed" || after.Version != before.Version+1 || after.Lease == nil || !after.Lease.Until.After(before.Lease.Until) {
|
||||
t.Fatalf("renewal was not projected: before=%+v after=%+v event=%+v", before, after, e)
|
||||
}
|
||||
if _, err := s.RenewLease("t", "worker-a", before.Version, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
if _, err := s.RenewLease("t", "worker-a", before.Lease.Epoch, before.Version, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("replayed renewal = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseEpochFencesStaleOwnerLifecycleWrites(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(domain.Event{ID: "create", Type: "TaskCreated", TaskID: "t", Version: 1, Payload: []byte(`{"source":"s","external_id":"epoch","project":"p"}`), Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Lease("t", "worker", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, _ := s.Task("t")
|
||||
ref, err := s.PutArtifact([]byte("handoff"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleRelease, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": strings.Repeat("a", 40), "harness_id": "worker", "lease_epoch": "stale", "expected_version": first.Version})
|
||||
if err := s.Append(domain.Event{ID: "stale-release", Type: "TaskReleased", TaskID: "t", Version: first.Version + 1, Payload: staleRelease, Surface: string(authz.System)}); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("stale release = %v, want conflict", err)
|
||||
}
|
||||
release, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": strings.Repeat("a", 40), "harness_id": "worker", "lease_epoch": first.Lease.Epoch, "expected_version": first.Version})
|
||||
if err := s.Append(domain.Event{ID: "release", Type: "TaskReleased", TaskID: "t", Version: first.Version + 1, Payload: release, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Lease("t", "worker", time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := s.Task("t")
|
||||
if second.Lease.Epoch == first.Lease.Epoch {
|
||||
t.Fatal("re-lease reused fencing epoch")
|
||||
}
|
||||
report, err := s.PutArtifact([]byte("report"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleComplete, _ := json.Marshal(map[string]any{"report_ref": report, "receipt": map[string]any{"consumed": 1}, "harness_id": "worker", "lease_epoch": first.Lease.Epoch, "expected_version": second.Version})
|
||||
if err := s.Append(domain.Event{ID: "stale-complete", Type: "TaskCompleted", TaskID: "t", Version: second.Version + 1, Payload: staleComplete, Surface: string(authz.System)}); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("stale completion = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRebuildsOnlyFromLogAndIgnoresCorruptSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(created("create")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "snapshot.json"), []byte(`not json`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("corrupt disposable snapshot prevented log recovery: %v", err)
|
||||
}
|
||||
if task, ok := restarted.Task("task-1"); !ok || task.State != domain.StateQueued {
|
||||
t.Fatalf("log projection = %#v, present=%v", task, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayLegacyLeaseDerivesNonRenewableFence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
until := time.Now().Add(time.Hour).UnixNano()
|
||||
events := []domain.Event{
|
||||
{SchemaVersion: 2, Seq: 1, ID: "create", Type: "TaskCreated", TaskID: "t", Version: 1, Payload: []byte(`{"source":"s","external_id":"legacy","project":"p"}`), Surface: string(authz.System)},
|
||||
{SchemaVersion: 2, Seq: 2, ID: "lease", Type: "TaskLeased", TaskID: "t", Version: 2, Payload: []byte(`{"harness_id":"worker","until_ns":` + fmt.Sprint(until) + `,"expected_version":1}`), Surface: string(authz.System)},
|
||||
}
|
||||
f, err := os.Create(filepath.Join(dir, "events.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range events {
|
||||
b, _ := json.Marshal(e)
|
||||
if _, err := f.Write(append(b, '\n')); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ := s.Task("t")
|
||||
if task.Lease == nil || task.Lease.Epoch != "legacy:lease" {
|
||||
t.Fatalf("legacy lease fence = %#v", task.Lease)
|
||||
}
|
||||
if _, err := s.RenewLease("t", "worker", "", task.Version, time.Hour); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("legacy lease renewed without derived fence: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedTaskProjectsStructuredDiagnosisAndLegacyFallback(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
@@ -407,7 +504,8 @@ func TestReleaseTransactionSurvivesReLeaseUntilMatchingPickup(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
anchor := strings.Repeat("a", 40)
|
||||
p, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": anchor, "transaction_id": "tx-1", "expected_version": leased.Version})
|
||||
first, _ := s.Task("task-1")
|
||||
p, _ := json.Marshal(map[string]any{"handoff_ref": ref, "anchor_sha": anchor, "transaction_id": "tx-1", "harness_id": first.Lease.HarnessID, "lease_epoch": first.Lease.Epoch, "expected_version": leased.Version})
|
||||
if err := s.Append(domain.Event{ID: "release", Type: "TaskReleased", TaskID: "task-1", Version: leased.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -418,7 +516,7 @@ func TestReleaseTransactionSurvivesReLeaseUntilMatchingPickup(t *testing.T) {
|
||||
if task.ReleaseTransaction != "tx-1" || task.ReleaseAnchor != anchor || task.HandoffRef != ref {
|
||||
t.Fatalf("re-lease lost transaction: %+v", task)
|
||||
}
|
||||
p, _ = json.Marshal(map[string]any{"transaction_id": "tx-1", "handoff_ref": ref, "anchor_sha": anchor, "harness_id": "successor", "lease_version": task.Version, "expected_version": task.Version})
|
||||
p, _ = json.Marshal(map[string]any{"transaction_id": "tx-1", "handoff_ref": ref, "anchor_sha": anchor, "harness_id": "successor", "lease_epoch": task.Lease.Epoch, "lease_version": task.Version, "expected_version": task.Version})
|
||||
if err := s.Append(domain.Event{ID: "pickup", Type: "TaskPickupValidated", TaskID: "task-1", Version: task.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -422,6 +422,11 @@ func (s Server) action(w http.ResponseWriter, r *http.Request, id, action string
|
||||
http.Error(w, "unknown action", 404)
|
||||
return
|
||||
}
|
||||
if action == "block" {
|
||||
// A browser-created block is an explicit operator decision. Preserve
|
||||
// that fact even if its prose happens to contain a system keyword.
|
||||
body["block_reason"] = string(domain.BlockReasonOperator)
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
e := domain.Event{ID: domain.NewID(), Type: typ, TaskID: id, Version: t.Version + 1, Payload: b, Surface: string(authz.Web)}
|
||||
if err := s.Store.Append(e); err != nil {
|
||||
|
||||
+13
-13
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<script type="module" crossorigin src="/assets/index-D1Up3m4b.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DUgNKGY0.css">
|
||||
<script type="module" crossorigin src="/assets/index-BXQHTW_a.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DDZzc9-8.css">
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user