package harness import ( "context" "encoding/json" "errors" "sync" "time" "keel/internal/evidence" "keel/internal/mode" ) var ( ErrBusy = errors.New("harness: another mode is active") ErrUnknownMode = errors.New("harness: unknown mode") ErrIdle = errors.New("harness: no active mode") ) // Factory builds a mode from the shared services, in its initial active state. type Factory func(Services) mode.Mode type Harness struct { mu sync.Mutex active mode.Mode factories map[string]Factory services Services onChange []func() } // New wires Services.Notify to the harness so every mode's async completion // reaches all listeners, then stores the services for the factories to receive. func New(services Services, factories map[string]Factory) *Harness { h := &Harness{factories: factories} services.Notify = h.notify h.services = services return h } // AddOnChange registers a change listener (web broadcaster, status writer). func (h *Harness) AddOnChange(f func()) { h.mu.Lock() h.onChange = append(h.onChange, f) h.mu.Unlock() } // SetServices swaps the services future Start calls hand to factories. In v1 it // affects the NEXT mode start only; an already-active mode keeps the services it // was built with (settings changes take effect on the next session). func (h *Harness) SetServices(s Services) { s.Notify = h.notify h.mu.Lock() h.services = s h.mu.Unlock() } // Services returns the shared services the harness hands to factories, with // Notify wired to the harness change fan-out. Use it to build a mode for Adopt // (e.g. startup restoration) so the restored mode's async work reaches the // harness's onChange listeners. func (h *Harness) Services() Services { h.mu.Lock() defer h.mu.Unlock() return h.services } func (h *Harness) notify() { h.mu.Lock() fs := append([]func(){}, h.onChange...) h.mu.Unlock() for _, f := range fs { if f != nil { f() } } } // Adopt installs an already-constructed mode as active (startup restoration). // It fails if a mode is already active. func (h *Harness) Adopt(m mode.Mode) error { h.mu.Lock() if h.active != nil { h.mu.Unlock() return ErrBusy } h.active = m h.mu.Unlock() h.notify() return nil } // Start activates a mode by kind. Idempotent if that kind is already active; // ErrBusy if a different kind is active. func (h *Harness) Start(kind string) error { h.mu.Lock() if h.active != nil { busy := h.active.Kind() != kind h.mu.Unlock() if busy { return ErrBusy } return nil } f, ok := h.factories[kind] if !ok { h.mu.Unlock() return ErrUnknownMode } h.active = f(h.services) h.mu.Unlock() h.notify() return nil } // Command routes to the active mode, then releases it if it went inactive. func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error { h.mu.Lock() m := h.active h.mu.Unlock() if m == nil { return ErrIdle } if err := m.Command(ctx, name, payload); err != nil { return err } h.releaseIfDone() h.notify() return nil } func (h *Harness) releaseIfDone() { h.mu.Lock() if h.active != nil && !h.active.Active() { h.active = nil } h.mu.Unlock() } // State returns the surfacing envelope. func (h *Harness) State() mode.Envelope { h.mu.Lock() m := h.active h.mu.Unlock() if m == nil { return mode.Envelope{} } return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()} } // RecordWindow forwards a window snapshot iff the active mode consumes evidence. func (h *Harness) RecordWindow(w evidence.WindowSnapshot) { h.mu.Lock() m := h.active h.mu.Unlock() if c, ok := m.(mode.EvidenceConsumer); ok { c.OnWindow(w) } } // Deadline returns the active mode's deadline iff it is an Expirer. func (h *Harness) Deadline() time.Time { h.mu.Lock() m := h.active h.mu.Unlock() if e, ok := m.(mode.Expirer); ok { return e.Deadline() } return time.Time{} } // Expire fires the active mode's expiry iff it is an Expirer, then releases it // if it went inactive. func (h *Harness) Expire() error { h.mu.Lock() m := h.active h.mu.Unlock() e, ok := m.(mode.Expirer) if !ok { return ErrIdle } if err := e.Expire(); err != nil { return err } h.releaseIfDone() h.notify() return nil }