77a2b323fa
The API was in a restart loop, exiting with `invalid event: until_ns required`. ValidateEvent compared until_ns against time.Now() for TaskLeased and TaskLeaseRenewed, so a lease event that was valid when written failed validation once it expired. store.Open replays the log tail after the snapshot and log.Fatal's on the first invalid event, so the coordinator refused its own history and could not start. Validation of a durable event must be time-independent. Well-formedness is this function's question; freshness belongs to Store.Lease and Store.ExpireLeases, which compute until_ns themselves. Latent since the field was introduced. It needed a renewal in the post-snapshot tail plus a restart after that renewal expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
544 lines
21 KiB
Go
544 lines
21 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"
|
|
// StateInReview is a submitted change waiting on the human. It is not
|
|
// completion: an agent never decides that a change shipped.
|
|
StateInReview TaskState = "in_review"
|
|
)
|
|
|
|
// 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"
|
|
// BlockReasonTrajectoryGate is a deliberate stop, not a fault: the plan is
|
|
// sealed and Orchestra is waiting for the human to confirm the direction.
|
|
BlockReasonTrajectoryGate BlockReason = "trajectory_gate"
|
|
// BlockReasonHumanDecision is a bounded question the repository could not
|
|
// answer. BlockReasonOperatorRequired is what a task becomes when it has
|
|
// spent its question budget: an operator looks at it rather than the
|
|
// agent asking again.
|
|
BlockReasonHumanDecision BlockReason = "human_decision"
|
|
BlockReasonOperatorRequired BlockReason = "operator_required"
|
|
BlockReasonUnknown BlockReason = "unknown"
|
|
)
|
|
|
|
func (r BlockReason) Valid() bool {
|
|
switch r {
|
|
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
|
|
BlockReasonApproval, BlockReasonHandoffValidation, BlockReasonOperator,
|
|
BlockReasonSystem, BlockReasonUnknown, BlockReasonTrajectoryGate,
|
|
BlockReasonHumanDecision, BlockReasonOperatorRequired:
|
|
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"`
|
|
// WorkPhase is the cognitive phase (frame/research/plan/implement/review),
|
|
// orthogonal to State and LifecyclePhase. Empty means frame.
|
|
WorkPhase WorkPhase `json:"work_phase,omitempty"`
|
|
// DecisionRequest is the question this task is currently blocked on. It is
|
|
// cleared when the task leaves the blocked state, because the answer then
|
|
// stands on its own as a decision and the log still holds the question.
|
|
DecisionRequest *DecisionRequest `json:"decision_request,omitempty"`
|
|
// ReviewTargetSHA is the commit the current review phase was entered
|
|
// against. A review of any other commit is not a review of this work.
|
|
ReviewTargetSHA string `json:"review_target_sha,omitempty"`
|
|
// Review is the last independent review, bound to the commit it was
|
|
// performed against. A review is never a free-floating pass: when the code
|
|
// moves, ResultSHA no longer matches and the review describes a tree that
|
|
// does not exist any more.
|
|
Review *ReviewRef `json:"review,omitempty"`
|
|
// Submission is the durable record of the change handed to the human.
|
|
Submission *SubmissionRef `json:"submission,omitempty"`
|
|
// ResearchRef and PlanRef are the sealed artifacts of the phases already
|
|
// finished. The next phase reads these, never the session that wrote them.
|
|
ResearchRef string `json:"research_ref,omitempty"`
|
|
PlanRef string `json:"plan_ref,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
// ReviewRef binds a sealed review artifact to one commit.
|
|
type ReviewRef struct {
|
|
ArtifactRef string `json:"artifact_ref"`
|
|
ResultSHA string `json:"result_sha"`
|
|
// Blocking is the count of blocker and important findings, projected so a
|
|
// completion check does not have to read the artifact to know the answer.
|
|
Blocking int `json:"blocking"`
|
|
}
|
|
|
|
// EventReviewRecorded seals one independent review. Orchestra emits it; the
|
|
// reviewing session only supplies the findings.
|
|
const EventReviewRecorded = "ReviewRecorded"
|
|
|
|
// ReviewSatisfied reports whether this task holds an accepted review of the
|
|
// exact commit named. It is the mechanical half of completion eligibility.
|
|
func (t Task) ReviewSatisfied(resultSHA string) bool {
|
|
return t.Review != nil && t.Review.ResultSHA == resultSHA && t.Review.Blocking == 0
|
|
}
|
|
|
|
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, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: 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
|
|
}
|
|
// Validation of a durable event must not depend on the current clock.
|
|
// Comparing until_ns against time.Now() here made every lease event
|
|
// fail validation once it expired, so replaying the log after a
|
|
// restart refused the store's own history and the coordinator could
|
|
// not start at all. Freshness is a lease question, answered by
|
|
// Store.Lease and Store.ExpireLeases; well-formedness is this
|
|
// function's question.
|
|
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 <= 0 {
|
|
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
|
|
}
|
|
// Time-independent for the same reason as TaskLeased above.
|
|
until, ok := p["until_ns"].(float64)
|
|
if !ok || until <= 0 {
|
|
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)
|
|
}
|
|
if v, ok := p["decision_request"]; ok {
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("%w: decision_request must be an object", ErrInvalid)
|
|
}
|
|
if err := decodeDecisionRequest(m).Validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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, StateInReview:
|
|
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)
|
|
}
|
|
case EventHumanDecisionRecorded:
|
|
for _, k := range []string{"decision_id", "kind", "subject", "value"} {
|
|
if err := requiredString(k); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if kind, _ := p["kind"].(string); !HumanDecisionKind(kind).Valid() {
|
|
return fmt.Errorf("%w: kind invalid", ErrInvalid)
|
|
}
|
|
src, ok := p["source"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("%w: source required", ErrInvalid)
|
|
}
|
|
if v, ok := src["provider"].(string); !ok || strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%w: source.provider required", ErrInvalid)
|
|
}
|
|
if v, ok := p["supersedes"]; ok {
|
|
list, ok := v.([]any)
|
|
if !ok {
|
|
return fmt.Errorf("%w: supersedes must be an array", ErrInvalid)
|
|
}
|
|
for _, item := range list {
|
|
if s, ok := item.(string); !ok || strings.TrimSpace(s) == "" {
|
|
return fmt.Errorf("%w: supersedes entries must be decision ids", ErrInvalid)
|
|
}
|
|
}
|
|
}
|
|
case EventHumanDecisionSuperseded:
|
|
if err := requiredString("decision_id"); err != nil {
|
|
return err
|
|
}
|
|
case EventWorkPhaseChanged:
|
|
return ValidateWorkPhaseChanged(p)
|
|
case EventTaskSubmitted:
|
|
return ValidateTaskSubmitted(p)
|
|
case EventTaskChangesRequested:
|
|
if v, ok := p["submitted_sha"].(string); !ok || len(v) != 40 {
|
|
return fmt.Errorf("%w: submitted_sha invalid", ErrInvalid)
|
|
}
|
|
if v, ok := p["submission_event"].(string); !ok || strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%w: submission_event required", ErrInvalid)
|
|
}
|
|
ids, ok := p["decision_ids"].([]any)
|
|
if !ok || len(ids) == 0 {
|
|
return fmt.Errorf("%w: decision_ids required", ErrInvalid)
|
|
}
|
|
for _, id := range ids {
|
|
if s, ok := id.(string); !ok || strings.TrimSpace(s) == "" {
|
|
return fmt.Errorf("%w: decision_ids entries must be ids", ErrInvalid)
|
|
}
|
|
}
|
|
case EventReviewRecorded:
|
|
if err := requiredHash(p, "artifact_ref"); err != nil {
|
|
return err
|
|
}
|
|
if v, ok := p["result_sha"].(string); !ok || len(v) != 40 {
|
|
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
|
}
|
|
if v, ok := p["blocking"].(float64); !ok || v < 0 || v != float64(int(v)) {
|
|
return fmt.Errorf("%w: blocking invalid", ErrInvalid)
|
|
}
|
|
case EventDeferredFindingRecorded:
|
|
f := DeferredFinding{}
|
|
f.Summary, _ = p["summary"].(string)
|
|
f.Why, _ = p["why"].(string)
|
|
return f.Validate()
|
|
}
|
|
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
|
|
}
|