Files
story/internal/orchestration/service.go
T

372 lines
15 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
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"
2026-09-06 17:04:11 +08:00
"data-terminal-cloud/internal/memorytool"
2026-08-26 06:41:07 +08:00
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/rtc"
2026-09-06 17:04:11 +08:00
"data-terminal-cloud/internal/vikingmemory"
2026-08-26 06:41:07 +08:00
"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
2026-09-06 17:04:11 +08:00
HardwareBotID string
HardwareTransport string
SubtitleCallbackURL string
SubtitleCallbackSecret []byte
SubtitlePrimary bool
2026-08-26 06:41:07 +08:00
}
type Service struct {
2026-09-06 17:04:11 +08:00
viking *vikingmemory.Service
executor *idempotency.Executor
repository *Repository
audit *audit.Repository
outbox *outbox.Store
tokens rtc.TokenIssuer
leases LeaseCache
options ServiceOptions
pool *pgxpool.Pool
embedder embeddingprovider.Provider
hardwareMemoryTool *memorytool.TokenSigner
hardwareMemoryToolURL string
hardwareMemoryToolTTL time.Duration
now func() time.Time
2026-08-26 06:41:07 +08:00
}
2026-09-06 17:04:11 +08:00
func (s *Service) ConfigureVikingMemory(memory *vikingmemory.Service) { s.viking = memory }
2026-08-26 06:41:07 +08:00
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
}
2026-09-06 17:04:11 +08:00
func (s *Service) ConfigureHardwareMemoryTool(signer *memorytool.TokenSigner, endpoint string, ttl time.Duration) error {
if signer == nil || strings.TrimSpace(endpoint) == "" || ttl <= 0 {
return errors.New("hardware memory tool signer, endpoint and TTL are required")
}
s.hardwareMemoryTool = signer
s.hardwareMemoryToolURL = strings.TrimSpace(endpoint)
s.hardwareMemoryToolTTL = ttl
return nil
}
2026-08-26 06:41:07 +08:00
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"
}
2026-09-06 17:04:11 +08:00
if options.HardwareTransport == "" {
options.HardwareTransport = "high_quality"
}
if options.HardwareTransport != "high_quality" && options.HardwareTransport != "low_load" {
return nil, errors.New("hardware agent transport must be high_quality or low_load")
}
2026-08-26 06:41:07 +08:00
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
}