Files
go-sip/internal/dispatcher/current_control.go
T

132 lines
4.9 KiB
Go

package dispatcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/store"
)
// CurrentControlSpec is an Agent control instruction, not evidence that a
// drain/hangup has completed. Never log a user-supplied reason or credentials.
type CurrentControlSpec struct {
DispatcherID string
TenantID int64
TaskID string
Action string
ActiveCallPolicy string
Reason string
}
type CurrentControlAgent interface {
SendControl(context.Context, CurrentControlSpec) error
}
type CurrentControlController struct {
DispatcherID string
Store *store.CurrentStore
Client *configread.Client
Agent CurrentControlAgent
VerifySIP func(context.Context, configread.CurrentSIP) error
Now func() time.Time
}
// ProcessControl applies each delivered control independently: it has no
// command_id, expected revision, or control-message deduplication. The state
// barrier is durable before Agent dispatch; applied state and MQ outbox are
// committed atomically after the Agent accepts the instruction.
func (c *CurrentControlController) ProcessControl(ctx context.Context, body []byte) error {
if c == nil || c.DispatcherID == "" || c.Store == nil || c.Client == nil || c.Agent == nil || c.VerifySIP == nil || c.Now == nil {
return errors.New("control processing requires Dispatcher, durable store, HTTP client, Agent, SIP verifier, and clock")
}
if err := contract.ValidateCurrent("mq", body); err != nil {
return fmt.Errorf("invalid incoming task.control: %w", err)
}
var event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
IssuedAt string `json:"issued_at"`
Payload struct {
TaskID string `json:"task_id"`
Action string `json:"action"`
Reason string `json:"reason"`
Options struct {
ActiveCallPolicy string `json:"active_call_policy"`
} `json:"options"`
} `json:"payload"`
}
if err := json.Unmarshal(body, &event); err != nil {
return fmt.Errorf("decode task.control: %w", err)
}
if event.EventType != "task.control" || event.DispatcherID != c.DispatcherID || event.Payload.TaskID == "" || event.Payload.Action == "" {
return errors.New("control event type, task, or Dispatcher owner mismatch")
}
issuedAt, err := time.Parse(time.RFC3339Nano, event.IssuedAt)
if err != nil {
return fmt.Errorf("invalid task.control issued_at: %w", err)
}
if issuedAt.After(c.Now()) {
return fmt.Errorf("control event %q issued_at is in the future; do not apply early", event.EventID)
}
policy := event.Payload.Options.ActiveCallPolicy
if event.Payload.Action == "resume" {
if policy != "" {
return errors.New("resume control must not carry an active-call policy")
}
// A stale HTTP running status cannot override persisted pause/stop.
// Only a fresh approved task plus applied SIP snapshot may authorize
// the transition from a durable pause back to admission.
snapshot, err := c.Client.ReadCurrentTask(ctx, event.Payload.TaskID, event.TenantID)
if err != nil {
return fmt.Errorf("fresh resume task configuration: %w", err)
}
if snapshot.Task.Status != "running" {
return errors.New("fresh resume task is not running")
}
if err := c.VerifySIP(ctx, snapshot.SIP); err != nil {
return fmt.Errorf("resume SIP revision not applied: %w", err)
}
if err := validateCurrentAISnapshot(snapshot); err != nil {
return err
}
if err := c.Store.SaveSnapshot(snapshot); err != nil {
return fmt.Errorf("bind fresh resume task: %w", err)
}
} else {
if policy == "" {
policy = "hangup"
}
if policy != "hangup" && policy != "drain" {
return errors.New("unapproved active-call policy")
}
}
if err := c.Store.PrepareControl(event.DispatcherID, event.TenantID, event.Payload.TaskID, event.Payload.Action); err != nil {
if errors.Is(err, store.ErrCurrentControlRejected) {
if rejectErr := c.Store.RejectControl(event.DispatcherID, event.TenantID, event.EventID); rejectErr != nil {
return fmt.Errorf("persist rejected control %q: %w", event.EventID, rejectErr)
}
return nil
}
return fmt.Errorf("prepare task control %q: %w", event.EventID, err)
}
spec := CurrentControlSpec{
DispatcherID: event.DispatcherID, TenantID: event.TenantID,
TaskID: event.Payload.TaskID, Action: event.Payload.Action,
ActiveCallPolicy: policy, Reason: event.Payload.Reason,
}
if err := c.Agent.SendControl(ctx, spec); err != nil {
return fmt.Errorf("Agent control %q dispatch failed: %w", event.EventID, err)
}
if err := c.Store.CompleteControl(event.DispatcherID, event.TenantID, event.Payload.TaskID, event.Payload.Action, event.EventID); err != nil {
return fmt.Errorf("Agent control %q dispatched but acknowledgment not durable: %w", event.EventID, err)
}
return nil
}