132 lines
4.9 KiB
Go
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
|
|
}
|