feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+22 -5
View File
@@ -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)
+135
View File
@@ -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
}
+1
View File
@@ -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)
+5
View File
@@ -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"`