package dispatcher import ( "context" "errors" "fmt" "sync" "time" agentpb "git.ipao.vip/rogee/go-sip/gen/agent" agentsession "git.ipao.vip/rogee/go-sip/internal/session" "git.ipao.vip/rogee/go-sip/internal/store" "git.ipao.vip/rogee/go-sip/internal/tenant" "github.com/google/uuid" ) type AgentSession struct { AgentID string CellID string BootID string DispatcherEpoch string SessionGeneration uint64 ExpiresAtUnixMs int64 } // AgentCoordinator binds an Agent endpoint to the current activated session. type AgentCoordinator struct { mu sync.Mutex now func() time.Time clients map[string]agentpb.AgentControlServiceClient sessions map[string]AgentSession } func NewAgentCoordinator(now func() time.Time) *AgentCoordinator { if now == nil { now = time.Now } return &AgentCoordinator{now: now, clients: make(map[string]agentpb.AgentControlServiceClient), sessions: make(map[string]AgentSession)} } func (c *AgentCoordinator) Register(agentID string, client agentpb.AgentControlServiceClient) error { if agentID == "" || client == nil { return errors.New("agent ID and client are required") } c.mu.Lock() defer c.mu.Unlock() c.clients[agentID] = client return nil } // Probe reads deployment status before Activate binds the returned boot ID. func (c *AgentCoordinator) Probe(ctx context.Context, agentID, cellID string) (*agentpb.AgentStatus, error) { if agentID == "" || cellID == "" { return nil, errors.New("agent ID and cell ID are required") } client, err := c.client(agentID) if err != nil { return nil, err } operationID := "status:" + agentID response, err := client.GetAgentStatus(ctx, &agentpb.GetAgentStatusRequest{ Meta: &agentpb.RequestMeta{ ProtocolVersion: "agent.v1", RequestId: operationID + ":request", TraceId: operationID, OperationId: operationID, AgentId: agentID, CellId: cellID, }, Target: &agentpb.AgentBinding{AgentId: agentID, CellId: cellID}, }) if err != nil { return nil, err } if response == nil || response.Status == nil { return nil, errors.New("Agent status response is empty") } if response.Status.AgentId != agentID || response.Status.CellId != cellID || response.Status.BootId == "" { return nil, errors.New("Agent status identity or boot ID is invalid") } return response.Status, nil } func (c *AgentCoordinator) Activate(ctx context.Context, dispatcherID, agentID, cellID, bootID, epoch string, generation uint64) (AgentSession, error) { if tenant.ValidateDispatcherID(dispatcherID) != nil { return AgentSession{}, errors.New("Agent activation requires a canonical Dispatcher UUID v4") } client, err := c.client(agentID) if err != nil { return AgentSession{}, err } if cellID == "" || bootID == "" || epoch == "" { return AgentSession{}, errors.New("cell, boot and dispatcher epoch are required") } activationID, err := uuid.NewRandom() if err != nil { return AgentSession{}, fmt.Errorf("create Agent activation identity: %w", err) } operationID := "activate:" + activationID.String() meta := &agentpb.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + ":request", TraceId: operationID, OperationId: operationID, DispatcherEpoch: epoch, AgentId: agentID, CellId: cellID, BootId: bootID} response, err := client.ActivateAgent(ctx, &agentpb.ActivateAgentRequest{Meta: meta, Binding: &agentpb.AgentBinding{AgentId: agentID, CellId: cellID, ExpectedBootId: bootID, DispatcherId: dispatcherID, DispatcherEpoch: epoch, SessionGeneration: generation}, ActivationOperationId: operationID}) if err != nil { return AgentSession{}, err } if response == nil || response.Session == nil || response.State != agentpb.ActivationState_ACTIVATION_STATE_ACTIVE || response.Session.DispatcherEpoch != epoch || response.Session.SessionGeneration == 0 || response.Session.ExpiresAtUnixMs <= c.now().UnixMilli() { return AgentSession{}, errors.New("Agent activation returned an invalid or expired session") } session := AgentSession{AgentID: agentID, CellID: cellID, BootID: bootID, DispatcherEpoch: epoch, SessionGeneration: response.Session.SessionGeneration, ExpiresAtUnixMs: response.Session.ExpiresAtUnixMs} c.mu.Lock() c.sessions[agentID] = session c.mu.Unlock() return session, nil } // Renew reactivates only the existing approved Agent boot before its session // expires. Failure never adopts another boot or grants a second execution. func (c *AgentCoordinator) Renew(ctx context.Context, dispatcherID string, previous AgentSession) (AgentSession, error) { if c == nil || ctx == nil || previous.AgentID == "" || previous.CellID == "" || previous.BootID == "" || previous.DispatcherEpoch == "" { return AgentSession{}, errors.New("complete approved Agent session is required for renewal") } if err := ctx.Err(); err != nil { return AgentSession{}, err } c.mu.Lock() active, ok := c.sessions[previous.AgentID] c.mu.Unlock() if !ok || active != previous || previous.ExpiresAtUnixMs <= c.now().UnixMilli() { return AgentSession{}, errors.New("Agent session changed or expired before renewal") } status, err := c.Probe(ctx, previous.AgentID, previous.CellID) if err != nil { return AgentSession{}, fmt.Errorf("probe Agent before session renewal: %w", err) } if status.BootId != previous.BootID { return AgentSession{}, errors.New("Agent boot changed during session renewal; unknown work requires disposition") } if previous.ExpiresAtUnixMs <= c.now().UnixMilli() { return AgentSession{}, errors.New("Agent session expired during status probing") } renewed, err := c.Activate(ctx, dispatcherID, previous.AgentID, previous.CellID, previous.BootID, previous.DispatcherEpoch, 0) if err != nil { return AgentSession{}, fmt.Errorf("renew approved Agent session: %w", err) } if renewed.SessionGeneration <= previous.SessionGeneration || renewed.ExpiresAtUnixMs <= previous.ExpiresAtUnixMs { return AgentSession{}, errors.New("Agent renewal did not advance generation and expiry") } return renewed, nil } func (c *AgentCoordinator) client(agentID string) (agentpb.AgentControlServiceClient, error) { c.mu.Lock() defer c.mu.Unlock() client := c.clients[agentID] if client == nil { return nil, fmt.Errorf("Agent %q is not registered", agentID) } return client, nil } func (c *AgentCoordinator) clientAndSession(agentID string) (agentpb.AgentControlServiceClient, AgentSession, error) { client, err := c.client(agentID) if err != nil { return nil, AgentSession{}, err } c.mu.Lock() session, ok := c.sessions[agentID] c.mu.Unlock() if !ok { return nil, AgentSession{}, fmt.Errorf("Agent %q is not activated", agentID) } return client, session, nil } // ApprovedMeta supplies a fresh operation identity from the current session. func (c *AgentCoordinator) ApprovedMeta(ctx context.Context, agentID string) (*agentpb.RequestMeta, error) { if c == nil || ctx == nil || agentID == "" { return nil, errors.New("registered Agent and request context are required") } if err := ctx.Err(); err != nil { return nil, err } _, session, err := c.clientAndSession(agentID) if err != nil { return nil, err } if session.AgentID != agentID || session.CellID == "" || session.BootID == "" || session.DispatcherEpoch == "" || session.SessionGeneration == 0 || session.ExpiresAtUnixMs <= c.now().UnixMilli() { return nil, errors.New("approved Agent session is incomplete or expired") } identity, err := uuid.NewRandom() if err != nil { return nil, fmt.Errorf("create Agent operation identity: %w", err) } return c.meta(session, "approved:"+identity.String()), nil } // AuthorizeInboundMeta accepts an Agent fact only from the active Endpoint // session; a replayed fact must carry the newly activated boot and generation. func (c *AgentCoordinator) AuthorizeInboundMeta(meta *agentpb.RequestMeta) error { if c == nil || meta == nil || meta.ProtocolVersion != "agent.v1" || meta.AgentId == "" { return fmt.Errorf("active Agent session is required: %w", store.ErrCommandConflict) } c.mu.Lock() session, active := c.sessions[meta.AgentId] client, registered := c.clients[meta.AgentId] c.mu.Unlock() if !active || !registered || client == nil || session.ExpiresAtUnixMs <= c.now().UnixMilli() || meta.CellId != session.CellID || meta.BootId != session.BootID || meta.DispatcherEpoch != session.DispatcherEpoch { return fmt.Errorf("Agent report does not match a current activated session: %w", store.ErrCommandConflict) } if meta.SessionGeneration != session.SessionGeneration { if meta.SessionGeneration > 0 && ((meta.SessionGeneration < session.SessionGeneration && session.SessionGeneration-meta.SessionGeneration == 1) || (meta.SessionGeneration > session.SessionGeneration && meta.SessionGeneration-session.SessionGeneration == 1)) { return fmt.Errorf("Agent fact was fenced during session transition: %w: %w", store.ErrCommandConflict, agentsession.ErrGenerationTransition) } return fmt.Errorf("Agent report has an unapproved session generation: %w", store.ErrCommandConflict) } return nil } func (c *AgentCoordinator) meta(session AgentSession, operationID string) *agentpb.RequestMeta { return &agentpb.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + ":request", TraceId: operationID, OperationId: operationID, DispatcherEpoch: session.DispatcherEpoch, AgentId: session.AgentID, CellId: session.CellID, BootId: session.BootID, SessionGeneration: session.SessionGeneration} }