chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/conversation"
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/embeddingprovider"
|
||||
"data-terminal-cloud/internal/idempotency"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"data-terminal-cloud/internal/rtc"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type ServiceOptions struct {
|
||||
RTCAppID string
|
||||
VoiceProvider string
|
||||
EngineMode string
|
||||
AllocationTTL time.Duration
|
||||
TokenTTL time.Duration
|
||||
HeartbeatInterval int
|
||||
RequirePublishedRouting bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
executor *idempotency.Executor
|
||||
repository *Repository
|
||||
audit *audit.Repository
|
||||
outbox *outbox.Store
|
||||
tokens rtc.TokenIssuer
|
||||
leases LeaseCache
|
||||
options ServiceOptions
|
||||
pool *pgxpool.Pool
|
||||
embedder embeddingprovider.Provider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureMemoryEmbedding(pool *pgxpool.Pool, provider embeddingprovider.Provider) error {
|
||||
if pool == nil || provider == nil {
|
||||
return errors.New("memory embedding pool and provider are required")
|
||||
}
|
||||
s.pool = pool
|
||||
s.embedder = provider
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewService(
|
||||
executor *idempotency.Executor,
|
||||
repository *Repository,
|
||||
auditRepository *audit.Repository,
|
||||
outboxStore *outbox.Store,
|
||||
tokens rtc.TokenIssuer,
|
||||
leases LeaseCache,
|
||||
options ServiceOptions,
|
||||
) (*Service, error) {
|
||||
if executor == nil || repository == nil || auditRepository == nil || outboxStore == nil || tokens == nil || leases == nil {
|
||||
return nil, errors.New("orchestration dependencies are required")
|
||||
}
|
||||
if options.RTCAppID == "" || options.EngineMode == "" || options.AllocationTTL <= 0 || options.TokenTTL <= 0 || options.HeartbeatInterval <= 0 {
|
||||
return nil, errors.New("valid orchestration options are required")
|
||||
}
|
||||
if options.VoiceProvider == "" {
|
||||
options.VoiceProvider = "mock"
|
||||
}
|
||||
return &Service{executor: executor, repository: repository, audit: auditRepository, outbox: outboxStore,
|
||||
tokens: tokens, leases: leases, options: options, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(
|
||||
ctx context.Context,
|
||||
identity deviceauth.Identity,
|
||||
idempotencyKey, requestID string,
|
||||
rawRequest []byte,
|
||||
input CreateInput,
|
||||
) (CreateResponse, bool, error) {
|
||||
tenantID := identity.TenantID
|
||||
request := idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID,
|
||||
Scope: "POST /device/v2/conversations", Key: idempotencyKey,
|
||||
RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour,
|
||||
}
|
||||
if cached, found, lookupErr := s.executor.Lookup(ctx, request); lookupErr != nil {
|
||||
return CreateResponse{}, false, lookupErr
|
||||
} else if found {
|
||||
return s.finalizeCreateResult(cached)
|
||||
}
|
||||
memoryVector, memoryPolicy, err := s.prepareMemoryQuery(ctx, identity, input.RequestedGoal)
|
||||
if err != nil {
|
||||
return CreateResponse{}, false, err
|
||||
}
|
||||
result, err := s.executor.Execute(ctx, request, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
now := s.now().UTC()
|
||||
response, internalID, createErr := s.repository.Create(operationContext, tx, identity, input, AllocationOptions{
|
||||
Now: now, AllocationExpires: now.Add(s.options.AllocationTTL), TokenExpires: now.Add(s.options.TokenTTL),
|
||||
RTCAppID: s.options.RTCAppID, VoiceProvider: s.options.VoiceProvider, EngineMode: s.options.EngineMode, HeartbeatInterval: s.options.HeartbeatInterval,
|
||||
MemoryQueryVector: memoryVector, MemoryPolicy: memoryPolicy, RequirePublishedRouting: s.options.RequirePublishedRouting,
|
||||
})
|
||||
if createErr != nil {
|
||||
return idempotency.Result{}, createErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, fmt.Errorf("encode create conversation result: %w", marshalErr)
|
||||
}
|
||||
actorPublicID := identity.PublicID
|
||||
auditResult := "success"
|
||||
if response.Status == conversation.StatusFailed {
|
||||
auditResult = "failure"
|
||||
}
|
||||
if auditErr := s.audit.Record(operationContext, tx, audit.Event{
|
||||
TenantID: &tenantID, ActorType: "device", ActorPublicID: &actorPublicID,
|
||||
Action: "conversation.create", ResourceType: "conversation", ResourceID: &internalID,
|
||||
RequestID: requestID, Purpose: input.RequestedGoal, Result: auditResult,
|
||||
Detail: map[string]any{"status": response.Status, "failure_code": response.FailureCode},
|
||||
}); auditErr != nil {
|
||||
return idempotency.Result{}, auditErr
|
||||
}
|
||||
statusCode := http.StatusCreated
|
||||
if response.Status == conversation.StatusFailed {
|
||||
statusCode = http.StatusUnprocessableEntity
|
||||
} else {
|
||||
deduplicationKey := response.ConversationID
|
||||
if _, outboxErr := s.outbox.Enqueue(operationContext, tx, outbox.NewEvent{
|
||||
TenantID: &tenantID, AggregateType: "conversation", AggregateID: internalID,
|
||||
EventType: "conversation.room_issued", DeduplicationKey: &deduplicationKey,
|
||||
Payload: json.RawMessage(fmt.Sprintf(`{"conversation_id":%q,"status":"ROOM_ISSUED"}`, response.ConversationID)),
|
||||
}); outboxErr != nil {
|
||||
return idempotency.Result{}, outboxErr
|
||||
}
|
||||
}
|
||||
return idempotency.Result{StatusCode: statusCode, Body: body, ResourceID: &internalID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return CreateResponse{}, false, err
|
||||
}
|
||||
return s.finalizeCreateResult(result)
|
||||
}
|
||||
|
||||
func (s *Service) finalizeCreateResult(result idempotency.Result) (CreateResponse, bool, error) {
|
||||
var response CreateResponse
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return CreateResponse{}, result.Replayed, fmt.Errorf("decode stored create conversation result: %w", err)
|
||||
}
|
||||
if response.Status == conversation.StatusFailed {
|
||||
return CreateResponse{}, result.Replayed, &CreateFailureError{
|
||||
ConversationID: response.ConversationID,
|
||||
FailureCode: response.FailureCode,
|
||||
}
|
||||
}
|
||||
token, err := s.tokens.Issue(response.RTC.RoomID, response.RTC.UserID, response.RTC.ExpiresAt)
|
||||
if err != nil {
|
||||
return CreateResponse{}, result.Replayed, fmt.Errorf("issue RTC token: %w", err)
|
||||
}
|
||||
response.RTC.Token = token
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) prepareMemoryQuery(ctx context.Context, identity deviceauth.Identity, requestedGoal string) (string, string, error) {
|
||||
if s.pool == nil || s.embedder == nil {
|
||||
return "", "", nil
|
||||
}
|
||||
var subjectName string
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT sp.preferred_name
|
||||
FROM device d
|
||||
JOIN device_binding b ON b.device_id=d.id AND b.tenant_id=d.tenant_id AND b.unbound_at IS NULL
|
||||
JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id AND sp.status='active'
|
||||
WHERE d.id=$1::uuid AND d.tenant_id=$2::uuid AND d.status='registered'
|
||||
AND EXISTS(SELECT 1 FROM consent_snapshot cs WHERE cs.tenant_id=d.tenant_id AND cs.subject_id=sp.id
|
||||
AND cs.effective_consents @> '{"voice_chat":true}'::jsonb)
|
||||
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=d.tenant_id AND cr.subject_id=sp.id
|
||||
AND cr.consent_type='personalized_memory' AND cr.withdrawn_at IS NULL
|
||||
AND (cr.expires_at IS NULL OR cr.expires_at>now()))`, identity.ID, identity.TenantID).Scan(&subjectName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "pgvector-not-eligible-v1", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("prepare memory retrieval query: %w", err)
|
||||
}
|
||||
result, err := s.embedder.Embed(ctx, strings.TrimSpace(subjectName+" "+requestedGoal))
|
||||
if err != nil {
|
||||
if outbox.IsRetryable(err) {
|
||||
return "", "pgvector-degraded-v1", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("generate memory retrieval embedding: %w", err)
|
||||
}
|
||||
literal, err := embeddingprovider.Literal(result.Vector)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("encode memory retrieval embedding: %w", err)
|
||||
}
|
||||
return literal, "pgvector-" + result.ProviderRef + "-normal-v1", nil
|
||||
}
|
||||
|
||||
func (s *Service) Activate(
|
||||
ctx context.Context,
|
||||
identity deviceauth.Identity,
|
||||
conversationID, idempotencyKey, requestID string,
|
||||
rawRequest []byte,
|
||||
input ActivateInput,
|
||||
) (ActivateResponse, bool, error) {
|
||||
tenantID := identity.TenantID
|
||||
scope := "POST /device/v2/conversations/" + conversationID + "/activate"
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID, Scope: scope,
|
||||
Key: idempotencyKey, RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
activation, activateErr := s.repository.Activate(
|
||||
operationContext, tx, identity, conversationID, requestID, input, s.now().UTC(),
|
||||
)
|
||||
if activateErr != nil {
|
||||
return idempotency.Result{}, activateErr
|
||||
}
|
||||
if activation.StartRequested {
|
||||
deduplicationKey := activation.ProviderTaskRef
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"conversation_id": conversationID, "provider_task_ref": activation.ProviderTaskRef,
|
||||
})
|
||||
if _, enqueueErr := s.outbox.Enqueue(operationContext, tx, outbox.NewEvent{
|
||||
TenantID: &tenantID, AggregateType: "conversation", AggregateID: activation.InternalID,
|
||||
EventType: "conversation.agent_start_requested", DeduplicationKey: &deduplicationKey,
|
||||
Payload: payload,
|
||||
}); enqueueErr != nil {
|
||||
return idempotency.Result{}, enqueueErr
|
||||
}
|
||||
}
|
||||
actorPublicID := identity.PublicID
|
||||
if auditErr := s.audit.Record(operationContext, tx, audit.Event{
|
||||
TenantID: &tenantID, ActorType: "device", ActorPublicID: &actorPublicID,
|
||||
Action: "conversation.activate", ResourceType: "conversation", ResourceID: &activation.InternalID,
|
||||
RequestID: requestID, Purpose: "confirm_rtc_join", Result: "success",
|
||||
Detail: map[string]any{"status": activation.Response.Status, "start_requested": activation.StartRequested},
|
||||
}); auditErr != nil {
|
||||
return idempotency.Result{}, auditErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(activation.Response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
return idempotency.Result{StatusCode: http.StatusAccepted, Body: body, ResourceID: &activation.InternalID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return ActivateResponse{}, false, err
|
||||
}
|
||||
var response ActivateResponse
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return ActivateResponse{}, result.Replayed, fmt.Errorf("decode stored activation result: %w", err)
|
||||
}
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetDeviceState(ctx context.Context, identity deviceauth.Identity, conversationID string) (DeviceConversationState, error) {
|
||||
if s.pool == nil {
|
||||
return DeviceConversationState{}, errors.New("conversation state database is not configured")
|
||||
}
|
||||
var state DeviceConversationState
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT c.public_id::text,c.status,COALESCE(c.failure_code,'')
|
||||
FROM conversation c
|
||||
WHERE c.public_id=$1::uuid AND c.device_id=$2::uuid AND c.tenant_id=$3::uuid`,
|
||||
conversationID, identity.ID, identity.TenantID,
|
||||
).Scan(&state.ConversationID, &state.Status, &state.FailureCode)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DeviceConversationState{}, ErrConversationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DeviceConversationState{}, fmt.Errorf("read device conversation state: %w", err)
|
||||
}
|
||||
switch state.Status {
|
||||
case conversation.StatusActive:
|
||||
state.Action = "continue"
|
||||
case conversation.StatusStopping, conversation.StatusCompleted, conversation.StatusFailed, conversation.StatusExpired:
|
||||
state.Action = "leave_room"
|
||||
default:
|
||||
state.Action = "wait"
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(
|
||||
ctx context.Context,
|
||||
identity deviceauth.Identity,
|
||||
conversationID, idempotencyKey, requestID string,
|
||||
rawRequest []byte,
|
||||
input StopInput,
|
||||
) (StopResponse, bool, error) {
|
||||
tenantID := identity.TenantID
|
||||
scope := "POST /device/v2/conversations/" + conversationID + "/stop"
|
||||
result, err := s.executor.Execute(ctx, idempotency.Request{
|
||||
TenantID: &tenantID, ActorKey: "device:" + identity.PublicID, Scope: scope,
|
||||
Key: idempotencyKey, RequestHash: idempotency.Hash(rawRequest), ExpiresAfter: 24 * time.Hour,
|
||||
}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
|
||||
stop, stopErr := s.repository.Stop(operationContext, tx, identity, conversationID, requestID, input, s.now().UTC())
|
||||
if stopErr != nil {
|
||||
return idempotency.Result{}, stopErr
|
||||
}
|
||||
if stop.StopRequested {
|
||||
deduplicationKey := stop.ProviderTaskRef
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"conversation_id": conversationID, "provider_task_ref": stop.ProviderTaskRef, "reason": input.Reason,
|
||||
})
|
||||
if _, enqueueErr := s.outbox.Enqueue(operationContext, tx, outbox.NewEvent{
|
||||
TenantID: &tenantID, AggregateType: "conversation", AggregateID: stop.InternalID,
|
||||
EventType: "conversation.agent_stop_requested", DeduplicationKey: &deduplicationKey,
|
||||
Payload: payload,
|
||||
}); enqueueErr != nil {
|
||||
return idempotency.Result{}, enqueueErr
|
||||
}
|
||||
}
|
||||
actorPublicID := identity.PublicID
|
||||
if auditErr := s.audit.Record(operationContext, tx, audit.Event{
|
||||
TenantID: &tenantID, ActorType: "device", ActorPublicID: &actorPublicID,
|
||||
Action: "conversation.stop", ResourceType: "conversation", ResourceID: &stop.InternalID,
|
||||
RequestID: requestID, Purpose: input.Reason, Result: "success",
|
||||
Detail: map[string]any{"status": stop.Response.Status, "stop_requested": stop.StopRequested},
|
||||
}); auditErr != nil {
|
||||
return idempotency.Result{}, auditErr
|
||||
}
|
||||
body, marshalErr := json.Marshal(stop.Response)
|
||||
if marshalErr != nil {
|
||||
return idempotency.Result{}, marshalErr
|
||||
}
|
||||
return idempotency.Result{StatusCode: http.StatusAccepted, Body: body, ResourceID: &stop.InternalID}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return StopResponse{}, false, err
|
||||
}
|
||||
var response StopResponse
|
||||
if err := json.Unmarshal(result.Body, &response); err != nil {
|
||||
return StopResponse{}, result.Replayed, fmt.Errorf("decode stored stop result: %w", err)
|
||||
}
|
||||
_ = s.leases.End(ctx, conversationID, identity.PublicID, s.now().UTC())
|
||||
return response, result.Replayed, nil
|
||||
}
|
||||
Reference in New Issue
Block a user