diff --git a/CLAUDE.md b/CLAUDE.md index 57a53ac..d216920 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,6 +163,21 @@ npm run clean sample_slot → runs the exact production gojq against a captured artifact, else the latest buffered one, else an illustrative template; bid/envelope reduced to `.message`). + - **Recurring rules** (`rules.go`) cover what sparse plans cannot: a scenario + that must repeat for a whole devnet run. A `SlotRule` matches slot INDICES + within the epoch (`slots_in_epoch`, optionally bounded by `from_epoch`/ + `to_epoch`) and carries the very same categories as a `SlotPlan` — e.g. + win slot 31 of every epoch (`bid: custom` + value override) and withhold + its payload (`reveal: disabled`), the ePBS analogue of "run a devnet with + slot 31 missing". Precedence is WHOLESALE, never merged: an explicit plan + for a slot replaces any rule, and `SlotPlan.IgnoreRules` opts a single slot + out entirely (which is why such a plan is not "empty"). Rules resolve at + freeze/read time (`PlanForSlot`), so a rule change never rewrites an + already frozen slot, and the materialized plan is marked with `rule_id` — + it is synthesized on every read, never persisted per slot. Several matching + rules: lowest id wins. The set is replaced atomically (`SetRules`, max 32), + persisted via the `kv_store` `slot_rules` namespace, and fires + `RuleChangeEvent` - **Freeze semantics**: `Freeze(slot)` resolves the immutable `FrozenPlan` (raw plan + effective settings merged from the live global config + target-slot fork + the COMPLETE build decision incl. schedule modes and next_n accounting) the first time @@ -178,7 +193,10 @@ npm run clean fine-grained `set` paths (`"bid.bid_min_amount": 5000`); overlapping updates apply in request order; committed changes fire `PlanChangeEvent`. - Persisted via the `kv_store` `slot_plans` namespace; past plans prune to - `slot-result-retention-epochs`, future plans never. + `slot-result-retention-epochs`, future plans never. `GetRange` returns the + EFFECTIVE plans of a range: stored plans plus the rule-derived plans of the + still-open slots (`> currentSlot`, not yet frozen) — past slots keep their + recorded history instead of being described by today's rules. 1. **Builder Service** (`pkg/payload_builder/`) - Main orchestrator for payload building @@ -340,8 +358,11 @@ npm run clean - **Action Plan View**: epoch × slot timetable (rows = epochs, columns = slots) rendering plan chips + result status per cell; click-to-edit modal for future slots, full outcome detail + SSZ/JSON artifact downloads for - past slots; multi-slot/range bulk editing; live via the - `action_plan_updated`/`slot_result_updated` SSE events + past slots; multi-slot/range bulk editing; a Recurring Rules panel + (list/toggle/edit/delete + scenario presets like "win the last slot of + every epoch and withhold the payload"); rule-derived cells render dashed + chips; live via the `action_plan_updated`/`action_plan_rules_updated`/ + `slot_result_updated` SSE events - **Bids Won View**: Paginated table of our blocks included on chain - Tab navigation: Dashboard / Bids Won / Action Plan - Real-time updates when new blocks are included (bid_won SSE from the @@ -453,7 +474,8 @@ The optional **state-db** (`pkg/db`, mirrors spamoor: `glebarez/go-sqlite` + `sq SSZ), `validator_registrations` (Builder API registrations, SSZ; codec in `builderapi/legacy`), `builder_preferences` (max_execution_payment, LE uint64; codec in `builderapi/epbs`), `slot_plans` (per-slot action plans, JSON; codec in - `action_plan`), `slot_results` (per-slot outcome summaries, JSON; codec in + `action_plan`), `slot_rules` (recurring per-slot-index plan templates, JSON; + codec in `action_plan`), `slot_results` (per-slot outcome summaries, JSON; codec in `slot_results`; the legacy `won_blocks` namespace is migrated into it once on startup and then deleted) - `slot_artifacts` — dedicated table (NOT a kv namespace: blobs are too large for @@ -518,9 +540,11 @@ buildoor/ ├── cmd/ # CLI commands (root, run, deposit, exit) ├── pkg/ │ ├── action_plan/ # per-slot scheduling authority: sparse SlotPlan store, +│ │ # recurring SlotRules (slot-index-in-epoch templates, +│ │ # wholesale precedence under explicit plans), │ │ # freeze semantics (FrozenPlan = raw plan + resolved │ │ # effective settings + complete build decision), atomic -│ │ # bulk updates w/ path-based partial edits, kv codec +│ │ # bulk updates w/ path-based partial edits, kv codecs │ ├── slot_results/ # generic per-slot outcome history: attempt-aware │ │ # SlotResult tracker (blocking subscriptions, baseline │ │ # slot clock, won-block view, won_blocks migration) + @@ -623,13 +647,21 @@ To make frontend changes: - Query params: `offset` (default: 0), `limit` (default: 20, max: 100) - Returns: `{ bids_won: [], total: number, offset: number, limit: number }` - `GET /api/buildoor/builder-api-status` - Builder API configuration and validator count -- `GET /api/buildoor/action-plan?min_slot=&max_slot=` - Per-slot action plans in the - inclusive range (max span 320 epochs) +- `GET /api/buildoor/action-plan?min_slot=&max_slot=` - EFFECTIVE per-slot action + plans in the inclusive range (max span 320 epochs): stored plans plus the + rule-derived plans of the still-open slots (those carry `rule_id`) - `POST /api/buildoor/action-plan` - Atomic bulk plan mutation (auth + audit). Body `{updates: [...]}`; each update targets `slots` and/or `from_slot..to_slot`, with three-state category members (absent/null/object) and fine-grained `set` - paths (`{"bid.bid_min_amount": 5000}`). Past/frozen slots → 409. Returns the + paths (`{"bid.bid_min_amount": 5000}`) plus the top-level `ignore_rules` + member (per-slot recurring-rule opt-out). Past/frozen slots → 409. Returns the authoritative normalized plans +- `GET /api/buildoor/action-plan/rules` - Recurring rules, id-ascending (= match + order) +- `POST /api/buildoor/action-plan/rules` - Atomically replaces the WHOLE rule set + (auth + audit). Body `{rules: [...]}`; each rule has an id slug, `enabled`, + `slots_in_epoch`, optional `from_epoch`/`to_epoch` and the same categories as a + slot plan. All-or-nothing validation (400 on any bad rule); max 32 rules - `GET /api/buildoor/slot-results?min_slot=&max_slot=` - Attempt-level outcome history per slot (build, bids, submissions, reveals, inclusion, applied plan) - `GET /api/buildoor/slot-results/{slot}/payload|envelope|bids|bids/{index}` - Raw @@ -670,6 +702,9 @@ To make frontend changes: - Logged in event stream for debugging - `action_plan_updated` - Fired on committed plan mutations; data is the authoritative `{slots: [...], plans: [...]}` change set (null plan = deleted) +- `action_plan_rules_updated` - Fired on committed recurring-rule mutations; data + is the authoritative `{rules: [...]}` set. The plan grid refetches its range + because rules change the effective plan of every uncovered slot - `slot_result_updated` - Fired when a slot's result record changes (coalesced to ~1/s per slot); data is the full SlotResult. SSE is an invalidation channel — the REST range endpoints are the source of truth @@ -787,6 +822,12 @@ happened afterwards, down to the exact SSZ objects. past slots reject edits with 409. Operators should plan ≥2 slots ahead - At-least-once semantics across restarts: an in-flight future slot may be re-frozen after a restart +- **Recurring rules** apply a plan template to the same slot index of every epoch + (`slots_in_epoch`, optional `from_epoch`/`to_epoch`) — the tool for scenarios + that must hold for a whole devnet run rather than a bounded slot list. An + explicit plan for a slot replaces the rule wholesale (never merged); a plan + with `ignore_rules` opts a single slot out. Rules resolve at freeze time, so + editing them never rewrites a frozen slot **Outcome inspection** (see `pkg/slot_results/`): every active slot gets an attempt-level `SlotResult` (REST range queries + `slot_result_updated` SSE) and @@ -797,8 +838,10 @@ artifact kind). **WebUI**: the Action Plan tab renders an epoch × slot timetable (rows = epochs, columns = slots) with plan chips + result status per cell, click-to-edit modal -(future slots) / full outcome detail with artifact downloads (past slots), and -multi-slot/range bulk editing. +(future slots) / full outcome detail with artifact downloads (past slots), +multi-slot/range bulk editing, and a Recurring Rules panel with scenario presets. +Rule-derived cells draw dashed chips; opening one pre-fills the rule's values and +saving detaches that slot into an explicit plan. **Testing the feature:** 1. Plan a future slot: `curl -X POST .../api/buildoor/action-plan -d @@ -810,6 +853,18 @@ multi-slot/range bulk editing. 4. Test a withheld reveal: `{"set":{"reveal.mode":"disabled"}}` on a future slot → the reveal attempt records `suppressed`/`plan_disabled`, the envelope artifact still exists +5. Run it for every epoch instead (the "slot 31 missing" devnet scenario): + `curl -X POST .../api/buildoor/action-plan/rules -d '{"rules":[{ + "id":"slot31-withhold","enabled":true,"slots_in_epoch":[31], + "bid":{"mode":"custom","bid_subsidy":200000000}, + "reveal":{"mode":"disabled"}}]}'` → every slot 31 is bid for and, once won, + never revealed: the block exists, its execution payload does not. Use + `bid_subsidy` (added on top of the block value) rather than the absolute + `bid_value_gwei` to outbid competitors: the absolute value replaces the block + value entirely and silently loses whenever it is set below what the block is + worth on that network. A rule that loses the auction withholds nothing, and + the only symptom is the grid's "bid, not included" dot — size the subsidy + against the other builders' bids, not against a round number ## Common Issues diff --git a/pkg/action_plan/codec.go b/pkg/action_plan/codec.go index f0d0d3b..b3ccbd9 100644 --- a/pkg/action_plan/codec.go +++ b/pkg/action_plan/codec.go @@ -50,3 +50,42 @@ func (PlanCodec) DecodeValue(value []byte) (*SlotPlan, error) { return plan, nil } + +// RuleCodec translates recurring slot rules to their persisted form in the +// kv_store: the rule id as key, JSON-encoded values. +type RuleCodec struct{} + +var _ db.KVCodec[string, *SlotRule] = RuleCodec{} + +// EncodeKey uses the rule id verbatim. +func (RuleCodec) EncodeKey(id string) string { + return id +} + +// DecodeKey uses the stored key verbatim. +func (RuleCodec) DecodeKey(key string) (string, error) { + if key == "" { + return "", fmt.Errorf("empty slot rule key") + } + + return key, nil +} + +// EncodeValue JSON-encodes a slot rule. +func (RuleCodec) EncodeValue(rule *SlotRule) ([]byte, error) { + if rule == nil { + return nil, fmt.Errorf("cannot encode nil slot rule") + } + + return json.Marshal(rule) +} + +// DecodeValue JSON-decodes a slot rule. +func (RuleCodec) DecodeValue(value []byte) (*SlotRule, error) { + rule := &SlotRule{} + if err := json.Unmarshal(value, rule); err != nil { + return nil, fmt.Errorf("failed to decode slot rule: %w", err) + } + + return rule, nil +} diff --git a/pkg/action_plan/rules.go b/pkg/action_plan/rules.go new file mode 100644 index 0000000..e01a931 --- /dev/null +++ b/pkg/action_plan/rules.go @@ -0,0 +1,152 @@ +package action_plan + +import ( + "fmt" + "regexp" + "slices" + "time" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" +) + +// RulesNamespace is the kv_store namespace holding the persisted recurring +// rules. +const RulesNamespace = "slot_rules" + +// MaxRules bounds the persisted rule set. Rules are evaluated per slot, and a +// long list is a sign that explicit plans are the better tool. +const MaxRules = 32 + +// ruleIDPattern constrains rule ids to stable, url/kv-safe slugs. +var ruleIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +// SlotRule is a recurring plan template: every slot whose index within the +// epoch matches gets the rule's categories as its plan. It exists for +// scenarios that must repeat for a whole devnet run — e.g. "win slot 31 of +// every epoch and withhold the payload" — which explicit per-slot plans can +// only express for a bounded horizon. +// +// Precedence is wholesale, never merged: an explicit plan for a slot replaces +// any rule, and a plan with IgnoreRules opts the slot out entirely. Rules are +// resolved at freeze time from the live rule set, exactly like the global +// config baseline, so a rule change never rewrites an already frozen slot. +type SlotRule struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Description string `json:"description,omitempty"` + + // SlotsInEpoch are the matched slot indices within an epoch (0-based, so + // 31 is the last slot of a 32-slot epoch). + SlotsInEpoch []uint64 `json:"slots_in_epoch"` + + // FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is + // unbounded on that side. + FromEpoch *uint64 `json:"from_epoch,omitempty"` + ToEpoch *uint64 `json:"to_epoch,omitempty"` + + Bid *BidPlan `json:"bid,omitempty"` + BuilderAPI *BuilderAPIPlan `json:"builder_api,omitempty"` + Reveal *RevealPlan `json:"reveal,omitempty"` + Build *BuildPlan `json:"build,omitempty"` + Transforms *TransformPlan `json:"transforms,omitempty"` + + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +// Clone returns a deep copy. Every rule crossing the package boundary is a +// clone, so callers can never mutate stored state. +func (r *SlotRule) Clone() *SlotRule { + if r == nil { + return nil + } + + c := *r + c.SlotsInEpoch = slices.Clone(r.SlotsInEpoch) + c.FromEpoch = cloneScalar(r.FromEpoch) + c.ToEpoch = cloneScalar(r.ToEpoch) + c.Bid = r.Bid.clone() + c.BuilderAPI = r.BuilderAPI.clone() + c.Reveal = r.Reveal.clone() + c.Build = r.Build.clone() + c.Transforms = r.Transforms.clone() + + return &c +} + +// Matches reports whether the rule applies to the slot. A disabled rule never +// matches. +func (r *SlotRule) Matches(slot phase0.Slot, slotsPerEpoch uint64) bool { + if r == nil || !r.Enabled || slotsPerEpoch == 0 { + return false + } + + epoch := uint64(slot) / slotsPerEpoch + if r.FromEpoch != nil && epoch < *r.FromEpoch { + return false + } + + if r.ToEpoch != nil && epoch > *r.ToEpoch { + return false + } + + return slices.Contains(r.SlotsInEpoch, uint64(slot)%slotsPerEpoch) +} + +// planFor materializes the rule as the slot's plan. The result is marked with +// the rule id and is never persisted — it is synthesized on every read. +func (r *SlotRule) planFor(slot phase0.Slot) *SlotPlan { + return &SlotPlan{ + Slot: slot, + Bid: r.Bid.clone(), + BuilderAPI: r.BuilderAPI.clone(), + Reveal: r.Reveal.clone(), + Build: r.Build.clone(), + Transforms: r.Transforms.clone(), + RuleID: r.ID, + UpdatedAt: r.UpdatedAt, + UpdatedBy: r.UpdatedBy, + } +} + +// Validate checks the matcher and, through a materialized plan, every category +// against the chain spec. +func (r *SlotRule) Validate(slotsPerEpoch uint64, secondsPerSlot time.Duration) error { + if !ruleIDPattern.MatchString(r.ID) { + return fmt.Errorf("rule id %q must match %s", r.ID, ruleIDPattern.String()) + } + + if len(r.SlotsInEpoch) == 0 { + return fmt.Errorf("rule %q: slots_in_epoch must not be empty", r.ID) + } + + seen := make(map[uint64]struct{}, len(r.SlotsInEpoch)) + + for _, index := range r.SlotsInEpoch { + if slotsPerEpoch > 0 && index >= slotsPerEpoch { + return fmt.Errorf("rule %q: slot index %d out of range for %d slots per epoch", + r.ID, index, slotsPerEpoch) + } + + if _, dup := seen[index]; dup { + return fmt.Errorf("rule %q: duplicate slot index %d", r.ID, index) + } + + seen[index] = struct{}{} + } + + if r.FromEpoch != nil && r.ToEpoch != nil && *r.ToEpoch < *r.FromEpoch { + return fmt.Errorf("rule %q: invalid epoch range %d..%d", r.ID, *r.FromEpoch, *r.ToEpoch) + } + + plan := r.planFor(0) + if plan.IsEmpty() { + return fmt.Errorf("rule %q carries no instruction", r.ID) + } + + if err := plan.Validate(secondsPerSlot); err != nil { + return fmt.Errorf("rule %q: %w", r.ID, err) + } + + return nil +} diff --git a/pkg/action_plan/rules_test.go b/pkg/action_plan/rules_test.go new file mode 100644 index 0000000..ebee954 --- /dev/null +++ b/pkg/action_plan/rules_test.go @@ -0,0 +1,374 @@ +package action_plan + +import ( + "encoding/json" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/buildoor/pkg/config" +) + +// slot31Rule is the motivating scenario: win the last slot of every epoch and +// never reveal its payload. +func slot31Rule() *SlotRule { + value := uint64(1_000_000) + + return &SlotRule{ + ID: "slot31-withhold", + Enabled: true, + Description: "win slot 31 and withhold the payload", + SlotsInEpoch: []uint64{31}, + Bid: &BidPlan{ + Mode: ModeCustom, + BidValueGwei: &value, + IgnoreMissingPrefs: true, + }, + Reveal: &RevealPlan{Mode: ModeDisabled}, + } +} + +func TestSlotRuleMatches(t *testing.T) { + rule := slot31Rule() + + require.True(t, rule.Matches(31, 32)) + require.True(t, rule.Matches(32*500+31, 32)) + require.False(t, rule.Matches(30, 32)) + require.False(t, rule.Matches(32, 32)) + + // A disabled rule never matches, and an unknown epoch length cannot match. + rule.Enabled = false + require.False(t, rule.Matches(31, 32)) + + rule.Enabled = true + require.False(t, rule.Matches(31, 0)) +} + +func TestSlotRuleEpochBounds(t *testing.T) { + rule := slot31Rule() + from, to := uint64(10), uint64(12) + rule.FromEpoch = &from + rule.ToEpoch = &to + + require.False(t, rule.Matches(9*32+31, 32)) + require.True(t, rule.Matches(10*32+31, 32)) + require.True(t, rule.Matches(12*32+31, 32)) + require.False(t, rule.Matches(13*32+31, 32)) +} + +func TestSlotRuleValidate(t *testing.T) { + chainSvc := newStubChain() + spec := chainSvc.spec + + require.NoError(t, slot31Rule().Validate(spec.SlotsPerEpoch, spec.SecondsPerSlot)) + + tests := []struct { + name string + mutate func(*SlotRule) + errMsg string + }{ + {"bad id", func(r *SlotRule) { r.ID = "Not A Slug" }, "must match"}, + {"no slots", func(r *SlotRule) { r.SlotsInEpoch = nil }, "must not be empty"}, + {"slot out of range", func(r *SlotRule) { r.SlotsInEpoch = []uint64{32} }, "out of range"}, + {"duplicate slot", func(r *SlotRule) { r.SlotsInEpoch = []uint64{31, 31} }, "duplicate slot index"}, + { + "inverted epoch range", + func(r *SlotRule) { + from, to := uint64(9), uint64(5) + r.FromEpoch, r.ToEpoch = &from, &to + }, + "invalid epoch range", + }, + { + "no instruction", + func(r *SlotRule) { r.Bid, r.Reveal = nil, nil }, + "carries no instruction", + }, + { + "invalid category", + func(r *SlotRule) { r.Reveal = &RevealPlan{Mode: "bogus"} }, + "invalid mode", + }, + { + "overrides on a disabled category", + func(r *SlotRule) { + start := int64(100) + r.Bid = &BidPlan{Mode: ModeDisabled, BidStartTime: &start} + }, + "overrides are only allowed in custom mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule := slot31Rule() + tt.mutate(rule) + require.ErrorContains(t, rule.Validate(spec.SlotsPerEpoch, spec.SecondsPerSlot), tt.errMsg) + }) + } +} + +func TestSetRulesAtomicAndPersistedShape(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + rules, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + require.Len(t, rules, 1) + require.Equal(t, "tester", rules[0].UpdatedBy) + require.False(t, rules[0].UpdatedAt.IsZero()) + + // A rejected batch leaves the committed set untouched. + broken := slot31Rule() + broken.ID = "second" + broken.SlotsInEpoch = []uint64{99} + + _, err = svc.SetRules([]*SlotRule{slot31Rule(), broken}, "tester") + require.ErrorContains(t, err, "out of range") + require.Len(t, svc.Rules(), 1) + + // Duplicate ids are rejected before anything is committed. + _, err = svc.SetRules([]*SlotRule{slot31Rule(), slot31Rule()}, "tester") + require.ErrorContains(t, err, "duplicate rule id") + + // The set is replaced wholesale: the old rule is gone. + replacement := slot31Rule() + replacement.ID = "other" + + rules, err = svc.SetRules([]*SlotRule{replacement}, "tester") + require.NoError(t, err) + require.Len(t, rules, 1) + require.Equal(t, "other", rules[0].ID) + + // Clearing works, and callers never hold a reference into the store. + rules, err = svc.SetRules(nil, "tester") + require.NoError(t, err) + require.Empty(t, rules) +} + +func TestRulesAreCloned(t *testing.T) { + svc := newTestService(newStubChain(), nil) + rule := slot31Rule() + + _, err := svc.SetRules([]*SlotRule{rule}, "tester") + require.NoError(t, err) + + // Mutating the caller's rule must not reach the store. + rule.SlotsInEpoch[0] = 7 + rule.Enabled = false + *rule.Bid.BidValueGwei = 1 + + stored := svc.Rules() + require.Equal(t, []uint64{31}, stored[0].SlotsInEpoch) + require.True(t, stored[0].Enabled) + require.Equal(t, uint64(1_000_000), *stored[0].Bid.BidValueGwei) + + // Nor does mutating a returned clone. + stored[0].SlotsInEpoch[0] = 7 + require.Equal(t, []uint64{31}, svc.Rules()[0].SlotsInEpoch) +} + +func TestPlanForSlotResolvesRules(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + matched := svc.PlanForSlot(2047) // 2047 = epoch 63, slot 31 + require.NotNil(t, matched) + require.Equal(t, "slot31-withhold", matched.RuleID) + require.Equal(t, phase0.Slot(2047), matched.Slot) + require.Equal(t, ModeDisabled, matched.Reveal.Mode) + + require.Nil(t, svc.PlanForSlot(2046)) + + // Explicit plans win wholesale — the rule's reveal instruction is gone. + _, err = svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2047}, + Bid: json.RawMessage(`{"mode":"custom","bid_min_amount":5}`), + }}, "tester") + require.NoError(t, err) + + explicit := svc.PlanForSlot(2047) + require.Empty(t, explicit.RuleID) + require.Nil(t, explicit.Reveal) + require.Equal(t, uint64(5), *explicit.Bid.BidMinAmount) + + // Get only ever reports the stored plan. + require.Nil(t, svc.Get(2079)) +} + +func TestIgnoreRulesOptsSlotOut(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + ignore := true + + event, err := svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2047}, + IgnoreRules: &ignore, + }}, "tester") + require.NoError(t, err) + require.NotNil(t, event.Plans[0], "an opt-out is an instruction, not an empty plan") + require.True(t, event.Plans[0].IgnoreRules) + + plan := svc.PlanForSlot(2047) + require.NotNil(t, plan) + require.Empty(t, plan.RuleID) + require.Nil(t, plan.Bid) + require.Nil(t, plan.Reveal) + + // The slot runs on the plain global baseline again. + frozen := svc.Freeze(2047) + require.NotNil(t, frozen.Reveal) + require.False(t, frozen.Reveal.Suppressed) +} + +func TestFreezeAppliesRule(t *testing.T) { + chainSvc := newStubChain() + svc := newTestService(chainSvc, nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + frozen := svc.Freeze(2047) + require.NotNil(t, frozen.Plan) + require.Equal(t, "slot31-withhold", frozen.Plan.RuleID) + + // The rule forces the build past the schedule and suppresses the reveal. + require.True(t, frozen.Build.Build) + require.True(t, frozen.Build.Forced) + require.NotNil(t, frozen.Bid) + require.Equal(t, uint64(1_000_000), *frozen.Bid.ValueGwei) + require.True(t, frozen.Bid.IgnoreMissingPrefs) + require.True(t, frozen.Reveal.Suppressed) + + // A frozen slot keeps its snapshot even when the rule set changes. + _, err = svc.SetRules(nil, "tester") + require.NoError(t, err) + require.True(t, svc.Freeze(2047).Reveal.Suppressed) +} + +func TestGetRangeIncludesRulePlans(t *testing.T) { + chainSvc := newStubChain() + chainSvc.currentSlot = 2020 + svc := newTestService(chainSvc, nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + // Epochs 62..64 cover slots 1984..2079, so 2015, 2047 and 2079 match the + // rule — but 2015 has already passed. + plans := svc.GetRange(1984, 2079) + require.Len(t, plans, 2, "past slots keep their recorded history, not the current rule") + require.Equal(t, phase0.Slot(2047), plans[0].Slot) + require.Equal(t, phase0.Slot(2079), plans[1].Slot) + require.Equal(t, "slot31-withhold", plans[0].RuleID) + + // A frozen slot is no longer open for the rule set to describe. + svc.Freeze(2047) + plans = svc.GetRange(1984, 2079) + require.Len(t, plans, 1) + require.Equal(t, phase0.Slot(2079), plans[0].Slot) + + // Explicit plans replace the rule-derived entry rather than duplicating it. + _, err = svc.ApplyUpdates([]*PlanUpdate{{ + Slots: []uint64{2079}, + Bid: json.RawMessage(`{"mode":"disabled"}`), + }}, "tester") + require.NoError(t, err) + + plans = svc.GetRange(1984, 2079) + require.Len(t, plans, 1) + require.Equal(t, phase0.Slot(2079), plans[0].Slot) + require.Empty(t, plans[0].RuleID) + require.Equal(t, ModeDisabled, plans[0].Bid.Mode) +} + +func TestRuleForcedBuildDoesNotConsumeNextN(t *testing.T) { + cfg := config.DefaultConfig() + cfg.EPBSEnabled = true + cfg.BuilderAPIEnabled = true + cfg.APIPort = 8080 + cfg.Schedule.Mode = config.ScheduleModeNextN + cfg.Schedule.NextN = 1 + + svc := newTestService(newStubChain(), cfg) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + + // A rule forces the build past the schedule, so it must not spend budget. + forced := svc.Freeze(2047) + require.True(t, forced.Build.Build) + require.True(t, forced.Build.Forced) + + svc.OnSlotBuilt(2047) + require.Equal(t, uint64(0), svc.GetSlotsBuilt()) + require.Equal(t, 1, svc.GetSlotsRemaining()) + + // A scheduled build on an unmatched slot still consumes it. + scheduled := svc.Freeze(2048) + require.True(t, scheduled.Build.Build) + require.False(t, scheduled.Build.Forced) + + svc.OnSlotBuilt(2048) + require.Equal(t, uint64(1), svc.GetSlotsBuilt()) + require.Equal(t, 0, svc.GetSlotsRemaining()) + + // With the budget spent the rule keeps forcing its own slots. + require.True(t, svc.Freeze(2079).Build.Build) + require.False(t, svc.Freeze(2080).Build.Build) +} + +func TestUnmatchableRulesAfterSpecChange(t *testing.T) { + chainSvc := newStubChain() + svc := newTestService(chainSvc, nil) + + _, err := svc.SetRules([]*SlotRule{slot31Rule()}, "tester") + require.NoError(t, err) + require.Empty(t, svc.unmatchableRules()) + + // Same state-db, network with a shorter epoch: slot index 31 cannot occur. + chainSvc.spec.SlotsPerEpoch = 8 + + require.Equal(t, []string{"slot31-withhold"}, svc.unmatchableRules()) + require.Nil(t, svc.PlanForSlot(2047), "an unmatchable rule must not resolve") +} + +func TestMatchRuleOrderIsDeterministic(t *testing.T) { + svc := newTestService(newStubChain(), nil) + + first := slot31Rule() + first.ID = "aaa-first" + + second := slot31Rule() + second.ID = "zzz-second" + second.Reveal = &RevealPlan{Mode: ModeCustom} + + _, err := svc.SetRules([]*SlotRule{second, first}, "tester") + require.NoError(t, err) + + require.Equal(t, "aaa-first", svc.PlanForSlot(2047).RuleID) +} + +func TestRuleCodecRoundTrip(t *testing.T) { + codec := RuleCodec{} + + encoded, err := codec.EncodeValue(slot31Rule()) + require.NoError(t, err) + + decoded, err := codec.DecodeValue(encoded) + require.NoError(t, err) + require.Equal(t, slot31Rule().SlotsInEpoch, decoded.SlotsInEpoch) + require.Equal(t, ModeDisabled, decoded.Reveal.Mode) + + key, err := codec.DecodeKey(codec.EncodeKey("slot31-withhold")) + require.NoError(t, err) + require.Equal(t, "slot31-withhold", key) + + _, err = codec.DecodeKey("") + require.Error(t, err) +} diff --git a/pkg/action_plan/service.go b/pkg/action_plan/service.go index ddb6527..52ca812 100644 --- a/pkg/action_plan/service.go +++ b/pkg/action_plan/service.go @@ -38,13 +38,24 @@ type PlanChangeEvent struct { Plans []*SlotPlan `json:"plans"` } -// PlanService owns the sparse per-slot action plan store, its freeze state and -// its persistence. It is the single writer; all reads return deep copies. +// RuleChangeEvent carries the authoritative rule set after a committed change. +type RuleChangeEvent struct { + Rules []*SlotRule `json:"rules"` +} + +// PlanService owns the sparse per-slot action plan store, the recurring rules +// that fill the slots it does not cover, their freeze state and their +// persistence. It is the single writer; all reads return deep copies. type PlanService struct { cfg *config.Config chainSvc chain.Service store *memstore.Store[phase0.Slot, *SlotPlan] + rules *memstore.Store[string, *SlotRule] + // mu guards frozen and slotsBuilt, and additionally serializes recurring + // rule swaps against Freeze: both stores have their own locks, but only + // this mutex guarantees that no slot freezes against a half-replaced rule + // set. Do not drop it from SetRules. mu sync.Mutex frozen map[phase0.Slot]*FrozenPlan @@ -52,7 +63,8 @@ type PlanService struct { // builds are exempt). Guarded by mu. slotsBuilt uint64 - changes utils.Dispatcher[*PlanChangeEvent] + changes utils.Dispatcher[*PlanChangeEvent] + ruleChanges utils.Dispatcher[*RuleChangeEvent] ctx context.Context cancel context.CancelFunc @@ -68,15 +80,18 @@ func NewPlanService(cfg *config.Config, chainSvc chain.Service, log logrus.Field cfg: cfg, chainSvc: chainSvc, store: memstore.New[phase0.Slot, *SlotPlan](), + rules: memstore.New[string, *SlotRule](), frozen: make(map[phase0.Slot]*FrozenPlan, 64), log: log.WithField("component", "action-plan"), } } -// SetPersistence attaches the state-db backed persistence (kv_store namespace -// "slot_plans") and rehydrates previously stored plans. +// SetPersistence attaches the state-db backed persistence (kv_store namespaces +// "slot_plans" and "slot_rules") and rehydrates previously stored plans and +// recurring rules. func (s *PlanService) SetPersistence(ctx context.Context, stateDB *db.Database) { s.store.SetPersistence(ctx, db.NewKVPersistence(stateDB, Namespace, PlanCodec{}), s.log) + s.rules.SetPersistence(ctx, db.NewKVPersistence(stateDB, RulesNamespace, RuleCodec{}), s.log) } // Start launches the pruning loop (driven by epoch transitions). @@ -89,11 +104,52 @@ func (s *PlanService) Start(ctx context.Context) error { go s.run(epochSub) + // Rehydrated rules are validated against the spec they were written under, + // not the active one — a state-db carried to a network with a different + // SLOTS_PER_EPOCH would otherwise leave them silently dead. + for _, id := range s.unmatchableRules() { + s.log.WithFields(logrus.Fields{ + "rule": id, + "slots_per_epoch": s.slotsPerEpoch(), + }).Warn("Recurring rule can never match: slot index out of range for the active chain spec") + } + s.log.Info("Action plan service started") return nil } +// unmatchableRules returns the ids of rules carrying a slot index the active +// chain spec can never produce. Writes are validated against the spec, so this +// only ever fires for rules rehydrated from another network's state-db. +func (s *PlanService) unmatchableRules() []string { + rules := s.sortedRules() + if len(rules) == 0 { + return nil + } + + slotsPerEpoch := s.slotsPerEpoch() + if slotsPerEpoch == 0 { + s.log.Warn("Cannot verify recurring rules: chain spec unavailable") + + return nil + } + + var unmatchable []string + + for _, rule := range rules { + for _, index := range rule.SlotsInEpoch { + if index >= slotsPerEpoch { + unmatchable = append(unmatchable, rule.ID) + + break + } + } + } + + return unmatchable +} + // Stop terminates the pruning loop and flushes the store. Must be called // before the state-db closes. func (s *PlanService) Stop() { @@ -103,6 +159,7 @@ func (s *PlanService) Stop() { s.wg.Wait() s.store.Stop() + s.rules.Stop() s.log.Info("Action plan service stopped") } @@ -125,7 +182,9 @@ func (s *PlanService) run(epochSub *utils.Subscription[*chain.EpochStats]) { } } -// Get returns a deep copy of the slot's plan, or nil when none exists. +// Get returns a deep copy of the slot's explicitly stored plan, or nil when +// none exists. Recurring rules are deliberately not consulted — use +// PlanForSlot for the effective plan. func (s *PlanService) Get(slot phase0.Slot) *SlotPlan { plan, ok := s.store.Get(slot) if !ok { @@ -135,8 +194,26 @@ func (s *PlanService) Get(slot phase0.Slot) *SlotPlan { return plan.Clone() } -// GetRange returns deep copies of all plans within [minSlot, maxSlot], -// slot-ascending. +// PlanForSlot returns the effective plan for a slot: the explicitly stored +// plan if there is one, otherwise the first matching recurring rule +// materialized for the slot (marked with its rule id). Precedence is +// wholesale — an explicit plan is never merged with a rule. +func (s *PlanService) PlanForSlot(slot phase0.Slot) *SlotPlan { + if plan, ok := s.store.Get(slot); ok { + return plan.Clone() + } + + rule := matchRule(s.sortedRules(), slot, s.slotsPerEpoch()) + if rule == nil { + return nil + } + + return rule.planFor(slot) +} + +// GetRange returns deep copies of all effective plans within +// [minSlot, maxSlot], slot-ascending: every explicitly stored plan plus the +// plans recurring rules contribute to the still-open slots of the range. func (s *PlanService) GetRange(minSlot, maxSlot phase0.Slot) []*SlotPlan { entries := s.store.Entries() plans := make([]*SlotPlan, 0, len(entries)) @@ -147,15 +224,78 @@ func (s *PlanService) GetRange(minSlot, maxSlot phase0.Slot) []*SlotPlan { } } + plans = append(plans, s.rulePlansInRange(minSlot, maxSlot, entries)...) + sortPlansBySlot(plans) return plans } +// rulePlansInRange materializes the rule-derived plans of a range. Only slots +// that can still execute under the CURRENT rule set are covered: past and +// already frozen slots ran under whatever was configured back then, and their +// slot result carries the authoritative applied plan. +func (s *PlanService) rulePlansInRange(minSlot, maxSlot phase0.Slot, + explicit map[phase0.Slot]*SlotPlan) []*SlotPlan { + rules := s.sortedRules() + slotsPerEpoch := s.slotsPerEpoch() + + if len(rules) == 0 || slotsPerEpoch == 0 { + return nil + } + + if firstOpen := s.chainSvc.GetCurrentSlot() + 1; firstOpen > minSlot { + minSlot = firstOpen + } + + if maxSlot < minSlot { + return nil + } + + frozen := s.frozenSlotsInRange(minSlot, maxSlot) + + var plans []*SlotPlan + + for slot := minSlot; ; slot++ { + _, hasPlan := explicit[slot] + _, isFrozen := frozen[slot] + + if !hasPlan && !isFrozen { + if rule := matchRule(rules, slot, slotsPerEpoch); rule != nil { + plans = append(plans, rule.planFor(slot)) + } + } + + if slot == maxSlot { + break + } + } + + return plans +} + +// frozenSlotsInRange snapshots the freeze markers within the range under one +// lock; the marker map is pruned to roughly an epoch, so this stays cheap. +func (s *PlanService) frozenSlotsInRange(minSlot, maxSlot phase0.Slot) map[phase0.Slot]struct{} { + s.mu.Lock() + defer s.mu.Unlock() + + frozen := make(map[phase0.Slot]struct{}, len(s.frozen)) + + for slot := range s.frozen { + if slot >= minSlot && slot <= maxSlot { + frozen[slot] = struct{}{} + } + } + + return frozen +} + // Freeze resolves and records the slot's immutable execution snapshot. The // first caller wins; every later caller (and every other decision point of // the same slot) receives the identical snapshot. From this moment on the -// slot's plan can no longer be edited. +// slot's plan can no longer be edited, and later rule changes no longer +// affect it. func (s *PlanService) Freeze(slot phase0.Slot) *FrozenPlan { s.mu.Lock() defer s.mu.Unlock() @@ -164,10 +304,8 @@ func (s *PlanService) Freeze(slot phase0.Slot) *FrozenPlan { return frozen } - var plan *SlotPlan - if stored, ok := s.store.Get(slot); ok { - plan = stored.Clone() - } + // Rule resolution reads the plan/rule stores only — it must never take s.mu. + plan := s.PlanForSlot(slot) fork := s.chainSvc.ActiveForkAtEpoch(s.chainSvc.GetEpochOfSlot(slot)) frozen := resolveFrozenPlan(slot, plan, s.cfg, fork, time.Now(), s.slotsBuilt) @@ -338,6 +476,115 @@ func (s *PlanService) SubscribeChanges(capacity int) *utils.Subscription[*PlanCh return s.changes.Subscribe(capacity, false) } +// SubscribeRuleChanges subscribes to committed recurring-rule mutations +// (non-blocking delivery; intended for the SSE bridge). +func (s *PlanService) SubscribeRuleChanges(capacity int) *utils.Subscription[*RuleChangeEvent] { + return s.ruleChanges.Subscribe(capacity, false) +} + +// Rules returns deep copies of all recurring rules, id-ascending — which is +// also the order in which they are matched. +func (s *PlanService) Rules() []*SlotRule { + stored := s.sortedRules() + rules := make([]*SlotRule, len(stored)) + + for i, rule := range stored { + rules[i] = rule.Clone() + } + + return rules +} + +// SetRules atomically replaces the whole recurring rule set: either every rule +// validates and is committed, or nothing changes. Already frozen slots keep +// the plan they froze with; every later slot resolves against the new set. +func (s *PlanService) SetRules(rules []*SlotRule, actor string) ([]*SlotRule, error) { + if len(rules) > MaxRules { + return nil, fmt.Errorf("too many rules: %d (max %d)", len(rules), MaxRules) + } + + spec := s.chainSvc.GetChainSpec() + now := time.Now() + + staged := make(map[string]*SlotRule, len(rules)) + + for i, rule := range rules { + if rule == nil { + return nil, fmt.Errorf("rule %d: must not be null", i) + } + + if err := rule.Validate(spec.SlotsPerEpoch, spec.SecondsPerSlot); err != nil { + return nil, err + } + + if _, dup := staged[rule.ID]; dup { + return nil, fmt.Errorf("duplicate rule id %q", rule.ID) + } + + stored := rule.Clone() + stored.UpdatedAt = now + stored.UpdatedBy = actor + staged[rule.ID] = stored + } + + s.mu.Lock() + defer s.mu.Unlock() + + for id := range s.rules.Entries() { + if _, keep := staged[id]; !keep { + s.rules.Delete(id) + } + } + + for id, rule := range staged { + s.rules.Put(id, rule) + } + + event := &RuleChangeEvent{Rules: s.Rules()} + + s.log.WithFields(logrus.Fields{ + "rules": len(event.Rules), + "actor": actor, + }).Info("Updated action plan rules") + + s.ruleChanges.Fire(event) + + return event.Rules, nil +} + +// sortedRules returns the stored rules in match order (id-ascending). The +// returned values are the stored pointers — package-internal read-only use. +func (s *PlanService) sortedRules() []*SlotRule { + rules := s.rules.Values() + + sort.Slice(rules, func(i, j int) bool { + return rules[i].ID < rules[j].ID + }) + + return rules +} + +func (s *PlanService) slotsPerEpoch() uint64 { + spec := s.chainSvc.GetChainSpec() + if spec == nil { + return 0 + } + + return spec.SlotsPerEpoch +} + +// matchRule returns the first rule of the (id-ordered) set that applies to the +// slot, so the id decides which rule wins when several match. +func matchRule(rules []*SlotRule, slot phase0.Slot, slotsPerEpoch uint64) *SlotRule { + for _, rule := range rules { + if rule.Matches(slot, slotsPerEpoch) { + return rule + } + } + + return nil +} + // pruneForEpoch drops past plans outside the retention window and stale // freeze markers. Future plans never match the cutoff and are never pruned. func (s *PlanService) pruneForEpoch(epoch phase0.Epoch) { diff --git a/pkg/action_plan/types.go b/pkg/action_plan/types.go index c0f91df..22c1218 100644 --- a/pkg/action_plan/types.go +++ b/pkg/action_plan/types.go @@ -330,8 +330,18 @@ type SlotPlan struct { Reveal *RevealPlan `json:"reveal,omitempty"` Build *BuildPlan `json:"build,omitempty"` Transforms *TransformPlan `json:"transforms,omitempty"` - UpdatedAt time.Time `json:"updated_at"` - UpdatedBy string `json:"updated_by"` + + // IgnoreRules opts the slot out of every recurring rule, so it runs on the + // plain global baseline. Without it an empty plan is dropped, which would + // hand the slot straight back to the matching rule. + IgnoreRules bool `json:"ignore_rules,omitempty"` + + // RuleID marks a plan synthesized from a recurring rule. Synthesized plans + // are never stored — the field is empty on every persisted plan. + RuleID string `json:"rule_id,omitempty"` + + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` } // Clone returns a deep copy. All plan values crossing the package boundary @@ -351,10 +361,11 @@ func (p *SlotPlan) Clone() *SlotPlan { return &c } -// IsEmpty reports whether the plan carries no instruction at all. +// IsEmpty reports whether the plan carries no instruction at all. An explicit +// rule opt-out is an instruction, so such a plan is kept. func (p *SlotPlan) IsEmpty() bool { return p == nil || (p.Bid == nil && p.BuilderAPI == nil && p.Reveal == nil && - p.Build.isZero() && p.Transforms.isZero()) + p.Build.isZero() && p.Transforms.isZero() && !p.IgnoreRules) } // BidOverride returns the per-slot enable override for p2p bidding: @@ -459,6 +470,10 @@ type PlanUpdate struct { Build json.RawMessage `json:"build,omitempty"` Transforms json.RawMessage `json:"transforms,omitempty"` + // IgnoreRules is two-state on purpose: absent = unchanged, true/false = + // set the slot's recurring-rule opt-out. + IgnoreRules *bool `json:"ignore_rules,omitempty"` + Set map[string]json.RawMessage `json:"set,omitempty"` } @@ -560,6 +575,10 @@ func ApplyUpdateToPlan(existing *SlotPlan, u *PlanUpdate) (*SlotPlan, error) { result.Transforms = transforms } + if u.IgnoreRules != nil { + result.IgnoreRules = *u.IgnoreRules + } + if err := applySetPaths(result, u.Set); err != nil { return nil, err } @@ -636,8 +655,8 @@ func applySetPaths(plan *SlotPlan, set map[string]json.RawMessage) error { plan.Transforms = patched default: - return fmt.Errorf( - "set: unknown path %q (categories: bid, builder_api, reveal, build, transforms)", path) + return fmt.Errorf("set: unknown path %q (categories: bid, builder_api, reveal, "+ + "build, transforms; the rule opt-out is the top-level \"ignore_rules\" member)", path) } } diff --git a/pkg/webui/handlers/api/action_plan.go b/pkg/webui/handlers/api/action_plan.go index 3e6bf23..ffa8ec5 100644 --- a/pkg/webui/handlers/api/action_plan.go +++ b/pkg/webui/handlers/api/action_plan.go @@ -43,6 +43,16 @@ type UpdateActionPlanResponse struct { Plans []*action_plan.SlotPlan `json:"plans"` } +// ActionPlanRulesRequest replaces the whole recurring rule set atomically. +type ActionPlanRulesRequest struct { + Rules []*action_plan.SlotRule `json:"rules"` +} + +// ActionPlanRulesResponse returns the authoritative recurring rule set. +type ActionPlanRulesResponse struct { + Rules []*action_plan.SlotRule `json:"rules"` +} + // SlotResultsResponse is the response of the slot results range query. type SlotResultsResponse struct { Results []*slot_results.SlotResult `json:"results"` @@ -195,6 +205,87 @@ func (h *APIHandler) UpdateActionPlan(w http.ResponseWriter, r *http.Request) { }) } +// GetActionPlanRules godoc +// @Id getActionPlanRules +// @Summary Get recurring action plan rules +// @Tags ActionPlan +// @Description Returns the recurring slot rules, id-ascending (which is also +// @Description their match order). A rule applies its categories to every slot +// @Description whose index within the epoch matches, unless the slot carries +// @Description an explicit plan. +// @Produce json +// @Success 200 {object} ActionPlanRulesResponse +// @Failure 503 {object} map[string]string "Plan service unavailable" +// @Router /api/buildoor/action-plan/rules [get] +func (h *APIHandler) GetActionPlanRules(w http.ResponseWriter, r *http.Request) { + if h.planSvc == nil { + writeError(w, http.StatusServiceUnavailable, "action plan service not available") + return + } + + rules := h.planSvc.Rules() + if rules == nil { + rules = []*action_plan.SlotRule{} + } + + writeJSON(w, http.StatusOK, &ActionPlanRulesResponse{Rules: rules}) +} + +// UpdateActionPlanRules godoc +// @Id updateActionPlanRules +// @Summary Replace the recurring action plan rules +// @Tags ActionPlan +// @Description Atomically replaces the whole recurring rule set (every rule +// @Description validates or nothing changes). Each rule matches slot indices +// @Description within an epoch, optionally bounded to an epoch window, and +// @Description carries the same categories as a slot plan — e.g. win slot 31 +// @Description of every epoch and withhold its payload. Already frozen slots +// @Description keep the plan they froze with. Requires authentication. +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer token" +// @Param request body ActionPlanRulesRequest true "Full rule set" +// @Success 200 {object} ActionPlanRulesResponse "Authoritative rule set" +// @Failure 400 {object} map[string]string "Validation error" +// @Failure 401 {object} map[string]string "Unauthorized" +// @Failure 503 {object} map[string]string "Plan service unavailable" +// @Router /api/buildoor/action-plan/rules [post] +func (h *APIHandler) UpdateActionPlanRules(w http.ResponseWriter, r *http.Request) { + token := h.authHandler.CheckAuthToken(r.Header.Get("Authorization")) + if token == nil { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + if h.planSvc == nil { + writeError(w, http.StatusServiceUnavailable, "action plan service not available") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxPlanUpdateBodyBytes) + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + var req ActionPlanRulesRequest + if err := decoder.Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + rules, err := h.planSvc.SetRules(req.Rules, actorFromToken(token)) + if err != nil { + h.audit(r, token, "action_plan.rules", "", req, "error: "+err.Error()) + writeError(w, http.StatusBadRequest, err.Error()) + + return + } + + h.audit(r, token, "action_plan.rules", "", req, "ok") + + writeJSON(w, http.StatusOK, &ActionPlanRulesResponse{Rules: rules}) +} + // GetSlotResults godoc // @Id getSlotResults // @Summary Get per-slot results diff --git a/pkg/webui/handlers/api/action_plan_test.go b/pkg/webui/handlers/api/action_plan_test.go index 5b4e0a6..caca3df 100644 --- a/pkg/webui/handlers/api/action_plan_test.go +++ b/pkg/webui/handlers/api/action_plan_test.go @@ -215,6 +215,83 @@ func TestUpdateActionPlanErrorMapping(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } +func postActionPlanRules(t *testing.T, env *planAPITestEnv, body string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodPost, "/api/buildoor/action-plan/rules", + bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + env.handler.UpdateActionPlanRules(rec, req) + + return rec +} + +func TestActionPlanRulesRoundTrip(t *testing.T) { + env := newPlanAPITestEnv(t) + + // The motivating scenario: win the last slot of every epoch, never reveal. + rec := postActionPlanRules(t, env, `{"rules":[{ + "id":"slot31-withhold", + "enabled":true, + "description":"win slot 31 and withhold the payload", + "slots_in_epoch":[31], + "bid":{"mode":"custom","bid_value_gwei":1000000,"ignore_missing_prefs":true}, + "reveal":{"mode":"disabled"} + }]}`) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp ActionPlanRulesResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Rules, 1) + assert.Equal(t, "slot31-withhold", resp.Rules[0].ID) + + rec = httptest.NewRecorder() + env.handler.GetActionPlanRules(rec, + httptest.NewRequest(http.MethodGet, "/api/buildoor/action-plan/rules", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Len(t, resp.Rules, 1) + assert.Equal(t, action_plan.ModeDisabled, resp.Rules[0].Reveal.Mode) + + // Rule-derived plans surface in the range query, marked with their rule. + rec = httptest.NewRecorder() + env.handler.GetActionPlan(rec, + httptest.NewRequest(http.MethodGet, "/api/buildoor/action-plan?min_slot=1024&max_slot=1055", nil)) + require.Equal(t, http.StatusOK, rec.Code) + + var plans ActionPlanResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &plans)) + require.Len(t, plans.Plans, 1) + assert.Equal(t, phase0.Slot(1055), plans.Plans[0].Slot) + assert.Equal(t, "slot31-withhold", plans.Plans[0].RuleID) +} + +func TestActionPlanRulesValidation(t *testing.T) { + env := newPlanAPITestEnv(t) + + tests := []struct { + name string + body string + }{ + {"bad id", `{"rules":[{"id":"Bad Id","enabled":true,"slots_in_epoch":[31],"reveal":{"mode":"disabled"}}]}`}, + {"slot out of range", `{"rules":[{"id":"r","enabled":true,"slots_in_epoch":[32],"reveal":{"mode":"disabled"}}]}`}, + {"no instruction", `{"rules":[{"id":"r","enabled":true,"slots_in_epoch":[31]}]}`}, + {"unknown field", `{"rules":[{"id":"r","enabled":true,"slots_in_epoch":[31],"nope":1}]}`}, + {"malformed", `{`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, http.StatusBadRequest, postActionPlanRules(t, env, tt.body).Code) + }) + } + + assert.Empty(t, env.planSvc.Rules(), "no rejected rule may be committed") +} + func TestGetSlotResultsRange(t *testing.T) { env := newPlanAPITestEnv(t) diff --git a/pkg/webui/handlers/api/events.go b/pkg/webui/handlers/api/events.go index 47cfcae..4c047bd 100644 --- a/pkg/webui/handlers/api/events.go +++ b/pkg/webui/handlers/api/events.go @@ -58,6 +58,7 @@ const ( EventTypeBuilderAPISubmitBlockDlvd EventType = "builder_api_submit_block_delivered" EventTypeServiceStatus EventType = "service_status" EventTypeActionPlanUpdated EventType = "action_plan_updated" + EventTypeActionPlanRulesUpdated EventType = "action_plan_rules_updated" EventTypeSlotResultUpdated EventType = "slot_result_updated" EventTypeLifecycle EventType = "lifecycle" EventTypeBidIncluded EventType = "bid_included" @@ -626,9 +627,15 @@ func (m *EventStreamManager) Start() { var planChangeChan <-chan *action_plan.PlanChangeEvent + var ruleChangeSub *utils.Subscription[*action_plan.RuleChangeEvent] + + var ruleChangeChan <-chan *action_plan.RuleChangeEvent + if m.planSvc != nil { planChangeSub = m.planSvc.SubscribeChanges(16) planChangeChan = planChangeSub.Channel() + ruleChangeSub = m.planSvc.SubscribeRuleChanges(16) + ruleChangeChan = ruleChangeSub.Channel() } // Subscribe to slot result updates (if results tracker available). SSE is @@ -711,6 +718,10 @@ func (m *EventStreamManager) Start() { defer planChangeSub.Unsubscribe() } + if ruleChangeSub != nil { + defer ruleChangeSub.Unsubscribe() + } + if resultUpdateSub != nil { defer resultUpdateSub.Unsubscribe() } @@ -796,6 +807,18 @@ func (m *EventStreamManager) Start() { Data: event, }) + case event, ok := <-ruleChangeChan: + if !ok { + ruleChangeChan = nil + continue + } + + m.Broadcast(&StreamEvent{ + Type: EventTypeActionPlanRulesUpdated, + Timestamp: time.Now().UnixMilli(), + Data: event, + }) + case event, ok := <-resultUpdateChan: if !ok { resultUpdateChan = nil diff --git a/pkg/webui/handlers/docs/docs.go b/pkg/webui/handlers/docs/docs.go index b0456e0..e6f4830 100644 --- a/pkg/webui/handlers/docs/docs.go +++ b/pkg/webui/handlers/docs/docs.go @@ -146,6 +146,103 @@ const docTemplate = `{ } } }, + "/api/buildoor/action-plan/rules": { + "get": { + "description": "Returns the recurring slot rules, id-ascending (which is also\ntheir match order). A rule applies its categories to every slot\nwhose index within the epoch matches, unless the slot carries\nan explicit plan.", + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Get recurring action plan rules", + "operationId": "getActionPlanRules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "description": "Atomically replaces the whole recurring rule set (every rule\nvalidates or nothing changes). Each rule matches slot indices\nwithin an epoch, optionally bounded to an epoch window, and\ncarries the same categories as a slot plan — e.g. win slot 31\nof every epoch and withhold its payload. Already frozen slots\nkeep the plan they froze with. Requires authentication.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Replace the recurring action plan rules", + "operationId": "updateActionPlanRules", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Full rule set", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesRequest" + } + } + ], + "responses": { + "200": { + "description": "Authoritative rule set", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/buildoor/action-plan/test-transform": { "post": { "description": "Runs an operator jq expression against a sample payload / bid /\nenvelope (a captured artifact when sample_slot is given and\navailable, otherwise a zero-value template of the object) and\nreturns the transformed JSON, so expressions can be built and\ntested live before being saved to a slot plan. Bid and envelope\ntransforms operate on the MESSAGE, matching production.", @@ -1531,6 +1628,10 @@ const docTemplate = `{ "from_slot": { "type": "integer" }, + "ignore_rules": { + "description": "IgnoreRules is two-state on purpose: absent = unchanged, true/false =\nset the slot's recurring-rule opt-out.", + "type": "boolean" + }, "reveal": { "type": "array", "items": { @@ -1730,9 +1831,17 @@ const docTemplate = `{ "builder_api": { "$ref": "#/definitions/action_plan.BuilderAPIPlan" }, + "ignore_rules": { + "description": "IgnoreRules opts the slot out of every recurring rule, so it runs on the\nplain global baseline. Without it an empty plan is dropped, which would\nhand the slot straight back to the matching rule.", + "type": "boolean" + }, "reveal": { "$ref": "#/definitions/action_plan.RevealPlan" }, + "rule_id": { + "description": "RuleID marks a plan synthesized from a recurring rule. Synthesized plans\nare never stored — the field is empty on every persisted plan.", + "type": "string" + }, "slot": { "type": "integer" }, @@ -1747,6 +1856,55 @@ const docTemplate = `{ } } }, + "action_plan.SlotRule": { + "type": "object", + "properties": { + "bid": { + "$ref": "#/definitions/action_plan.BidPlan" + }, + "build": { + "$ref": "#/definitions/action_plan.BuildPlan" + }, + "builder_api": { + "$ref": "#/definitions/action_plan.BuilderAPIPlan" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_epoch": { + "description": "FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is\nunbounded on that side.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "reveal": { + "$ref": "#/definitions/action_plan.RevealPlan" + }, + "slots_in_epoch": { + "description": "SlotsInEpoch are the matched slot indices within an epoch (0-based, so\n31 is the last slot of a 32-slot epoch).", + "type": "array", + "items": { + "type": "integer" + } + }, + "to_epoch": { + "type": "integer" + }, + "transforms": { + "$ref": "#/definitions/action_plan.TransformPlan" + }, + "updated_at": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + } + }, "action_plan.TransformPlan": { "type": "object", "properties": { @@ -1778,6 +1936,28 @@ const docTemplate = `{ } } }, + "api.ActionPlanRulesRequest": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, + "api.ActionPlanRulesResponse": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, "api.AuditLogResponse": { "type": "object", "properties": { diff --git a/pkg/webui/handlers/docs/swagger.json b/pkg/webui/handlers/docs/swagger.json index d673c28..5b1ed56 100644 --- a/pkg/webui/handlers/docs/swagger.json +++ b/pkg/webui/handlers/docs/swagger.json @@ -135,6 +135,103 @@ } } }, + "/api/buildoor/action-plan/rules": { + "get": { + "description": "Returns the recurring slot rules, id-ascending (which is also\ntheir match order). A rule applies its categories to every slot\nwhose index within the epoch matches, unless the slot carries\nan explicit plan.", + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Get recurring action plan rules", + "operationId": "getActionPlanRules", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "description": "Atomically replaces the whole recurring rule set (every rule\nvalidates or nothing changes). Each rule matches slot indices\nwithin an epoch, optionally bounded to an epoch window, and\ncarries the same categories as a slot plan — e.g. win slot 31\nof every epoch and withhold its payload. Already frozen slots\nkeep the plan they froze with. Requires authentication.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ActionPlan" + ], + "summary": "Replace the recurring action plan rules", + "operationId": "updateActionPlanRules", + "parameters": [ + { + "type": "string", + "description": "Bearer token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "description": "Full rule set", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesRequest" + } + } + ], + "responses": { + "200": { + "description": "Authoritative rule set", + "schema": { + "$ref": "#/definitions/api.ActionPlanRulesResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "503": { + "description": "Plan service unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/buildoor/action-plan/test-transform": { "post": { "description": "Runs an operator jq expression against a sample payload / bid /\nenvelope (a captured artifact when sample_slot is given and\navailable, otherwise a zero-value template of the object) and\nreturns the transformed JSON, so expressions can be built and\ntested live before being saved to a slot plan. Bid and envelope\ntransforms operate on the MESSAGE, matching production.", @@ -1520,6 +1617,10 @@ "from_slot": { "type": "integer" }, + "ignore_rules": { + "description": "IgnoreRules is two-state on purpose: absent = unchanged, true/false =\nset the slot's recurring-rule opt-out.", + "type": "boolean" + }, "reveal": { "type": "array", "items": { @@ -1719,9 +1820,17 @@ "builder_api": { "$ref": "#/definitions/action_plan.BuilderAPIPlan" }, + "ignore_rules": { + "description": "IgnoreRules opts the slot out of every recurring rule, so it runs on the\nplain global baseline. Without it an empty plan is dropped, which would\nhand the slot straight back to the matching rule.", + "type": "boolean" + }, "reveal": { "$ref": "#/definitions/action_plan.RevealPlan" }, + "rule_id": { + "description": "RuleID marks a plan synthesized from a recurring rule. Synthesized plans\nare never stored — the field is empty on every persisted plan.", + "type": "string" + }, "slot": { "type": "integer" }, @@ -1736,6 +1845,55 @@ } } }, + "action_plan.SlotRule": { + "type": "object", + "properties": { + "bid": { + "$ref": "#/definitions/action_plan.BidPlan" + }, + "build": { + "$ref": "#/definitions/action_plan.BuildPlan" + }, + "builder_api": { + "$ref": "#/definitions/action_plan.BuilderAPIPlan" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "from_epoch": { + "description": "FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is\nunbounded on that side.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "reveal": { + "$ref": "#/definitions/action_plan.RevealPlan" + }, + "slots_in_epoch": { + "description": "SlotsInEpoch are the matched slot indices within an epoch (0-based, so\n31 is the last slot of a 32-slot epoch).", + "type": "array", + "items": { + "type": "integer" + } + }, + "to_epoch": { + "type": "integer" + }, + "transforms": { + "$ref": "#/definitions/action_plan.TransformPlan" + }, + "updated_at": { + "type": "string" + }, + "updated_by": { + "type": "string" + } + } + }, "action_plan.TransformPlan": { "type": "object", "properties": { @@ -1767,6 +1925,28 @@ } } }, + "api.ActionPlanRulesRequest": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, + "api.ActionPlanRulesResponse": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/definitions/action_plan.SlotRule" + } + } + } + }, "api.AuditLogResponse": { "type": "object", "properties": { diff --git a/pkg/webui/handlers/docs/swagger.yaml b/pkg/webui/handlers/docs/swagger.yaml index 8e76371..43d7804 100644 --- a/pkg/webui/handlers/docs/swagger.yaml +++ b/pkg/webui/handlers/docs/swagger.yaml @@ -126,6 +126,11 @@ definitions: type: boolean from_slot: type: integer + ignore_rules: + description: |- + IgnoreRules is two-state on purpose: absent = unchanged, true/false = + set the slot's recurring-rule opt-out. + type: boolean reveal: items: type: integer @@ -298,8 +303,19 @@ definitions: $ref: '#/definitions/action_plan.BuildPlan' builder_api: $ref: '#/definitions/action_plan.BuilderAPIPlan' + ignore_rules: + description: |- + IgnoreRules opts the slot out of every recurring rule, so it runs on the + plain global baseline. Without it an empty plan is dropped, which would + hand the slot straight back to the matching rule. + type: boolean reveal: $ref: '#/definitions/action_plan.RevealPlan' + rule_id: + description: |- + RuleID marks a plan synthesized from a recurring rule. Synthesized plans + are never stored — the field is empty on every persisted plan. + type: string slot: type: integer transforms: @@ -309,6 +325,43 @@ definitions: updated_by: type: string type: object + action_plan.SlotRule: + properties: + bid: + $ref: '#/definitions/action_plan.BidPlan' + build: + $ref: '#/definitions/action_plan.BuildPlan' + builder_api: + $ref: '#/definitions/action_plan.BuilderAPIPlan' + description: + type: string + enabled: + type: boolean + from_epoch: + description: |- + FromEpoch / ToEpoch bound the rule to an inclusive epoch window; nil is + unbounded on that side. + type: integer + id: + type: string + reveal: + $ref: '#/definitions/action_plan.RevealPlan' + slots_in_epoch: + description: |- + SlotsInEpoch are the matched slot indices within an epoch (0-based, so + 31 is the last slot of a 32-slot epoch). + items: + type: integer + type: array + to_epoch: + type: integer + transforms: + $ref: '#/definitions/action_plan.TransformPlan' + updated_at: + type: string + updated_by: + type: string + type: object action_plan.TransformPlan: properties: bid: @@ -329,6 +382,20 @@ definitions: $ref: '#/definitions/action_plan.SlotPlan' type: array type: object + api.ActionPlanRulesRequest: + properties: + rules: + items: + $ref: '#/definitions/action_plan.SlotRule' + type: array + type: object + api.ActionPlanRulesResponse: + properties: + rules: + items: + $ref: '#/definitions/action_plan.SlotRule' + type: array + type: object api.AuditLogResponse: properties: entries: @@ -1167,6 +1234,81 @@ paths: summary: Update per-slot action plans tags: - ActionPlan + /api/buildoor/action-plan/rules: + get: + description: |- + Returns the recurring slot rules, id-ascending (which is also + their match order). A rule applies its categories to every slot + whose index within the epoch matches, unless the slot carries + an explicit plan. + operationId: getActionPlanRules + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.ActionPlanRulesResponse' + "503": + description: Plan service unavailable + schema: + additionalProperties: + type: string + type: object + summary: Get recurring action plan rules + tags: + - ActionPlan + post: + consumes: + - application/json + description: |- + Atomically replaces the whole recurring rule set (every rule + validates or nothing changes). Each rule matches slot indices + within an epoch, optionally bounded to an epoch window, and + carries the same categories as a slot plan — e.g. win slot 31 + of every epoch and withhold its payload. Already frozen slots + keep the plan they froze with. Requires authentication. + operationId: updateActionPlanRules + parameters: + - description: Bearer token + in: header + name: Authorization + required: true + type: string + - description: Full rule set + in: body + name: request + required: true + schema: + $ref: '#/definitions/api.ActionPlanRulesRequest' + produces: + - application/json + responses: + "200": + description: Authoritative rule set + schema: + $ref: '#/definitions/api.ActionPlanRulesResponse' + "400": + description: Validation error + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "503": + description: Plan service unavailable + schema: + additionalProperties: + type: string + type: object + summary: Replace the recurring action plan rules + tags: + - ActionPlan /api/buildoor/action-plan/test-transform: post: consumes: diff --git a/pkg/webui/src/components/actionplan/ActionPlanView.tsx b/pkg/webui/src/components/actionplan/ActionPlanView.tsx index da96ca9..2410c8f 100644 --- a/pkg/webui/src/components/actionplan/ActionPlanView.tsx +++ b/pkg/webui/src/components/actionplan/ActionPlanView.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useRef, useState } from 'react'; import { useEventStream } from '../../hooks/useEventStream'; import { useActionPlan } from '../../hooks/useActionPlan'; +import { useActionPlanRules } from '../../hooks/useActionPlanRules'; import { useAuthContext } from '../../context/AuthContext'; import { ActionPlanGrid } from './ActionPlanGrid'; import { BulkEditBar } from './BulkEditBar'; +import { RulesPanel } from './RulesPanel'; import { SlotEditModal, type ModalTarget } from './SlotEditModal'; // Default epoch window around the current epoch. @@ -43,6 +45,12 @@ export const ActionPlanView: React.FC = () => { const maxSlot = window ? (window.end + 1) * slotsPerEpoch - 1 : -1; const { plans, results, loading, error, refetch, applyUpdates } = useActionPlan(minSlot, maxSlot); + const { + rules, + loading: rulesLoading, + error: rulesError, + saveRules, + } = useActionPlanRules(); // Selection (future slots only) + range anchor for shift-click. const [selection, setSelection] = useState>(new Set()); @@ -158,6 +166,15 @@ export const ActionPlanView: React.FC = () => {
{error &&
{error}
} + + { P reorg parent jq transform B disabled + B from a recurring rule Bid (left dot): included bid, not included diff --git a/pkg/webui/src/components/actionplan/RuleEditModal.tsx b/pkg/webui/src/components/actionplan/RuleEditModal.tsx new file mode 100644 index 0000000..8135290 --- /dev/null +++ b/pkg/webui/src/components/actionplan/RuleEditModal.tsx @@ -0,0 +1,411 @@ +import React, { useState } from 'react'; +import type { BidPlan, BuilderAPIPlan, RevealPlan, SlotRule } from '../../types'; +import { TransformEditor, type TransformState } from './TransformEditor'; +import { + BID_FIELDS, + BUILDER_API_FIELDS, + REVEAL_FIELDS, + BuildForm, + CategoryForm, + initCategoryState, + resolveCategory, + type BuildFlagMode, + type CategoryFormState, +} from './planForms'; + +// Ready-made scenarios; picking one fills the form, which stays editable. +interface Preset { + id: string; + label: string; + hint: string; + apply: (slotsPerEpoch: number) => Partial & { slots_in_epoch: number[] }; +} + +const PRESETS: Preset[] = [ + { + id: 'win-and-withhold', + label: 'Win the last slot of every epoch, withhold the payload', + hint: 'Outbids the other builders with a large subsidy on top of the block value, then never reveals the envelope — the block exists, its execution payload does not.', + apply: (slotsPerEpoch) => ({ + id: 'slot-last-withhold', + description: 'win the last slot of every epoch and withhold the payload', + slots_in_epoch: [Math.max(0, slotsPerEpoch - 1)], + // A subsidy adds to the block value, so it outbids competitors whatever + // the block is worth — an absolute bid_value_gwei would have to be + // re-tuned per network and silently loses when it guesses too low. + // Raise it if another builder still wins the slot: the grid's bid dot + // shows "bid, not included" for exactly that case. + bid: { mode: 'custom', bid_subsidy: 200000000 }, + reveal: { mode: 'disabled' }, + }), + }, + { + id: 'withhold-only', + label: 'Withhold the payload of the last slot of every epoch', + hint: 'Leaves bidding on the global config and only suppresses the reveal when the slot is won.', + apply: (slotsPerEpoch) => ({ + id: 'slot-last-no-reveal', + description: 'withhold the payload of the last slot of every epoch', + slots_in_epoch: [Math.max(0, slotsPerEpoch - 1)], + reveal: { mode: 'disabled' }, + }), + }, +]; + +// parseSlotList reads a "31" / "0,15,31" / "28-31" slot-index list. +function parseSlotList(raw: string, slotsPerEpoch: number): { slots: number[]; error: string | null } { + const slots: number[] = []; + + for (const part of raw.split(',')) { + const token = part.trim(); + if (token === '') continue; + + const range = token.match(/^(\d+)\s*-\s*(\d+)$/); + const bounds = range ? [Number(range[1]), Number(range[2])] : [Number(token), Number(token)]; + + if (!bounds.every((n) => Number.isInteger(n) && n >= 0)) { + return { slots, error: `"${token}" is not a slot index or range` }; + } + if (bounds[1] < bounds[0]) { + return { slots, error: `invalid range "${token}"` }; + } + if (slotsPerEpoch > 0 && bounds[1] >= slotsPerEpoch) { + return { slots, error: `slot index ${bounds[1]} is out of range (0–${slotsPerEpoch - 1})` }; + } + + for (let s = bounds[0]; s <= bounds[1]; s++) { + if (!slots.includes(s)) slots.push(s); + } + } + + if (slots.length === 0) return { slots, error: 'at least one slot index is required' }; + + return { slots: slots.sort((a, b) => a - b), error: null }; +} + +function parseOptionalEpoch(raw: string, label: string): { value?: number; error: string | null } { + const token = raw.trim(); + if (token === '') return { error: null }; + + const value = Number(token); + if (!Number.isInteger(value) || value < 0) { + return { error: `${label} must be a non-negative integer` }; + } + + return { value, error: null }; +} + +interface RuleEditModalProps { + /** The rule being edited, or null for a new one. */ + rule: SlotRule | null; + /** Ids already in use — a new rule must not silently replace one of them. */ + existingIds: string[]; + slotsPerEpoch: number; + canEdit: boolean; + saving: boolean; + error: string | null; + onSave: (rule: SlotRule) => void; + onClose: () => void; +} + +export const RuleEditModal: React.FC = ({ + rule, + existingIds, + slotsPerEpoch, + canEdit, + saving, + error, + onSave, + onClose, +}) => { + const [id, setId] = useState(rule?.id ?? ''); + const [description, setDescription] = useState(rule?.description ?? ''); + const [enabled, setEnabled] = useState(rule?.enabled ?? true); + const [slotList, setSlotList] = useState((rule?.slots_in_epoch ?? []).join(',')); + const [fromEpoch, setFromEpoch] = useState(rule?.from_epoch?.toString() ?? ''); + const [toEpoch, setToEpoch] = useState(rule?.to_epoch?.toString() ?? ''); + + const [bidState, setBidState] = useState(() => + initCategoryState(rule?.bid, BID_FIELDS, false) + ); + const [apiState, setApiState] = useState(() => + initCategoryState(rule?.builder_api, BUILDER_API_FIELDS, false) + ); + const [revealState, setRevealState] = useState(() => + initCategoryState(rule?.reveal, REVEAL_FIELDS, false) + ); + const [buildReorg, setBuildReorg] = useState( + rule?.build?.reorg_parent_payload ? 'on' : 'off' + ); + const [transforms, setTransforms] = useState({ + payload: rule?.transforms?.payload ?? '', + bid: rule?.transforms?.bid ?? '', + envelope: rule?.transforms?.envelope ?? '', + }); + + const [formError, setFormError] = useState(null); + + const applyPreset = (preset: Preset) => { + const filled = preset.apply(slotsPerEpoch); + setId(filled.id ?? ''); + setDescription(filled.description ?? ''); + setEnabled(true); + setSlotList(filled.slots_in_epoch.join(',')); + setFromEpoch(''); + setToEpoch(''); + setBidState(initCategoryState(filled.bid, BID_FIELDS, false)); + setApiState(initCategoryState(filled.builder_api, BUILDER_API_FIELDS, false)); + setRevealState(initCategoryState(filled.reveal, REVEAL_FIELDS, false)); + setBuildReorg(filled.build?.reorg_parent_payload ? 'on' : 'off'); + setTransforms({ payload: '', bid: '', envelope: '' }); + setFormError(null); + }; + + // Rules are authored wholesale, so a category resolves to the object itself + // (custom/disabled) or to "absent" for inherit. + const resolveRuleCategory = ( + name: string, + fields: typeof BID_FIELDS, + state: CategoryFormState, + withIgnorePrefs: boolean + ): { value?: T; error?: string } => { + const outcome = resolveCategory(name, fields, state, undefined, false, withIgnorePrefs); + if (outcome.kind === 'error') return { error: outcome.error }; + if (outcome.kind === 'replace') return { value: outcome.obj as T }; + + return {}; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + // Saving upserts by id, so a new rule reusing one would replace it without + // a trace — the server only sees the collapsed set and cannot catch it. + if (!rule && existingIds.includes(id.trim())) { + setFormError(`A rule with id "${id.trim()}" already exists — edit it or pick another id.`); + return; + } + + const { slots, error: slotError } = parseSlotList(slotList, slotsPerEpoch); + if (slotError) { + setFormError(slotError); + return; + } + + const from = parseOptionalEpoch(fromEpoch, 'From epoch'); + const to = parseOptionalEpoch(toEpoch, 'To epoch'); + if (from.error || to.error) { + setFormError(from.error || to.error); + return; + } + + const bid = resolveRuleCategory('bid', BID_FIELDS, bidState, true); + const api = resolveRuleCategory('builder_api', BUILDER_API_FIELDS, apiState, false); + const reveal = resolveRuleCategory('reveal', REVEAL_FIELDS, revealState, false); + + const categoryError = bid.error || api.error || reveal.error; + if (categoryError) { + setFormError(categoryError); + return; + } + + const next: SlotRule = { + id: id.trim(), + enabled, + slots_in_epoch: slots, + ...(description.trim() ? { description: description.trim() } : {}), + ...(from.value !== undefined ? { from_epoch: from.value } : {}), + ...(to.value !== undefined ? { to_epoch: to.value } : {}), + ...(bid.value ? { bid: bid.value } : {}), + ...(api.value ? { builder_api: api.value } : {}), + ...(reveal.value ? { reveal: reveal.value } : {}), + ...(buildReorg === 'on' ? { build: { reorg_parent_payload: true } } : {}), + }; + + const transformPlan = { + ...(transforms.payload.trim() ? { payload: transforms.payload.trim() } : {}), + ...(transforms.bid.trim() ? { bid: transforms.bid.trim() } : {}), + ...(transforms.envelope.trim() ? { envelope: transforms.envelope.trim() } : {}), + }; + if (Object.keys(transformPlan).length > 0) { + next.transforms = transformPlan; + } + + onSave(next); + }; + + const disabled = !canEdit || saving; + + return ( + <> +
+
+
+
+
+ {rule ? `Recurring Rule — ${rule.id}` : 'New Recurring Rule'} +
+ +
+ +
+ {!rule && ( +
+
Scenario presets
+ {PRESETS.map((preset) => ( + + ))} +
+ )} + +
+
+ + setId(e.target.value)} + /> +
+ Lowercase slug; identifies the rule and decides match order when several apply. +
+
+
+ + 0 ? String(slotsPerEpoch - 1) : '31'} + value={slotList} + disabled={disabled} + onChange={(e) => setSlotList(e.target.value)} + /> +
+ 0-based indices within the epoch: 31, 0,15,31 or{' '} + 28-31. +
+
+
+ + setDescription(e.target.value)} + /> +
+
+ + setFromEpoch(e.target.value)} + /> +
+
+ + setToEpoch(e.target.value)} + /> +
+
+
+ setEnabled(e.target.checked)} + /> + +
+
+
+ + + + + + + +
+ The rule applies to every matching slot that has no explicit plan of its own. Slots + that already froze keep the plan they froze with. +
+ + {(formError || error) && ( +
{formError || error}
+ )} +
+ +
+ + +
+
+
+
+
+ + ); +}; diff --git a/pkg/webui/src/components/actionplan/RulesPanel.tsx b/pkg/webui/src/components/actionplan/RulesPanel.tsx new file mode 100644 index 0000000..636e13f --- /dev/null +++ b/pkg/webui/src/components/actionplan/RulesPanel.tsx @@ -0,0 +1,244 @@ +import React, { useState } from 'react'; +import type { SlotRule } from '../../types'; +import type { SaveRulesResult } from '../../hooks/useActionPlanRules'; +import { RuleEditModal } from './RuleEditModal'; + +// summarizeCategories renders the same chips the grid uses, so a rule reads +// like the cells it will produce. +const CategoryChips: React.FC<{ rule: SlotRule }> = ({ rule }) => { + const chip = (label: string, mode?: 'custom' | 'disabled', title?: string) => ( + + {label} + + ); + + const t = rule.transforms; + const hasTransform = !!(t && (t.payload || t.bid || t.envelope)); + + return ( + + {rule.bid && chip('B', rule.bid.mode, `bid: ${rule.bid.mode}`)} + {rule.builder_api && chip('A', rule.builder_api.mode, `builder api: ${rule.builder_api.mode}`)} + {rule.reveal && chip('R', rule.reveal.mode, `reveal: ${rule.reveal.mode}`)} + {rule.build?.reorg_parent_payload && ( + + P + + )} + {hasTransform && ( + + jq + + )} + + ); +}; + +const epochWindow = (rule: SlotRule): string => { + if (rule.from_epoch === undefined && rule.to_epoch === undefined) return 'every epoch'; + if (rule.to_epoch === undefined) return `from epoch ${rule.from_epoch}`; + if (rule.from_epoch === undefined) return `until epoch ${rule.to_epoch}`; + + return `epochs ${rule.from_epoch}–${rule.to_epoch}`; +}; + +interface RulesPanelProps { + rules: SlotRule[]; + slotsPerEpoch: number; + canEdit: boolean; + loading: boolean; + error: string | null; + saveRules: (rules: SlotRule[]) => Promise; +} + +/** + * Lists the recurring rules and edits them through the atomic replace-all + * endpoint: every mutation sends the full authoritative set. + */ +export const RulesPanel: React.FC = ({ + rules, + slotsPerEpoch, + canEdit, + loading, + error, + saveRules, +}) => { + const [expanded, setExpanded] = useState(false); + const [editing, setEditing] = useState<{ rule: SlotRule | null } | null>(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const commit = async (next: SlotRule[], onDone?: () => void) => { + setSaving(true); + setSaveError(null); + + const result = await saveRules(next); + + setSaving(false); + + if (!result.ok) { + setSaveError(result.error || 'Failed to save rules'); + return; + } + + onDone?.(); + }; + + // Upsert by id. The modal rejects a new rule reusing an existing id, so this + // only ever replaces the rule the operator opened for editing. + const handleSave = (rule: SlotRule) => { + const next = rules.some((r) => r.id === rule.id) + ? rules.map((r) => (r.id === rule.id ? rule : r)) + : [...rules, rule]; + + commit(next, () => setEditing(null)); + }; + + const handleToggle = (rule: SlotRule) => + commit(rules.map((r) => (r.id === rule.id ? { ...r, enabled: !r.enabled } : r))); + + const handleDelete = (rule: SlotRule) => + commit(rules.filter((r) => r.id !== rule.id)); + + const activeCount = rules.filter((r) => r.enabled).length; + + return ( +
+
+ + {loading && } + {expanded && canEdit && ( + + )} +
+ + {expanded && ( +
+ {(error || saveError) && ( +
{error || saveError}
+ )} + + {rules.length === 0 ? ( +
+ No recurring rules. A rule repeats a plan on the same slot index of every epoch — for + example win slot {Math.max(0, slotsPerEpoch - 1)} and withhold its payload for a whole + devnet run. Explicit per-slot plans always win over rules. +
+ ) : ( +
+ + + + + + + + + + + + {rules.map((rule) => ( + + + + + + + + ))} + +
RuleSlots in epochWindowPlanActions
+
+ + {rule.enabled ? 'on' : 'off'} + +
+
{rule.id}
+ {rule.description && ( +
{rule.description}
+ )} +
+
+
{rule.slots_in_epoch.join(', ')}{epochWindow(rule)} + + +
+ + + +
+
+
+ )} +
+ )} + + {editing && ( + r.id)} + slotsPerEpoch={slotsPerEpoch} + canEdit={canEdit} + saving={saving} + error={saveError} + onSave={handleSave} + onClose={() => setEditing(null)} + /> + )} +
+ ); +}; diff --git a/pkg/webui/src/components/actionplan/SlotCell.tsx b/pkg/webui/src/components/actionplan/SlotCell.tsx index ddc327b..440bc40 100644 --- a/pkg/webui/src/components/actionplan/SlotCell.tsx +++ b/pkg/webui/src/components/actionplan/SlotCell.tsx @@ -85,8 +85,10 @@ interface SlotCellProps { onCellClick: (slot: number, shiftKey: boolean) => void; } -const chipClass = (mode?: 'custom' | 'disabled'): string => - mode === 'disabled' ? 'ap-chip ap-chip-disabled' : 'ap-chip ap-chip-custom'; +// Rule-derived plans are drawn dashed: they are not stored for the slot and a +// rule change (or an explicit plan) can still replace them. +const chipClass = (mode: 'custom' | 'disabled' | undefined, fromRule: boolean): string => + `ap-chip ${mode === 'disabled' ? 'ap-chip-disabled' : 'ap-chip-custom'}${fromRule ? ' ap-chip-rule' : ''}`; const SlotCellInner: React.FC = ({ slot, @@ -103,7 +105,11 @@ const SlotCellInner: React.FC = ({ const t = plan?.transforms; const hasTransform = !!(t && (t.payload || t.bid || t.envelope)); + const fromRule = !!plan?.rule_id; + const titleParts = [`Slot ${slot}`]; + if (fromRule) titleParts.push(`rule: ${plan?.rule_id}`); + if (plan?.ignore_rules) titleParts.push('recurring rules ignored'); if (plan?.bid) titleParts.push(`bid: ${plan.bid.mode}`); if (plan?.builder_api) titleParts.push(`builder api: ${plan.builder_api.mode}`); if (plan?.reveal) titleParts.push(`reveal: ${plan.reveal.mode}`); @@ -127,9 +133,9 @@ const SlotCellInner: React.FC = ({ onClick={(e) => onCellClick(slot, e.shiftKey)} > - {plan?.bid && B} - {plan?.builder_api && A} - {plan?.reveal && R} + {plan?.bid && B} + {plan?.builder_api && A} + {plan?.reveal && R} {reorgParent && P} {hasTransform && jq} diff --git a/pkg/webui/src/components/actionplan/SlotEditModal.tsx b/pkg/webui/src/components/actionplan/SlotEditModal.tsx index 27591f9..4587c1c 100644 --- a/pkg/webui/src/components/actionplan/SlotEditModal.tsx +++ b/pkg/webui/src/components/actionplan/SlotEditModal.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import type { - ActionMode, FrozenPlan, PlanUpdate, SlotPlan, @@ -8,6 +7,18 @@ import type { } from '../../types'; import type { ApplyUpdatesResult } from '../../hooks/useActionPlan'; import { TransformEditor, type TransformState } from './TransformEditor'; +import { + BID_FIELDS, + BUILDER_API_FIELDS, + REVEAL_FIELDS, + BuildForm, + CategoryForm, + initCategoryState, + resolveCategory, + type BuildFlagMode, + type CategoryFormState, + type CategoryOutcome, +} from './planForms'; // Target of the modal: either an explicit slot list (single slot or grid // selection) or an inclusive from/to range (may extend beyond the grid). @@ -130,312 +141,6 @@ const ArtifactLinks: React.FC<{ ); -// --------------------------------------------------------------------------- -// Edit form model -// --------------------------------------------------------------------------- - -// 'unchanged' exists only in bulk mode (leave targeted slots as they are); -// 'inherit' clears the category back to the global baseline. -type FormMode = 'unchanged' | 'inherit' | 'custom' | 'disabled'; - -interface FieldDef { - key: string; - label: string; - unit: 'ms' | 'gwei' | '%' | ''; - // Enum fields render a select instead of a number input; the raw string - // value is sent as the override. - options?: string[]; -} - -const BID_FIELDS: FieldDef[] = [ - { key: 'bid_start_time', label: 'Bid Start (rel. slot)', unit: 'ms' }, - { key: 'bid_end_time', label: 'Bid End (rel. slot)', unit: 'ms' }, - { key: 'bid_min_amount', label: 'Bid Min', unit: 'gwei' }, - { key: 'bid_increase', label: 'Bid Increase', unit: 'gwei' }, - { key: 'bid_interval', label: 'Bid Interval', unit: 'ms' }, - { key: 'bid_subsidy', label: 'Bid Subsidy', unit: 'gwei' }, - { key: 'bid_value_gwei', label: 'Bid Value Override', unit: 'gwei' }, -]; - -const BUILDER_API_FIELDS: FieldDef[] = [ - { key: 'value_subsidy_gwei', label: 'Value Subsidy', unit: 'gwei' }, - { key: 'total_value_override_gwei', label: 'Total Value Override', unit: 'gwei' }, - { key: 'response_delay_ms', label: 'Response Delay', unit: 'ms' }, -]; - -const REVEAL_FIELDS: FieldDef[] = [ - { key: 'reveal_time_ms', label: 'Reveal Time (rel. slot)', unit: 'ms' }, - { - key: 'gate_mode', label: 'Gate Mode', unit: '', - options: ['time', 'vote', 'vote_or_time', 'vote_and_time'], - }, - { key: 'vote_threshold_pct', label: 'Vote Threshold', unit: '%' }, - { - key: 'broadcast_validation', label: 'Broadcast Validation', unit: '', - options: ['gossip', 'consensus', 'consensus_and_equivocation'], - }, -]; - -interface CategoryFormState { - mode: FormMode; - fields: Record; // raw input values; empty = inherit - ignoreMissingPrefs: boolean; -} - -function initCategoryState( - category: { mode: ActionMode } | undefined, - defs: FieldDef[], - bulk: boolean -): CategoryFormState { - if (bulk || !category) { - return { mode: bulk ? 'unchanged' : 'inherit', fields: {}, ignoreMissingPrefs: false }; - } - - const raw = category as unknown as Record; - const fields: Record = {}; - for (const def of defs) { - const value = raw[def.key]; - if (typeof value === 'number' || typeof value === 'string') { - fields[def.key] = String(value); - } - } - - return { - mode: category.mode, - fields, - ignoreMissingPrefs: raw['ignore_missing_prefs'] === true, - }; -} - -function parseCategoryFields( - name: string, - defs: FieldDef[], - state: CategoryFormState -): { values: Record; error: string | null } { - const values: Record = {}; - - for (const def of defs) { - const raw = (state.fields[def.key] ?? '').trim(); - if (raw === '') continue; - - if (def.options) { - if (!def.options.includes(raw)) { - return { values, error: `${name}: ${def.label} must be one of ${def.options.join(', ')}` }; - } - values[def.key] = raw; - continue; - } - - const num = Number(raw); - if (!Number.isFinite(num) || !Number.isInteger(num)) { - return { values, error: `${name}: ${def.label} must be an integer (${def.unit})` }; - } - values[def.key] = num; - } - - return { values, error: null }; -} - -type CategoryOutcome = - | { kind: 'none' } - | { kind: 'clear' } - | { kind: 'replace'; obj: Record } - | { kind: 'set'; paths: Record } - | { kind: 'error'; error: string }; - -// resolveCategory turns one category form into its PlanUpdate contribution. -// Single-slot edits of an existing custom category use fine-grained `set` -// paths so unchanged sibling fields are never clobbered; mode switches send -// the full category object. -function resolveCategory( - name: string, - defs: FieldDef[], - state: CategoryFormState, - initial: Record | undefined, - single: boolean, - withIgnorePrefs: boolean -): CategoryOutcome { - switch (state.mode) { - case 'unchanged': - return { kind: 'none' }; - - case 'inherit': - if (single && !initial) return { kind: 'none' }; - return { kind: 'clear' }; - - case 'disabled': - if (single && initial && initial['mode'] === 'disabled') return { kind: 'none' }; - return { kind: 'replace', obj: { mode: 'disabled' } }; - - case 'custom': { - const { values, error } = parseCategoryFields(name, defs, state); - if (error) return { kind: 'error', error }; - - if (single && initial && initial['mode'] === 'custom') { - const paths: Record = {}; - - for (const def of defs) { - const rawOld = initial[def.key]; - const oldValue = typeof rawOld === 'number' || typeof rawOld === 'string' - ? (rawOld as number | string) - : undefined; - const newValue = values[def.key]; - - if (newValue === undefined && oldValue !== undefined) { - paths[`${name}.${def.key}`] = null; // clear one override - } else if (newValue !== undefined && newValue !== oldValue) { - paths[`${name}.${def.key}`] = newValue; - } - } - - if (withIgnorePrefs) { - const oldFlag = initial['ignore_missing_prefs'] === true; - if (state.ignoreMissingPrefs !== oldFlag) { - paths[`${name}.ignore_missing_prefs`] = state.ignoreMissingPrefs; - } - } - - if (Object.keys(paths).length === 0) return { kind: 'none' }; - return { kind: 'set', paths }; - } - - const obj: Record = { mode: 'custom', ...values }; - if (withIgnorePrefs && state.ignoreMissingPrefs) { - obj['ignore_missing_prefs'] = true; - } - return { kind: 'replace', obj }; - } - } -} - -// --------------------------------------------------------------------------- -// Category form section -// --------------------------------------------------------------------------- - -const CategoryForm: React.FC<{ - title: string; - bulk: boolean; - state: CategoryFormState; - fields: FieldDef[]; - disabled: boolean; - showIgnorePrefs?: boolean; - onChange: (next: CategoryFormState) => void; -}> = ({ title, bulk, state, fields, disabled, showIgnorePrefs, onChange }) => ( -
-
-
{title}
- -
- - {state.mode === 'custom' && ( -
- {fields.map((def) => ( -
- - {def.options ? ( - - ) : ( -
- - onChange({ ...state, fields: { ...state.fields, [def.key]: e.target.value } }) - } - /> - {def.unit} -
- )} -
- ))} - {showIgnorePrefs && ( -
-
- onChange({ ...state, ignoreMissingPrefs: e.target.checked })} - /> - -
-
- )} -
- Empty fields inherit the global config. Timing values are signed ms relative to slot start. -
-
- )} -
-); - -// --------------------------------------------------------------------------- -// Build category (modeless single flag) -// --------------------------------------------------------------------------- - -// 'unchanged' only exists in bulk mode; 'off' = normal parent, 'on' = reorg. -type BuildFlagMode = 'unchanged' | 'off' | 'on'; - -const BuildForm: React.FC<{ - bulk: boolean; - value: BuildFlagMode; - disabled: boolean; - onChange: (next: BuildFlagMode) => void; -}> = ({ bulk, value, disabled, onChange }) => ( -
-
-
Build
- -
- {value === 'on' && ( -
- Builds the payload on the grandparent (n-2) execution payload instead of the immediate - parent — parent hash, parent number and withdrawals come from the parent slot, everything - else from this slot. A deliberate parent-payload reorg attempt; rejected by mainnet - forkchoice, useful for testing. -
- )} -
-); - // --------------------------------------------------------------------------- // Read-only frozen plan + result views // --------------------------------------------------------------------------- @@ -980,30 +685,39 @@ export const SlotEditModal: React.FC = ({ const isPastSingle = isSingle && singleSlot <= currentSlot; const bulk = !isSingle; - const initialPlan = isSingle ? plans[singleSlot] : undefined; + // The effective plan seeds the form; only a STORED plan is a diff base. A + // rule-derived plan lives nowhere, so saving it writes full categories and + // detaches the slot from the rule. + const effectivePlan = isSingle ? plans[singleSlot] : undefined; + const rulePlan = effectivePlan?.rule_id ? effectivePlan : undefined; + const initialPlan = rulePlan ? undefined : effectivePlan; const [bidState, setBidState] = useState(() => - initCategoryState(initialPlan?.bid, BID_FIELDS, bulk) + initCategoryState(effectivePlan?.bid, BID_FIELDS, bulk) ); const [apiState, setApiState] = useState(() => - initCategoryState(initialPlan?.builder_api, BUILDER_API_FIELDS, bulk) + initCategoryState(effectivePlan?.builder_api, BUILDER_API_FIELDS, bulk) ); const [revealState, setRevealState] = useState(() => - initCategoryState(initialPlan?.reveal, REVEAL_FIELDS, bulk) + initCategoryState(effectivePlan?.reveal, REVEAL_FIELDS, bulk) ); // Build is a modeless single-flag category: tri-state in bulk, on/off single. const [buildReorg, setBuildReorg] = useState(() => - bulk ? 'unchanged' : initialPlan?.build?.reorg_parent_payload ? 'on' : 'off' + bulk ? 'unchanged' : effectivePlan?.build?.reorg_parent_payload ? 'on' : 'off' ); // Transforms (modeless jq expressions). In bulk mode we start empty and only // send the ones the operator fills in. const [transforms, setTransforms] = useState(() => ({ - payload: (!bulk && initialPlan?.transforms?.payload) || '', - bid: (!bulk && initialPlan?.transforms?.bid) || '', - envelope: (!bulk && initialPlan?.transforms?.envelope) || '', + payload: (!bulk && effectivePlan?.transforms?.payload) || '', + bid: (!bulk && effectivePlan?.transforms?.bid) || '', + envelope: (!bulk && effectivePlan?.transforms?.envelope) || '', })); + // Per-slot opt-out from every recurring rule. In bulk mode it is write-only + // (checked applies it to all targets, unchecked leaves each slot as it is). + const [ignoreRules, setIgnoreRules] = useState(initialPlan?.ignore_rules === true); + const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [conflictError, setConflictError] = useState(null); @@ -1112,6 +826,11 @@ export const SlotEditModal: React.FC = ({ } }); + if (bulk ? ignoreRules : ignoreRules !== (initialPlan?.ignore_rules === true)) { + update.ignore_rules = ignoreRules; + hasChange = true; + } + if (Object.keys(setPaths).length > 0) { update.set = setPaths; } @@ -1187,6 +906,14 @@ export const SlotEditModal: React.FC = ({ category as it is per slot; "inherit (clear)" removes it.
)} + {rulePlan && ( +
+ + This slot has no plan of its own — it follows recurring rule{' '} + {rulePlan.rule_id}, whose values are pre-filled below. Saving + stores them as an explicit plan for this slot, detaching it from the rule. +
+ )} = ({ disabled={formDisabled} onChange={setBuildReorg} /> +
+
Recurring rules
+
+ setIgnoreRules(e.target.checked)} + /> + +
+
+ Runs the slot on the plain global baseline even when a rule matches it. Any + category set above already overrides the rule on its own. +
+
; // raw input values; empty = inherit + ignoreMissingPrefs: boolean; +} + +export function initCategoryState( + category: { mode: ActionMode } | undefined, + defs: FieldDef[], + bulk: boolean +): CategoryFormState { + if (bulk || !category) { + return { mode: bulk ? 'unchanged' : 'inherit', fields: {}, ignoreMissingPrefs: false }; + } + + const raw = category as unknown as Record; + const fields: Record = {}; + for (const def of defs) { + const value = raw[def.key]; + if (typeof value === 'number' || typeof value === 'string') { + fields[def.key] = String(value); + } + } + + return { + mode: category.mode, + fields, + ignoreMissingPrefs: raw['ignore_missing_prefs'] === true, + }; +} + +export function parseCategoryFields( + name: string, + defs: FieldDef[], + state: CategoryFormState +): { values: Record; error: string | null } { + const values: Record = {}; + + for (const def of defs) { + const raw = (state.fields[def.key] ?? '').trim(); + if (raw === '') continue; + + if (def.options) { + if (!def.options.includes(raw)) { + return { values, error: `${name}: ${def.label} must be one of ${def.options.join(', ')}` }; + } + values[def.key] = raw; + continue; + } + + const num = Number(raw); + if (!Number.isFinite(num) || !Number.isInteger(num)) { + return { values, error: `${name}: ${def.label} must be an integer (${def.unit})` }; + } + values[def.key] = num; + } + + return { values, error: null }; +} + +export type CategoryOutcome = + | { kind: 'none' } + | { kind: 'clear' } + | { kind: 'replace'; obj: Record } + | { kind: 'set'; paths: Record } + | { kind: 'error'; error: string }; + +// resolveCategory turns one category form into its PlanUpdate contribution. +// Single-slot edits of an existing custom category use fine-grained `set` +// paths so unchanged sibling fields are never clobbered; mode switches send +// the full category object. +export function resolveCategory( + name: string, + defs: FieldDef[], + state: CategoryFormState, + initial: Record | undefined, + single: boolean, + withIgnorePrefs: boolean +): CategoryOutcome { + switch (state.mode) { + case 'unchanged': + return { kind: 'none' }; + + case 'inherit': + if (single && !initial) return { kind: 'none' }; + return { kind: 'clear' }; + + case 'disabled': + if (single && initial && initial['mode'] === 'disabled') return { kind: 'none' }; + return { kind: 'replace', obj: { mode: 'disabled' } }; + + case 'custom': { + const { values, error } = parseCategoryFields(name, defs, state); + if (error) return { kind: 'error', error }; + + if (single && initial && initial['mode'] === 'custom') { + const paths: Record = {}; + + for (const def of defs) { + const rawOld = initial[def.key]; + const oldValue = typeof rawOld === 'number' || typeof rawOld === 'string' + ? (rawOld as number | string) + : undefined; + const newValue = values[def.key]; + + if (newValue === undefined && oldValue !== undefined) { + paths[`${name}.${def.key}`] = null; // clear one override + } else if (newValue !== undefined && newValue !== oldValue) { + paths[`${name}.${def.key}`] = newValue; + } + } + + if (withIgnorePrefs) { + const oldFlag = initial['ignore_missing_prefs'] === true; + if (state.ignoreMissingPrefs !== oldFlag) { + paths[`${name}.ignore_missing_prefs`] = state.ignoreMissingPrefs; + } + } + + if (Object.keys(paths).length === 0) return { kind: 'none' }; + return { kind: 'set', paths }; + } + + const obj: Record = { mode: 'custom', ...values }; + if (withIgnorePrefs && state.ignoreMissingPrefs) { + obj['ignore_missing_prefs'] = true; + } + return { kind: 'replace', obj }; + } + } +} + +// --------------------------------------------------------------------------- +// Category form section +// --------------------------------------------------------------------------- + +export const CategoryForm: React.FC<{ + title: string; + bulk: boolean; + state: CategoryFormState; + fields: FieldDef[]; + disabled: boolean; + showIgnorePrefs?: boolean; + onChange: (next: CategoryFormState) => void; +}> = ({ title, bulk, state, fields, disabled, showIgnorePrefs, onChange }) => ( +
+
+
{title}
+ +
+ + {state.mode === 'custom' && ( +
+ {fields.map((def) => ( +
+ + {def.options ? ( + + ) : ( +
+ + onChange({ ...state, fields: { ...state.fields, [def.key]: e.target.value } }) + } + /> + {def.unit} +
+ )} +
+ ))} + {showIgnorePrefs && ( +
+
+ onChange({ ...state, ignoreMissingPrefs: e.target.checked })} + /> + +
+
+ )} +
+ Empty fields inherit the global config. Timing values are signed ms relative to slot start. +
+
+ )} +
+); + +// --------------------------------------------------------------------------- +// Build category (modeless single flag) +// --------------------------------------------------------------------------- + +// 'unchanged' only exists in bulk mode; 'off' = normal parent, 'on' = reorg. +export type BuildFlagMode = 'unchanged' | 'off' | 'on'; + +export const BuildForm: React.FC<{ + bulk: boolean; + value: BuildFlagMode; + disabled: boolean; + onChange: (next: BuildFlagMode) => void; +}> = ({ bulk, value, disabled, onChange }) => ( +
+
+
Build
+ +
+ {value === 'on' && ( +
+ Builds the payload on the grandparent (n-2) execution payload instead of the immediate + parent — parent hash, parent number and withdrawals come from the parent slot, everything + else from this slot. A deliberate parent-payload reorg attempt; rejected by mainnet + forkchoice, useful for testing. +
+ )} +
+); diff --git a/pkg/webui/src/hooks/useActionPlan.ts b/pkg/webui/src/hooks/useActionPlan.ts index d54b652..403dca3 100644 --- a/pkg/webui/src/hooks/useActionPlan.ts +++ b/pkg/webui/src/hooks/useActionPlan.ts @@ -53,28 +53,6 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe const rangeRef = useRef({ min: minSlot, max: maxSlot }); rangeRef.current = { min: minSlot, max: maxSlot }; - // Merge an authoritative {slots, plans} change set (POST response or SSE - // event) into the in-range plan state; a null plan deletes the slot's entry. - const mergePlanChange = useCallback((slots: number[], changed: (SlotPlan | null)[]) => { - setPlans((prev) => { - let next: Record | null = null; - for (let i = 0; i < slots.length; i++) { - const slot = toSlotNumber(slots[i]); - if (slot < rangeRef.current.min || slot > rangeRef.current.max) continue; - const plan = changed[i] ?? null; - if (plan === null) { - if (prev[slot] === undefined && next === null) continue; - next = next ?? { ...prev }; - delete next[slot]; - } else { - next = next ?? { ...prev }; - next[slot] = plan; - } - } - return next ?? prev; - }); - }, []); - const fetchAll = useCallback(async () => { if (maxSlot < minSlot) return; @@ -119,6 +97,43 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe } }, [minSlot, maxSlot]); + // Stable handle for the SSE handlers, which must not resubscribe whenever + // the visible range changes. + const fetchAllRef = useRef(fetchAll); + fetchAllRef.current = fetchAll; + + // Apply an authoritative {slots, plans} change set (POST response or SSE + // event) to the in-range plan state. A stored plan always wins over a + // recurring rule, so setting one can be merged in place — but DELETING one + // hands the slot back to whatever rule matches it, and only the server knows + // that. Any in-range deletion therefore refetches instead of merging. + const applyPlanChange = useCallback((slots: number[], changed: (SlotPlan | null)[]) => { + const { min, max } = rangeRef.current; + const inRange: number[] = []; + + for (let i = 0; i < slots.length; i++) { + const slot = toSlotNumber(slots[i]); + if (slot < min || slot > max) continue; + + if ((changed[i] ?? null) === null) { + fetchAllRef.current(); + return; + } + + inRange.push(i); + } + + if (inRange.length === 0) return; + + setPlans((prev) => { + const next = { ...prev }; + for (const i of inRange) { + next[toSlotNumber(slots[i])] = changed[i] as SlotPlan; + } + return next; + }); + }, []); + useEffect(() => { fetchAll(); }, [fetchAll]); @@ -128,7 +143,13 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe const offPlan = onStreamEvent('action_plan_updated', (data) => { const change = data as { slots?: number[]; plans?: (SlotPlan | null)[] }; if (!change?.slots?.length) return; - mergePlanChange(change.slots, change.plans || []); + applyPlanChange(change.slots, change.plans || []); + }); + + // Rule changes rewrite the effective plan of every uncovered slot, so the + // whole visible range has to come back from the authoritative endpoint. + const offRules = onStreamEvent('action_plan_rules_updated', () => { + fetchAllRef.current(); }); const offResult = onStreamEvent('slot_result_updated', (data) => { @@ -141,9 +162,10 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe return () => { offPlan(); + offRules(); offResult(); }; - }, [mergePlanChange]); + }, [applyPlanChange]); // Refetch the visible range after an SSE reconnect (updates during the gap // were never delivered). @@ -183,7 +205,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe // from our own patch. const change = body as UpdateActionPlanResponse; if (change?.slots?.length) { - mergePlanChange(change.slots, change.plans || []); + applyPlanChange(change.slots, change.plans || []); } return { ok: true }; @@ -191,7 +213,7 @@ export function useActionPlan(minSlot: number, maxSlot: number): UseActionPlanRe return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; } }, - [mergePlanChange] + [applyPlanChange] ); return { plans, results, loading, error, refetch: fetchAll, applyUpdates }; diff --git a/pkg/webui/src/hooks/useActionPlanRules.ts b/pkg/webui/src/hooks/useActionPlanRules.ts new file mode 100644 index 0000000..4738fa2 --- /dev/null +++ b/pkg/webui/src/hooks/useActionPlanRules.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { ActionPlanRulesResponse, SlotRule } from '../types'; +import { authStore } from '../stores/authStore'; +import { + onStreamEvent, + getConnectionGeneration, + subscribeConnectionGeneration, +} from './useEventStream'; + +export interface SaveRulesResult { + ok: boolean; + error?: string; +} + +interface UseActionPlanRulesResult { + rules: SlotRule[]; + loading: boolean; + error: string | null; + refetch: () => void; + /** Replaces the whole rule set atomically (the backend validates all-or-nothing). */ + saveRules: (rules: SlotRule[]) => Promise; +} + +/** + * Fetches the recurring action plan rules and keeps them live via the shared + * SSE stream (action_plan_rules_updated carries the authoritative set). + */ +export function useActionPlanRules(): UseActionPlanRulesResult { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchRules = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/buildoor/action-plan/rules'); + if (!response.ok) { + throw new Error(`Failed to fetch action plan rules: ${response.statusText}`); + } + + const data: ActionPlanRulesResponse = await response.json(); + setRules(data.rules || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setRules([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRules(); + }, [fetchRules]); + + useEffect( + () => + onStreamEvent('action_plan_rules_updated', (data) => { + const change = data as { rules?: SlotRule[] }; + if (change?.rules) setRules(change.rules); + }), + [] + ); + + // Refetch after an SSE reconnect (changes during the gap were never delivered). + const generation = useSyncExternalStore(subscribeConnectionGeneration, getConnectionGeneration); + const lastGenerationRef = useRef(generation); + useEffect(() => { + if (generation !== lastGenerationRef.current) { + lastGenerationRef.current = generation; + fetchRules(); + } + }, [generation, fetchRules]); + + const saveRules = useCallback(async (next: SlotRule[]): Promise => { + const headers: HeadersInit = { 'Content-Type': 'application/json' }; + const authToken = await authStore.getAuthHeader(); + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + try { + const response = await fetch('/api/buildoor/action-plan/rules', { + method: 'POST', + headers, + body: JSON.stringify({ rules: next }), + }); + + const body = await response.json().catch(() => null); + + if (!response.ok) { + const message = + (body as { error?: string } | null)?.error || `Request failed: ${response.statusText}`; + return { ok: false, error: message }; + } + + // Adopt the authoritative set — never our own optimistic version. + setRules((body as ActionPlanRulesResponse)?.rules || []); + + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; + } + }, []); + + return { rules, loading, error, refetch: fetchRules, saveRules }; +} diff --git a/pkg/webui/src/styles.css b/pkg/webui/src/styles.css index 5f1c9ae..bc91efd 100644 --- a/pkg/webui/src/styles.css +++ b/pkg/webui/src/styles.css @@ -1203,6 +1203,18 @@ body:has(#panda-menu-root) header.header-bar > nav.navbar { text-decoration: line-through; } +/* Chips derived from a recurring rule: hollow, so a stored plan for the slot + stays visually distinct from a rule that merely applies to it. */ +.ap-chip-rule { + background: transparent; + outline: 1px dashed currentColor; + outline-offset: -1px; +} + +.ap-chip-custom.ap-chip-rule { + color: var(--bs-primary); +} + /* Build-modifier chip (parent-payload reorg): amber to read as "adverse". */ .ap-chip-reorg { background: #fd7e14; diff --git a/pkg/webui/src/types.ts b/pkg/webui/src/types.ts index b2b3262..cc2a35f 100644 --- a/pkg/webui/src/types.ts +++ b/pkg/webui/src/types.ts @@ -536,10 +536,36 @@ export interface SlotPlan { reveal?: RevealPlan; build?: BuildPlan; transforms?: TransformPlan; + // Opts the slot out of every recurring rule (runs on the global baseline). + ignore_rules?: boolean; + // Set on plans synthesized from a recurring rule; never on stored plans. + rule_id?: string; updated_at: string; updated_by: string; } +// A recurring plan template: its categories apply to every slot whose index +// within the epoch matches, unless the slot carries an explicit plan. +export interface SlotRule { + id: string; + enabled: boolean; + description?: string; + slots_in_epoch: number[]; + from_epoch?: number; + to_epoch?: number; + bid?: BidPlan; + builder_api?: BuilderAPIPlan; + reveal?: RevealPlan; + build?: BuildPlan; + transforms?: TransformPlan; + updated_at?: string; + updated_by?: string; +} + +export interface ActionPlanRulesResponse { + rules: SlotRule[]; +} + // One mutation unit of the bulk plan update API. Category members are // three-state: absent = unchanged, null = clear (back to inherit), object = // replace. `set` applies fine-grained path updates (e.g. "bid.bid_min_amount") @@ -554,6 +580,8 @@ export interface PlanUpdate { reveal?: RevealPlan | null; build?: BuildPlan | null; transforms?: TransformPlan | null; + // Two-state: absent = unchanged, true/false = set the rule opt-out. + ignore_rules?: boolean; set?: Record; } diff --git a/pkg/webui/webui.go b/pkg/webui/webui.go index e55e14d..998801e 100644 --- a/pkg/webui/webui.go +++ b/pkg/webui/webui.go @@ -90,6 +90,8 @@ func StartHttpServer(frontendConfig *types.FrontendConfig, settingsSvc *config.S // Per-slot action plan + results endpoints apiRouter.HandleFunc("/buildoor/action-plan", apiHandler.GetActionPlan).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/action-plan", apiHandler.UpdateActionPlan).Methods(http.MethodPost) + apiRouter.HandleFunc("/buildoor/action-plan/rules", apiHandler.GetActionPlanRules).Methods(http.MethodGet) + apiRouter.HandleFunc("/buildoor/action-plan/rules", apiHandler.UpdateActionPlanRules).Methods(http.MethodPost) apiRouter.HandleFunc("/buildoor/action-plan/test-transform", apiHandler.TestTransform).Methods(http.MethodPost) apiRouter.HandleFunc("/buildoor/slot-results", apiHandler.GetSlotResults).Methods(http.MethodGet) apiRouter.HandleFunc("/buildoor/slot-results/{slot}/payload", apiHandler.GetSlotPayloadArtifact).Methods(http.MethodGet)