This commit is contained in:
@@ -32,10 +32,12 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
|
||||
request := idempotency.Request{TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v3/agent-sessions", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour}
|
||||
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
} else if found {
|
||||
return s.finalizeHardwareCreate(cached)
|
||||
if identity.EnrollmentMode != "physical_confirm" {
|
||||
if cached, found, err := s.executor.Lookup(ctx, request); err != nil {
|
||||
return HardwareAgentCreateResponse{}, false, err
|
||||
} else if found {
|
||||
return s.finalizeHardwareCreate(cached)
|
||||
}
|
||||
}
|
||||
memoryVector, memoryPolicy, err := s.prepareMemoryQuery(ctx, identity, input.RequestedGoal)
|
||||
if err != nil {
|
||||
@@ -66,7 +68,7 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := s.executor.Execute(ctx, request, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
result, err := s.executor.ExecuteGuarded(ctx, request, onboardingCreateGuard(identity, input), func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
now := s.now().UTC()
|
||||
created, internalID, createErr := s.repository.Create(operationContext, tx, identity, CreateInput{
|
||||
DeviceTime: input.DeviceTime, MonotonicMS: input.MonotonicMS, Network: input.Network,
|
||||
@@ -86,9 +88,18 @@ func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.I
|
||||
if createErr != nil {
|
||||
return idempotency.Result{}, createErr
|
||||
}
|
||||
if err := attachOnboardingConversation(operationContext, tx, identity, input, internalID); err != nil {
|
||||
return idempotency.Result{}, err
|
||||
}
|
||||
if created.Hardware == nil {
|
||||
return idempotency.Result{}, errors.New("hardware agent runtime was not allocated")
|
||||
}
|
||||
if input.Intent == "activation_test" {
|
||||
created.Hardware.Session.MaximumDurationSeconds = 60
|
||||
if created.Hardware.Session.AllocationExpiresAt.After(now.Add(time.Minute)) {
|
||||
created.Hardware.Session.AllocationExpiresAt = now.Add(time.Minute)
|
||||
}
|
||||
}
|
||||
body, marshalErr := json.Marshal(created.Hardware)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
@@ -144,6 +155,9 @@ func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity device
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if err := guardOnboardingEvent(ctx, tx, identity, conversationPublicID, input); err != nil {
|
||||
return HardwareAgentEventResponse{}, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var internalID, tenantID, providerSessionRef, nonceDigest, expectedBotID, expectedTransport, expectedConfigDigest string
|
||||
var lastEventSequence int64
|
||||
@@ -248,6 +262,9 @@ func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity device
|
||||
currentStatus = conversation.StatusFailed
|
||||
} else {
|
||||
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET last_event_at=$2,last_event_sequence=GREATEST(last_event_sequence,$3),updated_at=$2 WHERE conversation_id=$1::uuid`, internalID, now, input.SequenceNo)
|
||||
if input.EventType == "first_audio_received" && currentStatus != conversation.StatusActive {
|
||||
return HardwareAgentEventResponse{}, ErrConversationStateConflict
|
||||
}
|
||||
if err == nil && currentStatus == conversation.StatusActive {
|
||||
leaseExpires := now.Add(time.Duration(s.options.HeartbeatInterval*5) * time.Second)
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET lease_expires_at=$2,updated_at=$3,last_heartbeat_seq=GREATEST(last_heartbeat_seq,$4) WHERE id=$1::uuid AND status='ACTIVE'`, internalID, leaseExpires, now, input.SequenceNo)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrOnboardingState = errors.New("onboarding state or test permit is no longer valid")
|
||||
|
||||
func onboardingCreateGuard(identity deviceauth.Identity, input HardwareAgentCreateInput) idempotency.Guard {
|
||||
return func(ctx context.Context, tx pgx.Tx, cached *idempotency.Result) error {
|
||||
var mode, status, lifecycle string
|
||||
var generation int64
|
||||
err := tx.QueryRow(ctx, `SELECT enrollment_mode,status,activation_state,binding_generation FROM device WHERE id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, identity.ID, identity.TenantID).Scan(&mode, &status, &lifecycle, &generation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == "legacy" {
|
||||
if input.Intent != "" || input.ActivationID != "" || input.AttemptID != "" || input.PermitToken != "" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if identity.EnrollmentMode != "physical_confirm" || generation != identity.BindingGeneration || status != "registered" || lifecycle == "REVOKED" || lifecycle == "LOST" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
var allowed bool
|
||||
err = tx.QueryRow(ctx, `SELECT true FROM device_binding b JOIN household h ON h.id=b.household_id AND h.status='active' JOIN subject_profile sp ON sp.id=b.subject_id AND sp.status='active' JOIN household_member hm ON hm.household_id=h.id AND hm.user_id=b.bound_by AND hm.status='active' AND hm.role IN ('owner','admin','editor') JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL AND b.generation=$2 FOR SHARE OF b,h,sp,hm,ua`, identity.ID, generation).Scan(&allowed)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if input.Intent == "" {
|
||||
if input.ActivationID != "" || input.AttemptID != "" || input.PermitToken != "" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM device_activation_job WHERE device_id=$1::uuid AND binding_generation=$2 AND status='READY' AND config_ack_at IS NOT NULL AND config_expires_at>now())`, identity.ID, generation).Scan(&allowed)
|
||||
} else {
|
||||
if input.Intent != "activation_test" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if _, e := uuid.Parse(input.ActivationID); e != nil {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if _, e := uuid.Parse(input.AttemptID); e != nil {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
hash := sha256.Sum256([]byte(input.PermitToken))
|
||||
var attached *string
|
||||
err = tx.QueryRow(ctx, `SELECT a.conversation_id::text FROM device_activation_job j JOIN device_activation_attempt a ON a.activation_id=j.id WHERE j.id=$1::uuid AND j.device_id=$2::uuid AND a.id=$3::uuid AND j.binding_generation=$4 AND a.binding_generation=$4 AND a.config_version=j.config_version AND j.status IN ('REGISTERED_OBSERVED','CONTACT_POSSIBLE','READY') AND j.config_ack_at IS NOT NULL AND j.engine_ready_at IS NOT NULL AND j.config_expires_at>now() AND a.stage='CONVERSATION_TEST' AND a.executor='DEVICE' AND a.status='CONTACT_POSSIBLE' AND (a.permit_hash IS NULL OR a.permit_hash=$5) AND (a.permit_expires_at>now() OR a.conversation_id IS NOT NULL) FOR UPDATE OF j,a`, input.ActivationID, identity.ID, input.AttemptID, generation, hex.EncodeToString(hash[:])).Scan(&attached)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allowed = true
|
||||
if cached != nil && (cached.ResourceID == nil || attached == nil || *cached.ResourceID != *attached) {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if cached != nil {
|
||||
if cached.ResourceID == nil {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation WHERE id=$1::uuid AND device_id=$2::uuid AND onboarding_generation=$3 AND status IN ('ROOM_ISSUED','ACTIVE') AND (activation_test_deadline IS NULL OR activation_test_deadline>now()))`, *cached.ResourceID, identity.ID, generation).Scan(&allowed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allowed {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func attachOnboardingConversation(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, input HardwareAgentCreateInput, conversation string) error {
|
||||
if identity.EnrollmentMode != "physical_confirm" {
|
||||
return nil
|
||||
}
|
||||
if input.Intent == "activation_test" {
|
||||
tag, err := tx.Exec(ctx, `UPDATE device_activation_attempt SET conversation_id=$2::uuid WHERE id=$1::uuid AND conversation_id IS NULL AND permit_expires_at>now() AND status='CONTACT_POSSIBLE'`, input.AttemptID, conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE conversation SET onboarding_generation=$2,activation_attempt_id=$3::uuid,activation_test_deadline=now()+interval '60 seconds',allocation_expires_at=LEAST(allocation_expires_at,now()+interval '60 seconds') WHERE id=$1::uuid`, conversation, identity.BindingGeneration, input.AttemptID)
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE conversation SET onboarding_generation=$2 WHERE id=$1::uuid`, conversation, identity.BindingGeneration)
|
||||
return err
|
||||
}
|
||||
func guardOnboardingEvent(ctx context.Context, tx pgx.Tx, identity deviceauth.Identity, conversation string, event HardwareAgentEventInput) error {
|
||||
if identity.EnrollmentMode != "physical_confirm" {
|
||||
return nil
|
||||
}
|
||||
var generation int64
|
||||
var status string
|
||||
if err := tx.QueryRow(ctx, `SELECT binding_generation,status FROM device WHERE id=$1::uuid FOR UPDATE`, identity.ID).Scan(&generation, &status); err != nil {
|
||||
return err
|
||||
}
|
||||
if generation != identity.BindingGeneration || status != "registered" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
var stored *int64
|
||||
var deadline *time.Time
|
||||
err := tx.QueryRow(ctx, `SELECT onboarding_generation,activation_test_deadline FROM conversation WHERE public_id=$1::uuid AND device_id=$2::uuid`, conversation, identity.ID).Scan(&stored, &deadline)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored == nil || *stored != generation {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
if deadline != nil && deadline.Before(time.Now()) && event.EventType != "session_ended" && event.EventType != "session_failed" {
|
||||
return ErrOnboardingState
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
|
||||
LEFT JOIN conversation_usage_budget cub ON cub.conversation_id=c.id AND cub.tenant_id=c.tenant_id
|
||||
WHERE (c.status='ROOM_ISSUED' AND c.allocation_expires_at <= $1)
|
||||
OR (c.status='ACTIVE' AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at <= $1)
|
||||
OR (c.status='ACTIVE' AND c.activation_test_deadline IS NOT NULL AND c.activation_test_deadline <= $1)
|
||||
OR (c.status='ACTIVE' AND cub.in_progress_action='STOP_IMMEDIATELY' AND c.started_at IS NOT NULL AND c.started_at+make_interval(secs=>cub.max_call_duration_sec)<=$1)
|
||||
OR (c.status='STARTING_AGENT' AND c.updated_at <= $2)
|
||||
OR (c.status='STOPPING' AND c.updated_at <= $3)
|
||||
|
||||
@@ -57,6 +57,7 @@ type Privacy struct {
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
MaximumDurationSeconds int `json:"maximum_duration_seconds,omitempty"`
|
||||
EngineMode string `json:"engine_mode"`
|
||||
HeartbeatIntervalSecond int `json:"heartbeat_interval_sec"`
|
||||
AllocationExpiresAt time.Time `json:"allocation_expires_at"`
|
||||
@@ -93,6 +94,10 @@ type HardwareAgentCapabilities struct {
|
||||
}
|
||||
|
||||
type HardwareAgentCreateInput struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
ActivationID string `json:"activation_id,omitempty"`
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
PermitToken string `json:"permit_token,omitempty"`
|
||||
DeviceTime time.Time `json:"device_time"`
|
||||
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
|
||||
Capabilities HardwareAgentCapabilities `json:"capabilities"`
|
||||
|
||||
Reference in New Issue
Block a user