408 lines
15 KiB
Go
408 lines
15 KiB
Go
package domain
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var ErrConflict = errors.New("task version conflict")
|
|
var ErrNotFound = errors.New("task not found")
|
|
var ErrInvalid = errors.New("invalid event")
|
|
|
|
// ErrDuplicate is returned by Store.Append for a TaskCreated event whose
|
|
// (source, external_id) pair was already ingested. The caller already has a
|
|
// task for this content; nothing was appended.
|
|
var ErrDuplicate = errors.New("duplicate task ingestion")
|
|
|
|
// 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
|
|
|
|
const (
|
|
StateQueued TaskState = "queued"
|
|
StateLeased TaskState = "leased"
|
|
StateCompleted TaskState = "completed"
|
|
StateFailed TaskState = "failed"
|
|
StateBlocked TaskState = "blocked"
|
|
// StateNeedsAttention records a recoverable fault without abandoning the
|
|
// current fenced lease. The owning worker may still reconcile a late
|
|
// completion, explicitly release it, or renew it while an operator
|
|
// investigates; expiry remains the only automatic reclaim.
|
|
StateNeedsAttention TaskState = "needs_attention"
|
|
)
|
|
|
|
// BlockReason is the machine-readable diagnosis for a TaskBlocked event.
|
|
// Blocker remains the operator-facing detail; this field lets projections
|
|
// group attention without repeatedly parsing prose at read time.
|
|
type BlockReason string
|
|
|
|
const (
|
|
BlockReasonLeaseFailure BlockReason = "lease_failure"
|
|
BlockReasonWorkerOffline BlockReason = "worker_offline"
|
|
BlockReasonLeaseExpired BlockReason = "lease_expired"
|
|
BlockReasonApproval BlockReason = "approval"
|
|
BlockReasonHandoffValidation BlockReason = "handoff_validation"
|
|
BlockReasonOperator BlockReason = "operator_block"
|
|
BlockReasonSystem BlockReason = "system_error"
|
|
BlockReasonUnknown BlockReason = "unknown"
|
|
)
|
|
|
|
func (r BlockReason) Valid() bool {
|
|
switch r {
|
|
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
|
|
BlockReasonApproval, BlockReasonHandoffValidation, BlockReasonOperator,
|
|
BlockReasonSystem, BlockReasonUnknown:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// InferBlockReason supplies a stable category for older events which only
|
|
// recorded a prose blocker. New producers should send block_reason directly.
|
|
func InferBlockReason(blocker string) BlockReason {
|
|
v := strings.ToLower(blocker)
|
|
switch {
|
|
case strings.Contains(v, "handoff"):
|
|
return BlockReasonHandoffValidation
|
|
case strings.Contains(v, "approval") || strings.Contains(v, "permission"):
|
|
return BlockReasonApproval
|
|
case strings.Contains(v, "expired") && strings.Contains(v, "lease"):
|
|
return BlockReasonLeaseExpired
|
|
case strings.Contains(v, "worker") && (strings.Contains(v, "offline") || strings.Contains(v, "unreachable")):
|
|
return BlockReasonWorkerOffline
|
|
case strings.Contains(v, "lease") || strings.Contains(v, "agent.start") || strings.Contains(v, "pane"):
|
|
return BlockReasonLeaseFailure
|
|
default:
|
|
return BlockReasonSystem
|
|
}
|
|
}
|
|
|
|
type Estimate struct {
|
|
Value float64 `json:"value"`
|
|
Who string `json:"who"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
|
|
// SessionEvidence is captured by the machine that owns a pane immediately
|
|
// before it drops its mapping. It is deliberately observation-only: it never
|
|
// claims that a pane is still live after the worker has closed it.
|
|
type SessionEvidence struct {
|
|
PaneID string `json:"pane_id,omitempty"`
|
|
HarnessID string `json:"harness_id,omitempty"`
|
|
PaneState string `json:"pane_state,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
CapturedAt time.Time `json:"captured_at,omitempty"`
|
|
CheckedAt time.Time `json:"checked_at,omitempty"`
|
|
}
|
|
type Lease struct {
|
|
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"`
|
|
Source string `json:"source"`
|
|
ExternalID string `json:"external_id"`
|
|
Project string `json:"project"`
|
|
Capability []string `json:"capability"`
|
|
Parent string `json:"parent,omitempty"`
|
|
InherentPriority int `json:"inherent_priority"`
|
|
Due *time.Time `json:"due,omitempty"`
|
|
Estimate *Estimate `json:"estimate,omitempty"`
|
|
State TaskState `json:"state"`
|
|
Lease *Lease `json:"lease,omitempty"`
|
|
// HandoffRef survives the queued interval between TaskReleased and the
|
|
// next router-owned TaskLeased event; it is the only artifact the worker
|
|
// may use for local pickup validation.
|
|
HandoffRef string `json:"handoff_ref,omitempty"`
|
|
// ReleaseTransaction and ReleaseAnchor bind successor pickup to the exact
|
|
// durable predecessor checkpoint. They survive queueing and re-lease.
|
|
ReleaseTransaction string `json:"release_transaction,omitempty"`
|
|
ReleaseAnchor string `json:"release_anchor,omitempty"`
|
|
PickupTransaction string `json:"pickup_transaction,omitempty"`
|
|
PickupLeaseVersion int `json:"pickup_lease_version,omitempty"`
|
|
Version int `json:"version"`
|
|
Title string `json:"title,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Acceptance []string `json:"acceptance,omitempty"`
|
|
QualityGate string `json:"quality_gate,omitempty"`
|
|
// Block evidence is projected from TaskBlocked so terminal records remain
|
|
// diagnosable after the live coordinator mapping is gone.
|
|
Blocker string `json:"blocker,omitempty"`
|
|
BlockReason BlockReason `json:"block_reason,omitempty"`
|
|
BlockedAt time.Time `json:"blocked_at,omitempty"`
|
|
LastPaneID string `json:"last_pane_id,omitempty"`
|
|
LastHarness string `json:"last_harness_id,omitempty"`
|
|
PaneState string `json:"pane_state,omitempty"` // open, closed, unreachable, unknown
|
|
LastSession SessionEvidence `json:"last_session,omitempty"`
|
|
// Recovery state is part of the durable projection, never process-local
|
|
// router memory. This makes retry and operator diagnostics survive a
|
|
// coordinator restart.
|
|
Attempt int `json:"attempt,omitempty"`
|
|
NextRetryAt time.Time `json:"next_retry_at,omitempty"`
|
|
FailureClass string `json:"failure_class,omitempty"`
|
|
LifecyclePhase string `json:"lifecycle_phase,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
type Event struct {
|
|
SchemaVersion int `json:"schema_version,omitempty"`
|
|
Seq uint64 `json:"seq"`
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
TaskID string `json:"task_id"`
|
|
Version int `json:"version"`
|
|
At time.Time `json:"at"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
// Surface identifies the bus capability the emitter is authorized under
|
|
// (see internal/authz). It is required on every event so authorization is
|
|
// enforced once, at the store append boundary, regardless of whether the
|
|
// emitter reached the store over HTTP, from the router, from a harness
|
|
// adapter, or from a provider.
|
|
Surface string `json:"surface"`
|
|
}
|
|
|
|
func Hash(v []byte) string { h := sha256.Sum256(v); return hex.EncodeToString(h[:]) }
|
|
|
|
// NewID returns a sortable, 128-bit ULID-like identifier using the canonical
|
|
// 48-bit millisecond timestamp plus 80 bits of cryptographic randomness.
|
|
|
|
var ulidEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
|
|
|
|
func NewID() string {
|
|
b := make([]byte, 16)
|
|
binary.BigEndian.PutUint64(b[:8], uint64(time.Now().UnixMilli())<<16)
|
|
_, _ = rand.Read(b[6:])
|
|
return ulidEncoding.EncodeToString(b)
|
|
}
|
|
func ValidateEvent(e Event) error {
|
|
if e.SchemaVersion > CurrentEventSchema || e.Type == "" || e.TaskID == "" || len(e.Payload) == 0 || len(e.Payload) > 64*1024 {
|
|
return ErrInvalid
|
|
}
|
|
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
|
|
return fmt.Errorf("%w: surface required", ErrInvalid)
|
|
}
|
|
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true}
|
|
if !allowed[e.Type] {
|
|
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
|
|
}
|
|
var p map[string]any
|
|
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
|
return fmt.Errorf("%w: payload is not JSON", ErrInvalid)
|
|
}
|
|
if p == nil {
|
|
return fmt.Errorf("%w: payload must be an object", ErrInvalid)
|
|
}
|
|
if err := ValidatePayload(e.Type, p); err != nil {
|
|
return err
|
|
}
|
|
if e.SchemaVersion >= 3 {
|
|
switch e.Type {
|
|
case "TaskLeased", "TaskLeaseRenewed", "TaskLaunchAcknowledged", "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"} {
|
|
if s, ok := p[k].(string); !ok || strings.TrimSpace(s) == "" {
|
|
return fmt.Errorf("%w: %s required", ErrInvalid, k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidatePayload(typ string, p map[string]any) error {
|
|
requiredString := func(key string) error {
|
|
v, ok := p[key].(string)
|
|
if !ok || strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%w: %s required", ErrInvalid, key)
|
|
}
|
|
return nil
|
|
}
|
|
switch typ {
|
|
case "TaskCreated":
|
|
return ValidateCreated(p)
|
|
case "TaskLeased":
|
|
if err := requiredString("harness_id"); err != nil {
|
|
return err
|
|
}
|
|
until, untilOK := p["until_ns"].(float64)
|
|
if ttl, ok := p["ttl"].(float64); ok {
|
|
if ttl <= 0 {
|
|
return fmt.Errorf("%w: ttl invalid", ErrInvalid)
|
|
}
|
|
} else if !untilOK || until <= float64(time.Now().UnixNano()) {
|
|
return fmt.Errorf("%w: ttl required", ErrInvalid)
|
|
}
|
|
if v, ok := p["expected_version"].(float64); !ok || v < 0 || v != float64(int(v)) {
|
|
return fmt.Errorf("%w: expected_version invalid", ErrInvalid)
|
|
}
|
|
case "TaskLeaseRenewed":
|
|
if err := requiredString("harness_id"); err != nil {
|
|
return err
|
|
}
|
|
until, ok := p["until_ns"].(float64)
|
|
if !ok || until <= float64(time.Now().UnixNano()) {
|
|
return fmt.Errorf("%w: until_ns required", ErrInvalid)
|
|
}
|
|
if v, ok := p["expected_version"].(float64); !ok || v < 0 || v != float64(int(v)) {
|
|
return fmt.Errorf("%w: expected_version invalid", ErrInvalid)
|
|
}
|
|
case "TaskReleased":
|
|
if err := requiredString("handoff_ref"); err != nil && p["reason"] == nil {
|
|
return err
|
|
}
|
|
case "TaskPickupValidated":
|
|
for _, key := range []string{"transaction_id", "handoff_ref", "anchor_sha", "harness_id"} {
|
|
if err := requiredString(key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := requiredHash(p, "handoff_ref"); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := p["anchor_sha"].(string); !ok || len(v) != 40 {
|
|
return fmt.Errorf("%w: anchor_sha invalid", ErrInvalid)
|
|
}
|
|
if v, ok := p["lease_version"].(float64); !ok || v < 1 || v != float64(int(v)) {
|
|
return fmt.Errorf("%w: lease_version invalid", ErrInvalid)
|
|
}
|
|
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 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)
|
|
}
|
|
if v, ok := p["result_sha"]; ok {
|
|
if s, ok := v.(string); !ok || len(s) != 40 {
|
|
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
|
}
|
|
if err := requiredString("branch"); err != nil {
|
|
return err
|
|
}
|
|
if err := requiredString("remote"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case "TaskFailed":
|
|
if err := requiredString("reason"); err != nil {
|
|
return err
|
|
}
|
|
case "TaskLaunchAcknowledged":
|
|
if err := requiredString("harness_id"); err != nil {
|
|
return err
|
|
}
|
|
case "TaskBlocked", "TaskNeedsAttention":
|
|
if err := requiredString("blocker"); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := p["block_reason"]; ok {
|
|
s, ok := v.(string)
|
|
if !ok || !BlockReason(s).Valid() {
|
|
return fmt.Errorf("%w: block_reason invalid", ErrInvalid)
|
|
}
|
|
}
|
|
if _, ok := p["handoff_ref"]; ok {
|
|
if err := requiredHash(p, "handoff_ref"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if v, ok := p["pane_state"]; ok && v != "open" && v != "closed" && v != "unreachable" && v != "unknown" {
|
|
return fmt.Errorf("%w: pane_state invalid", ErrInvalid)
|
|
}
|
|
case "TaskAmended":
|
|
if len(p) == 0 {
|
|
return fmt.Errorf("%w: amendment cannot be empty", ErrInvalid)
|
|
}
|
|
case "TaskCorrected":
|
|
// §3.1: "a wrong event is never edited; a compensating event is
|
|
// appended and replay sees both." `corrects` names the event this one
|
|
// reverses/repairs — existence against the log is checked in
|
|
// Store.Append, where the log is visible; ValidatePayload only knows
|
|
// shape.
|
|
if err := requiredString("corrects"); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := p["state"]; ok {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return fmt.Errorf("%w: state must be a string", ErrInvalid)
|
|
}
|
|
switch TaskState(s) {
|
|
case StateQueued, StateLeased, StateCompleted, StateFailed, StateBlocked, StateNeedsAttention:
|
|
default:
|
|
return fmt.Errorf("%w: state invalid", ErrInvalid)
|
|
}
|
|
}
|
|
if len(p) < 2 {
|
|
return fmt.Errorf("%w: correction must change at least one field", ErrInvalid)
|
|
}
|
|
case "ApprovalRequested":
|
|
for _, k := range []string{"subject_ref", "options"} {
|
|
if _, ok := p[k]; !ok {
|
|
return fmt.Errorf("%w: %s required", ErrInvalid, k)
|
|
}
|
|
}
|
|
case "ApprovalGranted", "ApprovalDenied":
|
|
if err := requiredString("subject_ref"); err != nil {
|
|
return err
|
|
}
|
|
case "QuotaReported":
|
|
if err := requiredString("harness_id"); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := p["consumed"].(float64); !ok || v < 0 {
|
|
return fmt.Errorf("%w: consumed required", ErrInvalid)
|
|
}
|
|
case "StandupAdvisory":
|
|
if _, ok := p["items"]; !ok {
|
|
return fmt.Errorf("%w: items required", ErrInvalid)
|
|
}
|
|
}
|
|
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
|
|
}
|